chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Stores Scope
Applies to Zustand stores under `apps/sim/**/stores/**` and `apps/sim/**/store.ts`.
Store authoring rules — `devtools` middleware, `persist` + `partialize` only for reload-surviving state, immutable updates, `set((state) => ...)` for previous-state-dependent updates, `reset()` action, splitting complex stores into `store.ts` + `types.ts`, and `_hasHydrated` tracking — live in `.claude/rules/sim-stores.md`.
+1
View File
@@ -0,0 +1 @@
export { useCanvasModeStore } from './store'
+22
View File
@@ -0,0 +1,22 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export type CanvasMode = 'cursor' | 'hand'
interface CanvasModeState {
mode: CanvasMode
setMode: (mode: CanvasMode) => void
}
export const useCanvasModeStore = create<CanvasModeState>()(
devtools(
persist(
(set) => ({
mode: 'hand',
setMode: (mode) => set({ mode }),
}),
{ name: 'canvas-mode' }
),
{ name: 'canvas-mode-store' }
)
)
+281
View File
@@ -0,0 +1,281 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { ChatMessage, ChatState } from './types'
import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils'
const logger = createLogger('ChatStore')
/**
* Maximum number of messages to store across all workflows
*/
const MAX_MESSAGES = 50
/**
* Floating chat dimensions
*/
const DEFAULT_WIDTH = 305
const DEFAULT_HEIGHT = 286
/**
* Floating chat store
* Manages the open/close state, position, messages, and all chat functionality
*/
export const useChatStore = create<ChatState>()(
devtools(
persist(
(set, get) => ({
isChatOpen: false,
chatPosition: null,
chatWidth: DEFAULT_WIDTH,
chatHeight: DEFAULT_HEIGHT,
setIsChatOpen: (open) => {
set({ isChatOpen: open })
},
setChatPosition: (position) => {
set({ chatPosition: position })
},
setChatDimensions: (dimensions) => {
set({
chatWidth: Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, dimensions.width)),
chatHeight: Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, dimensions.height)),
})
},
resetChatPosition: () => {
set({ chatPosition: null })
},
messages: [],
selectedWorkflowOutputs: {},
conversationIds: {},
addMessage: (message) => {
set((state) => {
const newMessage: ChatMessage = {
...message,
id: (message as any).id ?? generateId(),
timestamp: (message as any).timestamp ?? new Date().toISOString(),
}
const newMessages = [newMessage, ...state.messages].slice(0, MAX_MESSAGES)
return { messages: newMessages }
})
},
clearChat: (workflowId: string | null) => {
set((state) => {
const newState = {
messages: state.messages.filter(
(message) => !workflowId || message.workflowId !== workflowId
),
}
if (workflowId) {
const newConversationIds = { ...state.conversationIds }
newConversationIds[workflowId] = generateId()
return {
...newState,
conversationIds: newConversationIds,
}
}
return {
...newState,
conversationIds: {},
}
})
},
exportChatCSV: (workflowId: string) => {
const messages = get().messages.filter((message) => message.workflowId === workflowId)
if (messages.length === 0) {
return
}
/**
* Safely stringify and escape CSV values
*/
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
let stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
// Truncate very long strings
stringValue = truncate(stringValue, 2000)
// Escape quotes and wrap in quotes if contains special characters
if (
stringValue.includes('"') ||
stringValue.includes(',') ||
stringValue.includes('\n')
) {
stringValue = `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const headers = ['timestamp', 'type', 'content']
const sortedMessages = [...messages].sort(
(a: ChatMessage, b: ChatMessage) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
const csvRows = [
headers.join(','),
...sortedMessages.map((message: ChatMessage) =>
[
formatCSVValue(message.timestamp),
formatCSVValue(message.type),
formatCSVValue(message.content),
].join(',')
),
]
const csvContent = csvRows.join('\n')
const now = new Date()
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
const filename = `chat-${workflowId}-${timestamp}.csv`
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
},
setSelectedWorkflowOutput: (workflowId, outputIds) => {
set((state) => {
const newSelections = { ...state.selectedWorkflowOutputs }
if (outputIds.length === 0) {
delete newSelections[workflowId]
} else {
newSelections[workflowId] = [...new Set(outputIds)]
}
return { selectedWorkflowOutputs: newSelections }
})
},
getSelectedWorkflowOutput: (workflowId) => {
return get().selectedWorkflowOutputs[workflowId] || []
},
getConversationId: (workflowId) => {
const state = get()
if (!state.conversationIds[workflowId]) {
return get().generateNewConversationId(workflowId)
}
return state.conversationIds[workflowId]
},
generateNewConversationId: (workflowId) => {
const newId = generateId()
set((state) => {
const newConversationIds = { ...state.conversationIds }
newConversationIds[workflowId] = newId
return { conversationIds: newConversationIds }
})
return newId
},
appendMessageContent: (messageId, content) => {
logger.debug('[ChatStore] appendMessageContent called', {
messageId,
contentLength: content.length,
content: content.substring(0, 30),
})
set((state) => {
const message = state.messages.find((m) => m.id === messageId)
if (!message) {
logger.warn('[ChatStore] Message not found for appending', { messageId })
}
const newMessages = state.messages.map((message) => {
if (message.id === messageId) {
const newContent =
typeof message.content === 'string'
? message.content + content
: message.content
? String(message.content) + content
: content
logger.debug('[ChatStore] Updated message content', {
messageId,
oldLength: typeof message.content === 'string' ? message.content.length : 0,
newLength: newContent.length,
addedLength: content.length,
})
return {
...message,
content: newContent,
}
}
return message
})
return { messages: newMessages }
})
},
finalizeMessageStream: (messageId) => {
set((state) => {
const newMessages = state.messages.map((message) => {
if (message.id === messageId) {
const { isStreaming, ...rest } = message
return rest
}
return message
})
return { messages: newMessages }
})
},
}),
{
name: 'chat-store',
/**
* Persist only the durable chat state — message history (with transient
* blob `previewUrl`s stripped since they are not valid across reloads),
* per-workflow output selections and conversation ids, and the floating
* chat's open state, position, and dimensions. Actions and any transient
* UI flags are intentionally excluded.
*/
partialize: (state) => ({
isChatOpen: state.isChatOpen,
chatPosition: state.chatPosition,
chatWidth: state.chatWidth,
chatHeight: state.chatHeight,
selectedWorkflowOutputs: state.selectedWorkflowOutputs,
conversationIds: state.conversationIds,
messages: state.messages.map((msg) => ({
...msg,
attachments: msg.attachments?.map((att) => ({
...att,
previewUrl: undefined,
})),
})),
}),
}
)
)
)
+70
View File
@@ -0,0 +1,70 @@
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
/**
* Position interface for floating chat
*/
export interface ChatPosition {
x: number
y: number
}
/**
* Chat message interface
*/
export interface ChatMessage {
id: string
content: string | any
workflowId: string
type: 'user' | 'workflow'
timestamp: string
blockId?: string
isStreaming?: boolean
attachments?: ChatMessageAttachment[]
}
/**
* Output configuration for chat deployments
*/
export interface OutputConfig {
blockId: string
path: string
}
/**
* Chat dimensions interface
*/
interface ChatDimensions {
width: number
height: number
}
/**
* Chat store state interface combining UI state and message data
*/
export interface ChatState {
// UI State
isChatOpen: boolean
chatPosition: ChatPosition | null
chatWidth: number
chatHeight: number
setIsChatOpen: (open: boolean) => void
setChatPosition: (position: ChatPosition) => void
setChatDimensions: (dimensions: ChatDimensions) => void
resetChatPosition: () => void
// Message State
messages: ChatMessage[]
selectedWorkflowOutputs: Record<string, string[]>
conversationIds: Record<string, string>
// Message Actions
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & { id?: string }) => void
clearChat: (workflowId: string | null) => void
exportChatCSV: (workflowId: string) => void
setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void
getSelectedWorkflowOutput: (workflowId: string) => string[]
appendMessageContent: (messageId: string, content: string) => void
finalizeMessageStream: (messageId: string) => void
getConversationId: (workflowId: string) => string
generateNewConversationId: (workflowId: string) => string
}
+112
View File
@@ -0,0 +1,112 @@
import type { ChatPosition } from './types'
/**
* Floating chat dimensions
*/
const DEFAULT_WIDTH = 305
const DEFAULT_HEIGHT = 286
/**
* Minimum chat dimensions (same as baseline default)
*/
export const MIN_CHAT_WIDTH = DEFAULT_WIDTH
export const MIN_CHAT_HEIGHT = DEFAULT_HEIGHT
/**
* Maximum chat dimensions
*/
export const MAX_CHAT_WIDTH = 500
export const MAX_CHAT_HEIGHT = 600
/** Inset gap between the viewport edge and the content window */
const CONTENT_WINDOW_GAP = 8
/**
* Calculate default position in top right of canvas, offset from panel edge
*/
const calculateDefaultPosition = (): ChatPosition => {
if (typeof window === 'undefined') {
return { x: 100, y: 100 }
}
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const x = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - 32 - DEFAULT_WIDTH
const y = CONTENT_WINDOW_GAP + 32
return { x, y }
}
/**
* Get the default chat dimensions
*/
export const getDefaultChatDimensions = () => ({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
})
/**
* Calculate constrained position ensuring chat stays within bounds
*/
export const constrainChatPosition = (
position: ChatPosition,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): ChatPosition => {
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)),
}
}
/**
* Get chat position (default if not set or if invalid)
* @param storedPosition - Stored position from store
* @param width - Chat width
* @param height - Chat height
* @returns Valid chat position
*/
export const getChatPosition = (
storedPosition: ChatPosition | null,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): ChatPosition => {
if (!storedPosition) {
return calculateDefaultPosition()
}
// Validate stored position is still within bounds
const constrained = constrainChatPosition(storedPosition, width, height)
// If position significantly changed, it's likely invalid (window resized, etc)
// Return default position
const deltaX = Math.abs(constrained.x - storedPosition.x)
const deltaY = Math.abs(constrained.y - storedPosition.y)
if (deltaX > 100 || deltaY > 100) {
return calculateDefaultPosition()
}
return constrained
}
+68
View File
@@ -0,0 +1,68 @@
const API_ENDPOINTS = {
ENVIRONMENT: '/api/environment',
SETTINGS: '/api/users/me/settings',
WORKFLOWS: '/api/workflows',
WORKSPACE_PERMISSIONS: (id: string) => `/api/workspaces/${id}/permissions`,
WORKSPACE_ENVIRONMENT: (id: string) => `/api/workspaces/${id}/environment`,
WORKSPACE_BYOK_KEYS: (id: string) => `/api/workspaces/${id}/byok-keys`,
}
/**
* Layout dimension constants.
*
* These values must stay in sync with:
* - `globals.css` (CSS variable defaults)
* - `layout.tsx` (blocking script validations)
*
* @see globals.css for CSS variable definitions
* @see layout.tsx for pre-hydration script that reads localStorage
*/
/** Sidebar width constraints */
export const SIDEBAR_WIDTH = {
DEFAULT: 248,
MIN: 248,
/** Width when sidebar is collapsed to icon-only mode */
COLLAPSED: 51,
/** Maximum is 30% of viewport, enforced dynamically */
MAX_PERCENTAGE: 0.3,
} as const
/** Right panel width constraints */
export const PANEL_WIDTH = {
DEFAULT: 320,
MIN: 290,
/** Maximum is 40% of viewport, enforced dynamically */
MAX_PERCENTAGE: 0.4,
} as const
/** Terminal height constraints */
export const TERMINAL_HEIGHT = {
DEFAULT: 206,
MIN: 30,
/** Maximum is 70% of viewport, enforced dynamically */
MAX_PERCENTAGE: 0.7,
} as const
/** Editor connections section height constraints */
export const EDITOR_CONNECTIONS_HEIGHT = {
DEFAULT: 172,
MIN: 30,
MAX: 300,
} as const
/** Output panel (terminal execution results) width constraints */
export const OUTPUT_PANEL_WIDTH = {
DEFAULT: 560,
MIN: 280,
} as const
/** Home chat resource panel (MothershipView) width constraints */
export const MOTHERSHIP_WIDTH = {
MIN: 280,
/** Maximum is 65% of viewport, enforced dynamically */
MAX_PERCENTAGE: 0.65,
} as const
/** Terminal block column width - minimum width for the logs column */
export const TERMINAL_BLOCK_COLUMN_WIDTH = 240 as const
+8
View File
@@ -0,0 +1,8 @@
export {
useExecutionStore,
useIsBlockActive,
useIsCurrentWorkflowExecuting,
useLastRunEdges,
useLastRunPath,
} from './store'
export { defaultWorkflowExecutionState } from './types'
+620
View File
@@ -0,0 +1,620 @@
/**
* @vitest-environment node
*
* Tests for the per-workflow execution store.
*
* These tests cover:
* - Default state for unknown workflows
* - Per-workflow state isolation
* - Execution lifecycle (start/stop clears run path)
* - Block and edge run status tracking
* - Active block management
* - The {@link ExecutionStatus} enum and its derived `isExecuting` /
* `isDebugging` booleans (exhaustive status → flag mapping + transitions)
* - Execution snapshot management
* - Store reset
* - Immutability guarantees
*
* @remarks
* The store under test transitively imports the workflow registry store,
* which drags in the block registry and emcn icon CSS. To keep this a true
* unit test that loads under the node environment, the registry store is
* mocked to a minimal stub (the store actions never touch it — only the
* convenience hooks do, which are not exercised here).
*
* Most tests use `it.concurrent` with unique workflow IDs per test.
* Because the store isolates state by workflow ID, concurrent tests
* do not interfere with each other. The `reset` and `immutability`
* groups run sequentially since they affect or read global store state.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: Object.assign(
vi.fn(() => null),
{ getState: vi.fn(() => ({ activeWorkflowId: null })) }
),
}))
vi.unmock('@/stores/execution/store')
vi.unmock('@/stores/execution/types')
import { useExecutionStore } from '@/stores/execution/store'
import {
defaultWorkflowExecutionState,
deriveExecutionFlags,
type ExecutionStatus,
initialState,
} from '@/stores/execution/types'
describe('useExecutionStore', () => {
describe('getWorkflowExecution', () => {
it.concurrent('should return default state for an unknown workflow', () => {
const state = useExecutionStore.getState().getWorkflowExecution('wf-get-default')
expect(state.status).toBe('idle')
expect(state.isExecuting).toBe(false)
expect(state.isDebugging).toBe(false)
expect(state.activeBlockIds.size).toBe(0)
expect(state.pendingBlocks).toEqual([])
expect(state.executor).toBeNull()
expect(state.debugContext).toBeNull()
expect(state.lastRunPath.size).toBe(0)
expect(state.lastRunEdges.size).toBe(0)
})
it.concurrent(
'should return fresh collections for unknown workflows, not shared references',
() => {
const stateA = useExecutionStore.getState().getWorkflowExecution('wf-fresh-a')
const stateB = useExecutionStore.getState().getWorkflowExecution('wf-fresh-b')
expect(stateA.activeBlockIds).not.toBe(stateB.activeBlockIds)
expect(stateA.lastRunPath).not.toBe(stateB.lastRunPath)
expect(stateA.lastRunEdges).not.toBe(stateB.lastRunEdges)
expect(stateA.activeBlockIds).not.toBe(defaultWorkflowExecutionState.activeBlockIds)
}
)
it.concurrent('should return the stored state after a mutation', () => {
useExecutionStore.getState().setIsExecuting('wf-get-stored', true)
const state = useExecutionStore.getState().getWorkflowExecution('wf-get-stored')
expect(state.isExecuting).toBe(true)
})
})
describe('deriveExecutionFlags', () => {
it.concurrent('maps every status to the documented legacy booleans', () => {
const cases: Array<[ExecutionStatus, boolean, boolean]> = [
['idle', false, false],
['running', true, false],
['debugging', true, true],
]
for (const [status, isExecuting, isDebugging] of cases) {
expect(deriveExecutionFlags(status)).toEqual({ isExecuting, isDebugging })
}
})
})
describe('setIsExecuting', () => {
it.concurrent('should set isExecuting to true (status running)', () => {
useExecutionStore.getState().setIsExecuting('wf-exec-true', true)
const state = useExecutionStore.getState().getWorkflowExecution('wf-exec-true')
expect(state.isExecuting).toBe(true)
expect(state.status).toBe('running')
})
it.concurrent('should set isExecuting to false (status idle)', () => {
useExecutionStore.getState().setIsExecuting('wf-exec-false', true)
useExecutionStore.getState().setIsExecuting('wf-exec-false', false)
const state = useExecutionStore.getState().getWorkflowExecution('wf-exec-false')
expect(state.isExecuting).toBe(false)
expect(state.status).toBe('idle')
})
it.concurrent('should clear lastRunPath and lastRunEdges when starting execution', () => {
const wf = 'wf-exec-clears-run'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges.size).toBe(1)
useExecutionStore.getState().setIsExecuting(wf, true)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.lastRunPath.size).toBe(0)
expect(state.lastRunEdges.size).toBe(0)
expect(state.isExecuting).toBe(true)
})
it.concurrent('should NOT clear lastRunPath when stopping execution', () => {
const wf = 'wf-exec-stop-keeps-path'
useExecutionStore.getState().setIsExecuting(wf, true)
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().setIsExecuting(wf, false)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.isExecuting).toBe(false)
expect(state.lastRunPath.get('block-1')).toBe('success')
})
it.concurrent('starting a debug run then setIsExecuting(true) clears the run path', () => {
const wf = 'wf-exec-debug-start-clears'
useExecutionStore.getState().setIsExecuting(wf, true)
useExecutionStore.getState().setIsDebugging(wf, true)
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().setIsExecuting(wf, true)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.status).toBe('debugging')
expect(state.isExecuting).toBe(true)
expect(state.isDebugging).toBe(true)
expect(state.lastRunPath.size).toBe(0)
expect(state.lastRunEdges.size).toBe(0)
})
})
describe('setIsDebugging', () => {
it.concurrent('should toggle debug mode', () => {
const wf = 'wf-debug-toggle'
useExecutionStore.getState().setIsDebugging(wf, true)
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(true)
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('debugging')
useExecutionStore.getState().setIsDebugging(wf, false)
expect(useExecutionStore.getState().getWorkflowExecution(wf).isDebugging).toBe(false)
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('running')
})
it.concurrent('setIsDebugging(false) while idle is a no-op (stays idle)', () => {
const wf = 'wf-debug-false-idle'
useExecutionStore.getState().setIsDebugging(wf, false)
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('idle')
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(false)
})
it.concurrent('setIsDebugging(false) while running keeps running', () => {
const wf = 'wf-debug-false-running'
useExecutionStore.getState().setIsExecuting(wf, true)
useExecutionStore.getState().setIsDebugging(wf, false)
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('running')
expect(useExecutionStore.getState().getWorkflowExecution(wf).isExecuting).toBe(true)
})
it.concurrent('does not clear the run path when entering debug mode', () => {
const wf = 'wf-debug-keeps-path'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().setIsDebugging(wf, true)
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
'success'
)
})
})
describe('status enum', () => {
it.concurrent('idle derives both flags false', () => {
const wf = 'wf-status-idle'
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.status).toBe('idle')
expect(state.isExecuting).toBe(false)
expect(state.isDebugging).toBe(false)
})
it.concurrent('running derives isExecuting only', () => {
const wf = 'wf-status-running'
useExecutionStore.getState().setStatus(wf, 'running')
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.status).toBe('running')
expect(state.isExecuting).toBe(true)
expect(state.isDebugging).toBe(false)
})
it.concurrent('debugging derives both flags true', () => {
const wf = 'wf-status-debugging'
useExecutionStore.getState().setStatus(wf, 'debugging')
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.status).toBe('debugging')
expect(state.isExecuting).toBe(true)
expect(state.isDebugging).toBe(true)
})
it.concurrent('setStatus preserves the run path unless clearRunPath is passed', () => {
const wf = 'wf-status-path-rules'
useExecutionStore.getState().setStatus(wf, 'debugging')
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
useExecutionStore.getState().setStatus(wf, 'running')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(1)
useExecutionStore.getState().setStatus(wf, 'running', { clearRunPath: true })
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.size).toBe(0)
})
it.concurrent('the derived booleans always agree with the stored status', () => {
const wf = 'wf-status-no-drift'
for (const status of ['idle', 'running', 'debugging', 'idle'] as const) {
useExecutionStore.getState().setStatus(wf, status)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect({ isExecuting: state.isExecuting, isDebugging: state.isDebugging }).toEqual(
deriveExecutionFlags(status)
)
}
})
it.concurrent('setIsExecuting(true) preserves an active debug session', () => {
const wf = 'wf-status-debug-preserve'
useExecutionStore.getState().setStatus(wf, 'debugging')
useExecutionStore.getState().setIsExecuting(wf, true)
expect(useExecutionStore.getState().getWorkflowExecution(wf).status).toBe('debugging')
})
it.concurrent('setIsExecuting(false) returns to idle from any mode', () => {
const wf = 'wf-status-stop'
useExecutionStore.getState().setStatus(wf, 'debugging')
useExecutionStore.getState().setIsExecuting(wf, false)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.status).toBe('idle')
expect(state.isExecuting).toBe(false)
expect(state.isDebugging).toBe(false)
})
})
describe('setActiveBlocks', () => {
it.concurrent('should set the active block IDs', () => {
const wf = 'wf-active-set'
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-1', 'block-2']))
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.activeBlockIds.has('block-1')).toBe(true)
expect(state.activeBlockIds.has('block-2')).toBe(true)
expect(state.activeBlockIds.size).toBe(2)
})
it.concurrent('should replace the previous set', () => {
const wf = 'wf-active-replace'
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-1']))
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-2']))
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.activeBlockIds.has('block-1')).toBe(false)
expect(state.activeBlockIds.has('block-2')).toBe(true)
})
it.concurrent('should clear active blocks with an empty set', () => {
const wf = 'wf-active-clear'
useExecutionStore.getState().setActiveBlocks(wf, new Set(['block-1']))
useExecutionStore.getState().setActiveBlocks(wf, new Set())
expect(useExecutionStore.getState().getWorkflowExecution(wf).activeBlockIds.size).toBe(0)
})
})
describe('setPendingBlocks', () => {
it.concurrent('should set pending block IDs', () => {
const wf = 'wf-pending'
useExecutionStore.getState().setPendingBlocks(wf, ['block-1', 'block-2'])
expect(useExecutionStore.getState().getWorkflowExecution(wf).pendingBlocks).toEqual([
'block-1',
'block-2',
])
})
})
describe('setExecutor', () => {
it.concurrent('should store and clear executor', () => {
const wf = 'wf-executor'
const mockExecutor = { run: () => {} } as any
useExecutionStore.getState().setExecutor(wf, mockExecutor)
expect(useExecutionStore.getState().getWorkflowExecution(wf).executor).toBe(mockExecutor)
useExecutionStore.getState().setExecutor(wf, null)
expect(useExecutionStore.getState().getWorkflowExecution(wf).executor).toBeNull()
})
})
describe('setDebugContext', () => {
it.concurrent('should store and clear debug context', () => {
const wf = 'wf-debug-ctx'
const mockContext = { blockId: 'block-1' } as any
useExecutionStore.getState().setDebugContext(wf, mockContext)
expect(useExecutionStore.getState().getWorkflowExecution(wf).debugContext).toBe(mockContext)
useExecutionStore.getState().setDebugContext(wf, null)
expect(useExecutionStore.getState().getWorkflowExecution(wf).debugContext).toBeNull()
})
})
describe('setBlockRunStatus', () => {
it.concurrent('should record a success status for a block', () => {
const wf = 'wf-block-success'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
'success'
)
})
it.concurrent('should record an error status for a block', () => {
const wf = 'wf-block-error'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'error')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
'error'
)
})
it.concurrent('should accumulate statuses for multiple blocks', () => {
const wf = 'wf-block-accum'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().setBlockRunStatus(wf, 'block-2', 'error')
const runPath = useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath
expect(runPath.get('block-1')).toBe('success')
expect(runPath.get('block-2')).toBe('error')
expect(runPath.size).toBe(2)
})
it.concurrent('should overwrite a previous status for the same block', () => {
const wf = 'wf-block-overwrite'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'error')
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath.get('block-1')).toBe(
'success'
)
})
})
describe('setEdgeRunStatus', () => {
it.concurrent('should record a success status for an edge', () => {
const wf = 'wf-edge-success'
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
expect(useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges.get('edge-1')).toBe(
'success'
)
})
it.concurrent('should accumulate statuses for multiple edges', () => {
const wf = 'wf-edge-accum'
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-2', 'error')
const runEdges = useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges
expect(runEdges.get('edge-1')).toBe('success')
expect(runEdges.get('edge-2')).toBe('error')
expect(runEdges.size).toBe(2)
})
})
describe('clearRunPath', () => {
it.concurrent('should clear both lastRunPath and lastRunEdges', () => {
const wf = 'wf-clear-both'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
useExecutionStore.getState().clearRunPath(wf)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.lastRunPath.size).toBe(0)
expect(state.lastRunEdges.size).toBe(0)
})
it.concurrent('should not affect other workflow state', () => {
const wf = 'wf-clear-other'
useExecutionStore.getState().setIsExecuting(wf, true)
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
useExecutionStore.getState().clearRunPath(wf)
const state = useExecutionStore.getState().getWorkflowExecution(wf)
expect(state.isExecuting).toBe(true)
expect(state.lastRunPath.size).toBe(0)
})
})
describe('per-workflow isolation', () => {
it.concurrent('should keep execution state independent between workflows', () => {
const wfA = 'wf-iso-exec-a'
const wfB = 'wf-iso-exec-b'
useExecutionStore.getState().setIsExecuting(wfA, true)
useExecutionStore.getState().setActiveBlocks(wfA, new Set(['block-a1']))
useExecutionStore.getState().setIsExecuting(wfB, false)
useExecutionStore.getState().setActiveBlocks(wfB, new Set(['block-b1', 'block-b2']))
const stateA = useExecutionStore.getState().getWorkflowExecution(wfA)
const stateB = useExecutionStore.getState().getWorkflowExecution(wfB)
expect(stateA.isExecuting).toBe(true)
expect(stateA.activeBlockIds.size).toBe(1)
expect(stateA.activeBlockIds.has('block-a1')).toBe(true)
expect(stateB.isExecuting).toBe(false)
expect(stateB.activeBlockIds.size).toBe(2)
expect(stateB.activeBlockIds.has('block-b1')).toBe(true)
})
it.concurrent('should keep run path independent between workflows', () => {
const wfA = 'wf-iso-path-a'
const wfB = 'wf-iso-path-b'
useExecutionStore.getState().setBlockRunStatus(wfA, 'block-1', 'success')
useExecutionStore.getState().setEdgeRunStatus(wfA, 'edge-1', 'success')
useExecutionStore.getState().setBlockRunStatus(wfB, 'block-1', 'error')
useExecutionStore.getState().setEdgeRunStatus(wfB, 'edge-1', 'error')
const stateA = useExecutionStore.getState().getWorkflowExecution(wfA)
const stateB = useExecutionStore.getState().getWorkflowExecution(wfB)
expect(stateA.lastRunPath.get('block-1')).toBe('success')
expect(stateA.lastRunEdges.get('edge-1')).toBe('success')
expect(stateB.lastRunPath.get('block-1')).toBe('error')
expect(stateB.lastRunEdges.get('edge-1')).toBe('error')
})
it.concurrent('should not affect workflow B when starting execution on workflow A', () => {
const wfA = 'wf-iso-start-a'
const wfB = 'wf-iso-start-b'
useExecutionStore.getState().setBlockRunStatus(wfA, 'block-1', 'success')
useExecutionStore.getState().setBlockRunStatus(wfB, 'block-1', 'success')
useExecutionStore.getState().setIsExecuting(wfA, true)
const stateA = useExecutionStore.getState().getWorkflowExecution(wfA)
const stateB = useExecutionStore.getState().getWorkflowExecution(wfB)
expect(stateA.lastRunPath.size).toBe(0)
expect(stateB.lastRunPath.get('block-1')).toBe('success')
})
it.concurrent('should not affect workflow B when clearing run path on workflow A', () => {
const wfA = 'wf-iso-clear-a'
const wfB = 'wf-iso-clear-b'
useExecutionStore.getState().setBlockRunStatus(wfA, 'block-1', 'success')
useExecutionStore.getState().setBlockRunStatus(wfB, 'block-2', 'error')
useExecutionStore.getState().clearRunPath(wfA)
expect(useExecutionStore.getState().getWorkflowExecution(wfA).lastRunPath.size).toBe(0)
expect(
useExecutionStore.getState().getWorkflowExecution(wfB).lastRunPath.get('block-2')
).toBe('error')
})
})
describe('execution snapshots', () => {
const mockSnapshot = {
blockStates: {},
blockLogs: [],
executionOrder: [],
} as any
it.concurrent('should store a snapshot', () => {
const wf = 'wf-snap-store'
useExecutionStore.getState().setLastExecutionSnapshot(wf, mockSnapshot)
expect(useExecutionStore.getState().getLastExecutionSnapshot(wf)).toBe(mockSnapshot)
})
it.concurrent('should return undefined for unknown workflows', () => {
expect(
useExecutionStore.getState().getLastExecutionSnapshot('wf-snap-unknown')
).toBeUndefined()
})
it.concurrent('should clear a snapshot', () => {
const wf = 'wf-snap-clear'
useExecutionStore.getState().setLastExecutionSnapshot(wf, mockSnapshot)
useExecutionStore.getState().clearLastExecutionSnapshot(wf)
expect(useExecutionStore.getState().getLastExecutionSnapshot(wf)).toBeUndefined()
})
it.concurrent('should keep snapshots independent between workflows', () => {
const wfA = 'wf-snap-iso-a'
const wfB = 'wf-snap-iso-b'
const snapshotB = { blockStates: { x: 1 } } as any
useExecutionStore.getState().setLastExecutionSnapshot(wfA, mockSnapshot)
useExecutionStore.getState().setLastExecutionSnapshot(wfB, snapshotB)
expect(useExecutionStore.getState().getLastExecutionSnapshot(wfA)).toBe(mockSnapshot)
expect(useExecutionStore.getState().getLastExecutionSnapshot(wfB)).toBe(snapshotB)
})
})
describe('reset', () => {
beforeEach(() => {
useExecutionStore.setState(initialState)
})
it('should clear all workflow execution state', () => {
useExecutionStore.getState().setIsExecuting('wf-reset-a', true)
useExecutionStore.getState().setBlockRunStatus('wf-reset-a', 'block-1', 'success')
useExecutionStore.getState().setLastExecutionSnapshot('wf-reset-a', {} as any)
useExecutionStore.getState().reset()
const state = useExecutionStore.getState()
expect(state.workflowExecutions.size).toBe(0)
expect(state.lastExecutionSnapshots.size).toBe(0)
})
it('should return defaults for all workflows after reset', () => {
useExecutionStore.getState().setIsExecuting('wf-reset-b', true)
useExecutionStore.getState().setIsExecuting('wf-reset-c', true)
useExecutionStore.getState().reset()
expect(useExecutionStore.getState().getWorkflowExecution('wf-reset-b').isExecuting).toBe(
false
)
expect(useExecutionStore.getState().getWorkflowExecution('wf-reset-c').isExecuting).toBe(
false
)
})
})
describe('immutability', () => {
beforeEach(() => {
useExecutionStore.setState(initialState)
})
it('should create a new workflowExecutions map on each mutation', () => {
const mapBefore = useExecutionStore.getState().workflowExecutions
useExecutionStore.getState().setIsExecuting('wf-immut-map', true)
const mapAfter = useExecutionStore.getState().workflowExecutions
expect(mapBefore).not.toBe(mapAfter)
})
it('should create a new lastRunPath map when adding block status', () => {
const wf = 'wf-immut-path'
useExecutionStore.getState().setBlockRunStatus(wf, 'block-1', 'success')
const pathBefore = useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath
useExecutionStore.getState().setBlockRunStatus(wf, 'block-2', 'error')
const pathAfter = useExecutionStore.getState().getWorkflowExecution(wf).lastRunPath
expect(pathBefore).not.toBe(pathAfter)
expect(pathBefore.size).toBe(1)
expect(pathAfter.size).toBe(2)
})
it('should create a new lastRunEdges map when adding edge status', () => {
const wf = 'wf-immut-edges'
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-1', 'success')
const edgesBefore = useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges
useExecutionStore.getState().setEdgeRunStatus(wf, 'edge-2', 'error')
const edgesAfter = useExecutionStore.getState().getWorkflowExecution(wf).lastRunEdges
expect(edgesBefore).not.toBe(edgesAfter)
expect(edgesBefore.size).toBe(1)
expect(edgesAfter.size).toBe(2)
})
it.concurrent('should not mutate the default state constant', () => {
useExecutionStore.getState().setBlockRunStatus('wf-immut-const', 'block-1', 'success')
expect(defaultWorkflowExecutionState.lastRunPath.size).toBe(0)
expect(defaultWorkflowExecutionState.lastRunEdges.size).toBe(0)
expect(defaultWorkflowExecutionState.activeBlockIds.size).toBe(0)
})
})
})
+259
View File
@@ -0,0 +1,259 @@
import { create } from 'zustand'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import {
type BlockRunStatus,
defaultWorkflowExecutionState,
deriveExecutionFlags,
type EdgeRunStatus,
type ExecutionActions,
type ExecutionState,
type ExecutionStatus,
initialState,
type WorkflowExecutionState,
} from './types'
/**
* Returns the execution state for a workflow, creating a fresh default if absent.
*
* @remarks
* When the workflow has no entry in the map, fresh `Set` and `Map` instances
* are created so that callers never share mutable collections with
* {@link defaultWorkflowExecutionState}.
*/
function getOrCreate(
map: Map<string, WorkflowExecutionState>,
workflowId: string
): WorkflowExecutionState {
return (
map.get(workflowId) ?? {
...defaultWorkflowExecutionState,
activeBlockIds: new Set<string>(),
lastRunPath: new Map<string, BlockRunStatus>(),
lastRunEdges: new Map<string, EdgeRunStatus>(),
}
)
}
/**
* Immutably updates a single workflow's execution state within the map.
*
* Creates a shallow copy of the outer map, merges the patch into the
* target workflow's entry, and returns the new map. This ensures Zustand
* detects the top-level reference change and notifies subscribers.
*/
function updatedMap(
map: Map<string, WorkflowExecutionState>,
workflowId: string,
patch: Partial<WorkflowExecutionState>
): Map<string, WorkflowExecutionState> {
const next = new Map(map)
const current = getOrCreate(map, workflowId)
next.set(workflowId, { ...current, ...patch })
return next
}
/**
* Global Zustand store for per-workflow execution state.
*
* All execution state (running, debugging, block/edge highlights) is keyed
* by workflow ID so users can run multiple workflows concurrently, each
* with independent visual feedback.
*/
export const useExecutionStore = create<ExecutionState & ExecutionActions>()((set, get) => ({
...initialState,
getWorkflowExecution: (workflowId) => {
return getOrCreate(get().workflowExecutions, workflowId)
},
setActiveBlocks: (workflowId, blockIds) => {
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
activeBlockIds: new Set(blockIds),
}),
})
},
setPendingBlocks: (workflowId, pendingBlocks) => {
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { pendingBlocks }),
})
},
setStatus: (workflowId, status, options) => {
const patch: Partial<WorkflowExecutionState> = {
status,
...deriveExecutionFlags(status),
}
if (options?.clearRunPath) {
patch.lastRunPath = new Map()
patch.lastRunEdges = new Map()
}
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, patch),
})
},
setIsExecuting: (workflowId, isExecuting) => {
const current = getOrCreate(get().workflowExecutions, workflowId)
const nextStatus: ExecutionStatus = isExecuting
? current.status === 'debugging'
? 'debugging'
: 'running'
: 'idle'
get().setStatus(workflowId, nextStatus, { clearRunPath: isExecuting })
},
setIsDebugging: (workflowId, isDebugging) => {
const current = getOrCreate(get().workflowExecutions, workflowId)
const nextStatus: ExecutionStatus = isDebugging
? 'debugging'
: current.status === 'debugging'
? 'running'
: current.status
get().setStatus(workflowId, nextStatus)
},
setExecutor: (workflowId, executor) => {
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { executor }),
})
},
setDebugContext: (workflowId, debugContext) => {
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { debugContext }),
})
},
setBlockRunStatus: (workflowId, blockId, status) => {
const current = getOrCreate(get().workflowExecutions, workflowId)
const newRunPath = new Map(current.lastRunPath)
newRunPath.set(blockId, status)
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
lastRunPath: newRunPath,
}),
})
},
setEdgeRunStatus: (workflowId, edgeId, status) => {
const current = getOrCreate(get().workflowExecutions, workflowId)
const newRunEdges = new Map(current.lastRunEdges)
newRunEdges.set(edgeId, status)
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
lastRunEdges: newRunEdges,
}),
})
},
setCurrentExecutionId: (workflowId, executionId) => {
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
currentExecutionId: executionId,
}),
})
},
getCurrentExecutionId: (workflowId) => {
return getOrCreate(get().workflowExecutions, workflowId).currentExecutionId
},
clearRunPath: (workflowId) => {
set({
workflowExecutions: updatedMap(get().workflowExecutions, workflowId, {
lastRunPath: new Map(),
lastRunEdges: new Map(),
}),
})
},
reset: () => set(initialState),
setLastExecutionSnapshot: (workflowId, snapshot) => {
const newSnapshots = new Map(get().lastExecutionSnapshots)
newSnapshots.set(workflowId, snapshot)
set({ lastExecutionSnapshots: newSnapshots })
},
getLastExecutionSnapshot: (workflowId) => {
return get().lastExecutionSnapshots.get(workflowId)
},
clearLastExecutionSnapshot: (workflowId) => {
const newSnapshots = new Map(get().lastExecutionSnapshots)
newSnapshots.delete(workflowId)
set({ lastExecutionSnapshots: newSnapshots })
},
}))
/**
* Convenience hook that returns the execution state for the currently active workflow.
*/
export function useCurrentWorkflowExecution(): WorkflowExecutionState {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return defaultWorkflowExecutionState
return state.workflowExecutions.get(activeWorkflowId) ?? defaultWorkflowExecutionState
})
}
/**
* Returns whether the active workflow is currently executing.
* More granular than useCurrentWorkflowExecution — only re-renders when
* the isExecuting boolean changes, not on every iteration update during
* parallel-loop runs.
*/
export function useIsCurrentWorkflowExecuting(): boolean {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return false
return state.workflowExecutions.get(activeWorkflowId)?.isExecuting ?? false
})
}
/**
* Returns whether a specific block is currently active (executing) in the current workflow.
* More granular than useCurrentWorkflowExecution — only re-renders when
* the boolean result changes for this specific block.
*/
export function useIsBlockActive(blockId: string): boolean {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return false
return state.workflowExecutions.get(activeWorkflowId)?.activeBlockIds.has(blockId) ?? false
})
}
/**
* Returns the last run path (block statuses) for the current workflow.
* More granular than useCurrentWorkflowExecution — only re-renders when
* the lastRunPath map reference changes.
*/
export function useLastRunPath(): Map<string, BlockRunStatus> {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return defaultWorkflowExecutionState.lastRunPath
return (
state.workflowExecutions.get(activeWorkflowId)?.lastRunPath ??
defaultWorkflowExecutionState.lastRunPath
)
})
}
/**
* Returns the last run edges (edge statuses) for the current workflow.
* More granular than useCurrentWorkflowExecution — only re-renders when
* the lastRunEdges map reference changes.
*/
export function useLastRunEdges(): Map<string, EdgeRunStatus> {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return defaultWorkflowExecutionState.lastRunEdges
return (
state.workflowExecutions.get(activeWorkflowId)?.lastRunEdges ??
defaultWorkflowExecutionState.lastRunEdges
)
})
}
+185
View File
@@ -0,0 +1,185 @@
import type { Executor } from '@/executor'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { ExecutionContext } from '@/executor/types'
/**
* Represents the execution result of a block in the last run
*/
export type BlockRunStatus = 'success' | 'error'
/**
* Represents the execution result of an edge in the last run
*/
export type EdgeRunStatus = 'success' | 'error'
/**
* The mutually-exclusive execution mode of a single workflow.
*
* @remarks
* This is the single source of truth for whether a workflow is running.
* The legacy `isExecuting` / `isDebugging` booleans are derived from it
* via {@link deriveExecutionFlags} so illegal combinations — such as
* "debugging while not executing" — are unrepresentable.
*
* - `idle` — not running.
* - `running` — executing normally (derives `isExecuting`).
* - `debugging` — executing in step-by-step debug mode (derives both
* `isExecuting` and `isDebugging`).
*/
export type ExecutionStatus = 'idle' | 'running' | 'debugging'
/**
* Execution state scoped to a single workflow.
*
* Each workflow has its own independent instance so concurrent executions
* do not interfere with one another.
*/
export interface WorkflowExecutionState {
/** Mutually-exclusive execution mode; the source of truth for run state */
status: ExecutionStatus
/** Derived from {@link status}: whether this workflow is currently executing */
isExecuting: boolean
/** Derived from {@link status}: whether this workflow is in step-by-step debug mode */
isDebugging: boolean
/** Block IDs that are currently running (pulsing in the UI) */
activeBlockIds: Set<string>
/** Block IDs queued to execute next (used during debug stepping) */
pendingBlocks: string[]
/** The executor instance when running client-side */
executor: Executor | null
/** Debug execution context preserved across steps */
debugContext: ExecutionContext | null
/** Maps block IDs to their run result from the last execution */
lastRunPath: Map<string, BlockRunStatus>
/** Maps edge IDs to their run result from the last execution */
lastRunEdges: Map<string, EdgeRunStatus>
/** The execution ID of the currently running execution */
currentExecutionId: string | null
}
/**
* Computes the legacy `isExecuting` / `isDebugging` booleans from a status.
*
* @remarks
* Keeping the derived booleans on the stored state object lets existing
* consumers keep reading `state.isExecuting` / `state.isDebugging`
* unchanged while {@link ExecutionStatus} remains the single source of truth.
*/
export function deriveExecutionFlags(status: ExecutionStatus): {
isExecuting: boolean
isDebugging: boolean
} {
return {
isExecuting: status !== 'idle',
isDebugging: status === 'debugging',
}
}
/**
* Default values for a workflow that has never been executed.
*
* @remarks
* This constant is used as the fallback in selectors when no per-workflow
* entry exists. Its reference identity is stable, which prevents unnecessary
* re-renders in Zustand selectors that use `Object.is` equality.
*/
export const defaultWorkflowExecutionState: WorkflowExecutionState = {
status: 'idle',
...deriveExecutionFlags('idle'),
activeBlockIds: new Set(),
pendingBlocks: [],
executor: null,
debugContext: null,
lastRunPath: new Map(),
lastRunEdges: new Map(),
currentExecutionId: null,
}
/**
* Root state shape for the execution store.
*
* All execution state is keyed by workflow ID so multiple workflows
* can be executed concurrently with independent visual feedback.
*/
export interface ExecutionState {
/** Per-workflow execution state keyed by workflow ID */
workflowExecutions: Map<string, WorkflowExecutionState>
/** Serializable snapshots of the last successful execution per workflow */
lastExecutionSnapshots: Map<string, SerializableExecutionState>
}
/**
* Actions available on the execution store.
*
* Every setter takes a `workflowId` as its first argument so mutations
* are scoped to a single workflow.
*/
export interface ExecutionActions {
/** Returns the execution state for a workflow, falling back to defaults */
getWorkflowExecution: (workflowId: string) => WorkflowExecutionState
/** Replaces the set of currently-executing block IDs for a workflow */
setActiveBlocks: (workflowId: string, blockIds: Set<string>) => void
/**
* Sets the {@link ExecutionStatus} for a workflow.
*
* @remarks
* Pass `{ clearRunPath: true }` to also reset `lastRunPath` / `lastRunEdges`.
* Run-path clearing is opt-in: it is owned by
* {@link ExecutionActions.setIsExecuting} (which clears on start), matching
* the legacy behavior where only starting execution wiped the run history.
*/
setStatus: (
workflowId: string,
status: ExecutionStatus,
options?: { clearRunPath?: boolean }
) => void
/**
* Marks a workflow as executing or idle. Starting (`true`) clears the run path.
*
* @remarks
* Translates to {@link ExecutionActions.setStatus}: `true` preserves an
* active debug session (`debugging`) and otherwise enters `running`, and
* always clears the run path; `false` returns to `idle` and preserves it.
*/
setIsExecuting: (workflowId: string, isExecuting: boolean) => void
/**
* Toggles step-by-step debug mode for a workflow.
*
* @remarks
* Translates to {@link ExecutionActions.setStatus}: `true` enters
* `debugging` (which implies executing); `false` returns to `running` only
* when currently `debugging`, otherwise the status is preserved (e.g. calling
* it while `idle` is a no-op).
*/
setIsDebugging: (workflowId: string, isDebugging: boolean) => void
/** Sets the list of blocks pending execution during debug stepping */
setPendingBlocks: (workflowId: string, blockIds: string[]) => void
/** Stores the executor instance for a workflow */
setExecutor: (workflowId: string, executor: Executor | null) => void
/** Stores the debug execution context for a workflow */
setDebugContext: (workflowId: string, context: ExecutionContext | null) => void
/** Records a block's run result (success/error) in the run path */
setBlockRunStatus: (workflowId: string, blockId: string, status: BlockRunStatus) => void
/** Records an edge's run result (success/error) in the run edges */
setEdgeRunStatus: (workflowId: string, edgeId: string, status: EdgeRunStatus) => void
/** Clears the run path and run edges for a workflow */
clearRunPath: (workflowId: string) => void
/** Stores the current execution ID for a workflow */
setCurrentExecutionId: (workflowId: string, executionId: string | null) => void
/** Returns the current execution ID for a workflow */
getCurrentExecutionId: (workflowId: string) => string | null
/** Resets the entire store to its initial empty state */
reset: () => void
/** Stores a serializable execution snapshot for a workflow */
setLastExecutionSnapshot: (workflowId: string, snapshot: SerializableExecutionState) => void
/** Returns the stored execution snapshot for a workflow, if any */
getLastExecutionSnapshot: (workflowId: string) => SerializableExecutionState | undefined
/** Removes the stored execution snapshot for a workflow */
clearLastExecutionSnapshot: (workflowId: string) => void
}
/** Empty initial state used by the store and by {@link ExecutionActions.reset} */
export const initialState: ExecutionState = {
workflowExecutions: new Map(),
lastExecutionSnapshots: new Map(),
}
+302
View File
@@ -0,0 +1,302 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const logger = createLogger('FoldersStore')
interface FolderState {
expandedFolders: Set<string>
selectedWorkflows: Set<string>
selectedFolders: Set<string>
lastSelectedFolderId: string | null
selectedChats: Set<string>
lastSelectedChatId: string | null
toggleExpanded: (folderId: string) => void
setExpanded: (folderId: string, expanded: boolean) => void
// Workflow selection actions
selectWorkflow: (workflowId: string) => void
deselectWorkflow: (workflowId: string) => void
toggleWorkflowSelection: (workflowId: string) => void
clearSelection: () => void
selectOnly: (workflowId: string) => void
selectRange: (workflowIds: string[], fromId: string, toId: string) => void
isWorkflowSelected: (workflowId: string) => boolean
// Folder selection actions
selectFolder: (folderId: string) => void
deselectFolder: (folderId: string) => void
toggleFolderSelection: (folderId: string) => void
clearFolderSelection: () => void
selectFolderOnly: (folderId: string) => void
selectFolderRange: (folderIds: string[], fromId: string, toId: string) => void
isFolderSelected: (folderId: string) => boolean
// Chat selection actions
selectChatOnly: (chatId: string) => void
toggleChatSelection: (chatId: string) => void
selectChatRange: (chatIds: string[], fromId: string, toId: string) => void
clearChatSelection: () => void
isChatSelected: (chatId: string) => boolean
// Unified selection helpers
getFullSelection: () => { workflowIds: string[]; folderIds: string[]; chatIds: string[] }
hasAnySelection: () => boolean
isMixedSelection: () => boolean
clearAllSelection: () => void
}
export const useFolderStore = create<FolderState>()(
devtools(
(set, get) => ({
expandedFolders: new Set(),
selectedWorkflows: new Set(),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
toggleExpanded: (folderId) =>
set((state) => {
const newExpanded = new Set(state.expandedFolders)
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId)
} else {
newExpanded.add(folderId)
}
return { expandedFolders: newExpanded }
}),
setExpanded: (folderId, expanded) =>
set((state) => {
const newExpanded = new Set(state.expandedFolders)
if (expanded) {
newExpanded.add(folderId)
} else {
newExpanded.delete(folderId)
}
return { expandedFolders: newExpanded }
}),
// Selection actions
selectWorkflow: (workflowId) =>
set((state) => {
const newSelected = new Set(state.selectedWorkflows)
newSelected.add(workflowId)
return { selectedWorkflows: newSelected }
}),
deselectWorkflow: (workflowId) =>
set((state) => {
const newSelected = new Set(state.selectedWorkflows)
newSelected.delete(workflowId)
return { selectedWorkflows: newSelected }
}),
toggleWorkflowSelection: (workflowId) =>
set((state) => {
const newSelected = new Set(state.selectedWorkflows)
if (newSelected.has(workflowId)) {
newSelected.delete(workflowId)
} else {
newSelected.add(workflowId)
}
return {
selectedWorkflows: newSelected,
...(state.selectedChats.size > 0 && {
selectedChats: new Set<string>(),
lastSelectedChatId: null,
}),
}
}),
clearSelection: () => set({ selectedWorkflows: new Set() }),
selectOnly: (workflowId) =>
set({
selectedWorkflows: new Set([workflowId]),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
}),
selectRange: (workflowIds, fromId, toId) => {
const fromIndex = workflowIds.indexOf(fromId)
const toIndex = workflowIds.indexOf(toId)
if (fromIndex === -1 || toIndex === -1) return
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]
const rangeIds = workflowIds.slice(start, end + 1)
set({ selectedWorkflows: new Set(rangeIds) })
},
isWorkflowSelected: (workflowId) => get().selectedWorkflows.has(workflowId),
// Folder selection actions
selectFolder: (folderId) =>
set((state) => {
const newSelected = new Set(state.selectedFolders)
newSelected.add(folderId)
return { selectedFolders: newSelected, lastSelectedFolderId: folderId }
}),
deselectFolder: (folderId) =>
set((state) => {
const newSelected = new Set(state.selectedFolders)
newSelected.delete(folderId)
// If deselecting the last selected folder, update anchor to another selected folder or null
const newLastSelected =
state.lastSelectedFolderId === folderId
? (Array.from(newSelected)[0] ?? null)
: state.lastSelectedFolderId
return { selectedFolders: newSelected, lastSelectedFolderId: newLastSelected }
}),
toggleFolderSelection: (folderId) =>
set((state) => {
const newSelected = new Set(state.selectedFolders)
let newLastSelected: string | null
if (newSelected.has(folderId)) {
newSelected.delete(folderId)
// If toggling off the last selected, pick another or null
newLastSelected =
state.lastSelectedFolderId === folderId
? (Array.from(newSelected)[0] ?? null)
: state.lastSelectedFolderId
} else {
newSelected.add(folderId)
// Always update anchor to the most recently clicked folder
newLastSelected = folderId
}
return {
selectedFolders: newSelected,
lastSelectedFolderId: newLastSelected,
...(state.selectedChats.size > 0 && {
selectedChats: new Set<string>(),
lastSelectedChatId: null,
}),
}
}),
clearFolderSelection: () => set({ selectedFolders: new Set(), lastSelectedFolderId: null }),
selectFolderOnly: (folderId) =>
set({
selectedFolders: new Set([folderId]),
lastSelectedFolderId: folderId,
selectedChats: new Set(),
lastSelectedChatId: null,
}),
selectFolderRange: (folderIds, fromId, toId) => {
const fromIndex = folderIds.indexOf(fromId)
const toIndex = folderIds.indexOf(toId)
if (fromIndex === -1 || toIndex === -1) return
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]
const rangeIds = folderIds.slice(start, end + 1)
set({ selectedFolders: new Set(rangeIds), lastSelectedFolderId: fromId })
},
isFolderSelected: (folderId) => get().selectedFolders.has(folderId),
// Chat selection actions
selectChatOnly: (chatId) =>
set((state) => ({
selectedChats: new Set([chatId]),
lastSelectedChatId: chatId,
...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
lastSelectedFolderId: null,
}),
})),
toggleChatSelection: (chatId) =>
set((state) => {
const newSelected = new Set(state.selectedChats)
let newLastSelected: string | null
if (newSelected.has(chatId)) {
newSelected.delete(chatId)
newLastSelected =
state.lastSelectedChatId === chatId
? (Array.from(newSelected)[0] ?? null)
: state.lastSelectedChatId
} else {
newSelected.add(chatId)
newLastSelected = chatId
}
return {
selectedChats: newSelected,
lastSelectedChatId: newLastSelected,
...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
lastSelectedFolderId: null,
}),
}
}),
selectChatRange: (chatIds, fromId, toId) => {
const fromIndex = chatIds.indexOf(fromId)
const toIndex = chatIds.indexOf(toId)
if (fromIndex === -1 || toIndex === -1) return
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]
const rangeIds = chatIds.slice(start, end + 1)
const state = get()
set({
selectedChats: new Set(rangeIds),
lastSelectedChatId: fromId,
...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
lastSelectedFolderId: null,
}),
})
},
clearChatSelection: () => set({ selectedChats: new Set(), lastSelectedChatId: null }),
isChatSelected: (chatId) => get().selectedChats.has(chatId),
// Unified selection helpers
getFullSelection: () => ({
workflowIds: Array.from(get().selectedWorkflows),
folderIds: Array.from(get().selectedFolders),
chatIds: Array.from(get().selectedChats),
}),
hasAnySelection: () =>
get().selectedWorkflows.size > 0 ||
get().selectedFolders.size > 0 ||
get().selectedChats.size > 0,
isMixedSelection: () => get().selectedWorkflows.size > 0 && get().selectedFolders.size > 0,
clearAllSelection: () =>
set({
selectedWorkflows: new Set(),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
}),
}),
{ name: 'folder-store' }
)
)
export const useIsWorkflowSelected = (workflowId: string) =>
useFolderStore((state) => state.selectedWorkflows.has(workflowId))
export const useIsFolderSelected = (folderId: string) =>
useFolderStore((state) => state.selectedFolders.has(folderId))
+19
View File
@@ -0,0 +1,19 @@
export interface WorkflowFolder {
id: string
name: string
userId: string
workspaceId: string
parentId: string | null
color: string
isExpanded: boolean
locked: boolean
sortOrder: number
createdAt: Date
updatedAt: Date
archivedAt?: Date | null
}
export interface FolderTreeNode extends WorkflowFolder {
children: FolderTreeNode[]
level: number
}
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface FullscreenOriginState {
/** Pathname active immediately before the current fullscreen route, or `null` on a direct load. */
origin: string | null
/** Records the page a fullscreen route was launched from. */
setOrigin: (origin: string | null) => void
}
const initialState = { origin: null as string | null }
/**
* Holds the pathname a fullscreen route (e.g. `/upgrade`) was launched from.
* {@link WorkspaceChrome} writes the last non-fullscreen pathname it observes,
* so any trigger that merely pushes a fullscreen route gets correct
* return-to-origin without per-call-site wiring.
*/
export const useFullscreenOriginStore = create<FullscreenOriginState>()(
devtools(
(set) => ({
...initialState,
setOrigin: (origin) => set({ origin }, false, 'setOrigin'),
}),
{ name: 'fullscreen-origin-store' }
)
)
+62
View File
@@ -0,0 +1,62 @@
'use client'
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 { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Stores')
/**
* Reset all Zustand stores and React Query caches to initial state.
*/
export const resetAllStores = () => {
useWorkflowRegistry.setState({
activeWorkflowId: null,
error: null,
hydration: {
phase: 'idle',
workspaceId: null,
workflowId: null,
requestId: null,
error: null,
},
})
useWorkflowStore.getState().clear()
useSubBlockStore.getState().clear()
getQueryClient().removeQueries({ queryKey: environmentKeys.all })
useExecutionStore.getState().reset()
useTerminalConsoleStore.setState({
workflowEntries: {},
entryIdsByBlockExecution: {},
entryLocationById: {},
isOpen: false,
})
consolePersistence.persist()
useMothershipDraftsStore.setState({ drafts: {} })
}
/**
* 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 })
}
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { LogViewState } from '@/stores/logs/filters/types'
/**
* Logs view store. Holds only non-URL view state (the logs/dashboard toggle).
* All filter state is URL-backed via `useLogFilters` (nuqs).
*/
export const useFilterStore = create<LogViewState>()(
devtools(
(set) => ({
viewMode: 'logs',
setViewMode: (viewMode) => set({ viewMode }),
}),
{ name: 'logs-view-store' }
)
)
+50
View File
@@ -0,0 +1,50 @@
export type TimeRange =
| 'Past 30 minutes'
| 'Past hour'
| 'Past 6 hours'
| 'Past 12 hours'
| 'Past 24 hours'
| 'Past 3 days'
| 'Past 7 days'
| 'Past 14 days'
| 'Past 30 days'
| 'All time'
| 'Custom range'
export type LogLevel =
| 'error'
| 'info'
| 'running'
| 'pending'
| 'cancelled'
| 'all'
| (string & {})
/** Core trigger types for workflow execution */
export const CORE_TRIGGER_TYPES = [
'manual',
'api',
'schedule',
'chat',
'webhook',
'mcp',
'copilot',
'mothership',
'workflow',
] as const
export type CoreTriggerType = (typeof CORE_TRIGGER_TYPES)[number]
export type TriggerType = CoreTriggerType | 'all' | (string & {})
export type LogViewMode = 'logs' | 'dashboard'
/**
* Non-URL logs view state. The filter state itself (time range, level,
* workflows, folders, triggers, search) lives in the URL via nuqs
* (`useLogFilters`); only the view-mode toggle is kept in this store.
*/
export interface LogViewState {
viewMode: LogViewMode
setViewMode: (viewMode: LogViewMode) => void
}
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { LogDetailsUIState } from './types'
import { clampPanelWidth, DEFAULT_LOG_DETAILS_WIDTH } from './utils'
export const useLogDetailsUIStore = create<LogDetailsUIState>()(
persist(
(set) => ({
panelWidth: DEFAULT_LOG_DETAILS_WIDTH,
/**
* Updates the log details panel width, enforcing min/max constraints.
* @param width - Desired width in pixels for the panel.
*/
setPanelWidth: (width) => {
set({ panelWidth: clampPanelWidth(width) })
},
isResizing: false,
/**
* Updates the resize state flag.
* @param isResizing - True while the panel is being resized via mouse drag.
*/
setIsResizing: (isResizing) => {
set({ isResizing })
},
}),
{
name: 'log-details-ui-state',
partialize: (state) => ({ panelWidth: state.panelWidth }),
onRehydrateStorage: () => (state) => {
if (state) {
state.panelWidth = clampPanelWidth(state.panelWidth)
}
},
}
)
)
+9
View File
@@ -0,0 +1,9 @@
/**
* Log details UI state persisted across sessions.
*/
export interface LogDetailsUIState {
panelWidth: number
setPanelWidth: (width: number) => void
isResizing: boolean
setIsResizing: (isResizing: boolean) => void
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Width constraints for the log details panel.
*
* The min is the floor below which the panel becomes unusable. On narrow
* viewports (e.g. tablets, side-by-side windows, Mothership task pages with
* a constrained content area) the viewport-ratio cap is what actually wins
* — `getMaxLogDetailsWidth` enforces this. We never let the floor exceed
* the cap, so the panel always leaves room for the surface behind it.
*/
export const MIN_LOG_DETAILS_WIDTH = 320
export const DEFAULT_LOG_DETAILS_WIDTH = 520
export const MAX_LOG_DETAILS_WIDTH_RATIO = 0.6
/**
* Returns the maximum log details panel width (60% of viewport width).
* Falls back to a reasonable default for SSR.
*/
export const getMaxLogDetailsWidth = () =>
typeof window !== 'undefined' ? window.innerWidth * MAX_LOG_DETAILS_WIDTH_RATIO : 1040
/**
* Clamps a width value to the valid panel range for the current viewport.
* The floor (`MIN_LOG_DETAILS_WIDTH`) is itself capped by the viewport ratio
* so a small viewport never produces a panel that covers more than 60vw.
*/
export const clampPanelWidth = (width: number) => {
const max = getMaxLogDetailsWidth()
const min = Math.min(MIN_LOG_DETAILS_WIDTH, max)
return Math.max(min, Math.min(width, max))
}
+151
View File
@@ -0,0 +1,151 @@
import { createElement, type SVGProps } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BlockConfig } from '@/blocks/types'
const { mockGetAllBlocks, mockGetToolOperationsIndex, mockGetTriggersForSidebar } = vi.hoisted(
() => ({
mockGetAllBlocks: vi.fn(),
mockGetToolOperationsIndex: vi.fn(() => []),
mockGetTriggersForSidebar: vi.fn(() => []),
})
)
vi.mock('@/blocks', () => ({
getAllBlocks: mockGetAllBlocks,
}))
vi.mock('@/lib/search/tool-operations', () => ({
getToolOperationsIndex: mockGetToolOperationsIndex,
}))
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
getTriggersForSidebar: mockGetTriggersForSidebar,
}))
import {
buildCommandSearchableOptionSearchValue,
useSearchModalStore,
} from '@/stores/modals/search/store'
function TestIcon(props: SVGProps<SVGSVGElement>) {
return createElement('svg', props)
}
function createBlock(overrides: Partial<BlockConfig> = {}): BlockConfig {
return {
type: 'image_generator_v2',
name: 'Image Generator',
description: 'Generate images',
category: 'tools',
bgColor: '#4D5FFF',
icon: TestIcon,
subBlocks: [
{
id: 'provider',
title: 'Provider',
type: 'dropdown',
commandSearchable: true,
options: [
{ label: 'OpenAI', id: 'openai' },
{ label: 'Fal.ai (Multi-Model)', id: 'falai' },
{ label: 'Hidden Provider', id: 'hidden', hidden: true },
],
},
],
tools: { access: ['image_generate'] },
inputs: {},
outputs: {},
...overrides,
}
}
describe('search modal store', () => {
beforeEach(() => {
vi.clearAllMocks()
useSearchModalStore.setState({
isOpen: false,
data: {
blocks: [],
tools: [],
triggers: [],
toolOperations: [],
docs: [],
isInitialized: false,
},
})
})
describe('buildCommandSearchableOptionSearchValue', () => {
it('builds search terms for marked static dropdown options', () => {
const block = createBlock()
const searchValue = buildCommandSearchableOptionSearchValue(block)
expect(searchValue).toContain('Provider')
expect(searchValue).toContain('Fal.ai (Multi-Model)')
expect(searchValue).toContain('falai')
expect(searchValue).not.toContain('Hidden Provider')
expect(searchValue).not.toContain('hidden')
})
it('does not index dropdowns that only use in-dropdown search', () => {
const block = createBlock({
subBlocks: [
{
id: 'timezone',
title: 'Timezone',
type: 'dropdown',
searchable: true,
options: [{ label: 'UTC', id: 'utc' }],
},
],
})
expect(buildCommandSearchableOptionSearchValue(block)).toBe('')
})
it('builds search terms for marked combobox option functions', () => {
const block = createBlock({
subBlocks: [
{
id: 'model',
title: 'Model',
type: 'combobox',
commandSearchable: true,
options: () => [
{ label: 'claude-sonnet-4-6', id: 'claude-sonnet-4-6' },
{ label: 'Hidden Model', id: 'hidden-model', hidden: true },
],
},
],
})
const searchValue = buildCommandSearchableOptionSearchValue(block)
expect(searchValue).toContain('Model')
expect(searchValue).toContain('claude-sonnet-4-6')
expect(searchValue).not.toContain('Hidden Model')
expect(searchValue).not.toContain('hidden-model')
})
})
it('adds command-searchable options to visible block search values without extra rows', () => {
const visibleBlock = createBlock()
const hiddenBlock = createBlock({
type: 'hidden_generator',
hideFromToolbar: true,
})
mockGetAllBlocks.mockReturnValue([visibleBlock, hiddenBlock])
useSearchModalStore.getState().initializeData((blocks) => blocks)
const { tools } = useSearchModalStore.getState().data
expect(tools).toHaveLength(1)
expect(tools[0]).toEqual(
expect.objectContaining({
id: 'image_generator_v2',
searchValue: expect.stringContaining('Fal.ai (Multi-Model)'),
})
)
})
})
+204
View File
@@ -0,0 +1,204 @@
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils'
import { getAllBlocks } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type {
SearchBlockItem,
SearchData,
SearchDocItem,
SearchModalState,
SearchToolOperationItem,
} from './types'
const initialData: SearchData = {
blocks: [],
tools: [],
triggers: [],
toolOperations: [],
docs: [],
isInitialized: false,
}
type CommandSearchableOption = {
label: string
id: string
hidden?: boolean
}
function getCommandSearchableOptions(subBlock: SubBlockConfig): CommandSearchableOption[] {
if (!subBlock.options) return []
try {
const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options
return Array.isArray(options) ? options : []
} catch {
return []
}
}
export function buildCommandSearchableOptionSearchValue(block: BlockConfig): string {
const terms = new Set<string>()
for (const subBlock of block.subBlocks) {
if (
(subBlock.type !== 'dropdown' && subBlock.type !== 'combobox') ||
!subBlock.commandSearchable
) {
continue
}
for (const option of getCommandSearchableOptions(subBlock)) {
if (option.hidden) continue
const subBlockTitle = subBlock.title ?? subBlock.id
terms.add(subBlockTitle)
terms.add(option.label)
terms.add(option.id)
}
}
return Array.from(terms).join(' ')
}
export const useSearchModalStore = create<SearchModalState>()(
devtools(
(set, _) => ({
isOpen: false,
sections: null,
pendingConnect: null,
data: initialData,
setOpen: (open: boolean) => {
set({ isOpen: open, sections: null, pendingConnect: null })
},
open: (options) => {
set({
isOpen: true,
sections: options?.sections ?? null,
pendingConnect: options?.pendingConnect ?? null,
})
},
close: () => {
set({ isOpen: false, sections: null, pendingConnect: null })
},
initializeData: (filterBlocks) => {
const allBlocks = getAllBlocks()
const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks
const regularBlocks: SearchBlockItem[] = []
const tools: SearchBlockItem[] = []
const docs: SearchDocItem[] = []
for (const block of filteredAllBlocks) {
if (block.hideFromToolbar) continue
const searchItem: SearchBlockItem = {
id: block.type,
name: block.name,
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
}
if (block.category === 'blocks' && block.type !== 'starter') {
regularBlocks.push(searchItem)
} else if (block.category === 'tools') {
tools.push(searchItem)
}
if (block.docsLink) {
docs.push({
id: `docs-${block.type}`,
name: block.name,
icon: block.icon,
href: block.docsLink,
})
}
}
const specialBlocks: SearchBlockItem[] = [
{
id: 'loop',
name: 'Loop',
icon: RepeatIcon,
bgColor: '#2FB3FF',
type: 'loop',
},
{
id: 'parallel',
name: 'Parallel',
icon: SplitIcon,
bgColor: '#FEE12B',
type: 'parallel',
},
]
const blocks = [...regularBlocks, ...(filterBlocks(specialBlocks) as SearchBlockItem[])]
const allTriggers = getTriggersForSidebar()
const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers
const priorityOrder = ['Start', 'Schedule', 'Webhook']
const sortedTriggers = [...filteredTriggers].sort(
(a: (typeof filteredTriggers)[number], b: (typeof filteredTriggers)[number]) => {
const aIndex = priorityOrder.indexOf(a.name)
const bIndex = priorityOrder.indexOf(b.name)
const aHasPriority = aIndex !== -1
const bHasPriority = bIndex !== -1
if (aHasPriority && bHasPriority) return aIndex - bIndex
if (aHasPriority) return -1
if (bHasPriority) return 1
return a.name.localeCompare(b.name)
}
)
const triggers = sortedTriggers.map(
(block): SearchBlockItem => ({
id: block.type,
name: block.name,
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
config: block,
})
)
const allowedBlockTypes = new Set(tools.map((t) => t.type))
const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex()
.filter((op) => allowedBlockTypes.has(op.blockType))
.map((op) => {
const aliasesStr = op.aliases?.length ? ` ${op.aliases.join(' ')}` : ''
return {
id: op.id,
name: op.operationName,
searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`,
icon: op.icon,
bgColor: op.bgColor,
blockType: op.blockType,
operationId: op.operationId,
}
})
set({
data: {
blocks,
tools,
triggers,
toolOperations,
docs,
isInitialized: true,
},
})
},
}),
{ name: 'search-modal-store' }
)
)
+138
View File
@@ -0,0 +1,138 @@
import type { ComponentType } from 'react'
import type { BlockConfig } from '@/blocks/types'
/**
* Represents a block item in the search results.
*/
export interface SearchBlockItem {
id: string
name: string
icon: ComponentType<{ className?: string }>
bgColor: string
type: string
config?: BlockConfig
searchValue?: string
}
/**
* Represents a tool operation item in the search results.
*/
export interface SearchToolOperationItem {
id: string
name: string
searchValue: string
icon: ComponentType<{ className?: string }>
bgColor: string
blockType: string
operationId: string
}
/**
* Represents a doc item in the search results.
*/
export interface SearchDocItem {
id: string
name: string
icon: ComponentType<{ className?: string }>
href: string
}
/**
* Pre-computed search data that is initialized on app load.
*/
export interface SearchData {
blocks: SearchBlockItem[]
tools: SearchBlockItem[]
triggers: SearchBlockItem[]
toolOperations: SearchToolOperationItem[]
docs: SearchDocItem[]
isInitialized: boolean
}
/**
* Every result group the search modal can render, in render order. Used to
* restrict the palette to a subset of sections when opened for a specific
* intent (e.g. a drag-release that should only offer canvas-insertable items).
*/
export const SEARCH_SECTIONS = [
'actions',
'connectedAccounts',
'integrations',
'blocks',
'tools',
'triggers',
'chats',
'workflows',
'tables',
'files',
'knowledgeBases',
'toolOperations',
'workspaces',
'docs',
'pages',
] as const
/** A single search-modal result group. */
export type SearchSection = (typeof SEARCH_SECTIONS)[number]
/**
* Context handed to the palette when it is opened to complete an edge
* drag-release: the dragged source handle and the release point. A selection
* stamps it onto its event so the canvas places the block at the drop point and
* wires it from that handle.
*/
export interface PendingConnect {
source: { nodeId: string; handleId: string }
screenX: number
screenY: number
}
/**
* Global state for the universal search modal.
*
* Centralizing this state in a store allows any component (e.g. sidebar,
* workflow command list, keyboard shortcuts) to open or close the modal
* without relying on DOM events or prop drilling.
*/
export interface SearchModalState {
/** Whether the search modal is currently open. */
isOpen: boolean
/**
* When set, the palette renders only these sections; `null` shows all of them.
*/
sections: SearchSection[] | null
/**
* Pending edge drag-release the palette was opened to complete. A selection
* stamps it onto its event; other add-block dispatchers carry none, so only a
* genuine palette pick completes the connection. `null` for ordinary opens.
*/
pendingConnect: PendingConnect | null
/** Pre-computed search data. */
data: SearchData
/**
* Explicitly set the open state of the modal. Always resets to the full
* palette (no section restriction, no pending connect).
*/
setOpen: (open: boolean) => void
/**
* Convenience method to open the modal. Pass `sections` to restrict the
* palette to a subset of result groups, and `pendingConnect` to complete an
* edge drag-release with the selection.
*/
open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void
/**
* Convenience method to close the modal.
*/
close: () => void
/**
* Initialize search data. Called once on app load.
*/
initializeData: (filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]) => void
}
@@ -0,0 +1,50 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
export interface DraftPayload {
text: string
fileAttachments?: FileAttachmentForApi[]
contexts?: ChatContext[]
}
interface MothershipDraftsState {
drafts: Record<string, DraftPayload>
setDraft: (key: string, payload: DraftPayload) => void
clearDraft: (key: string) => void
}
function isEmpty(payload: DraftPayload): boolean {
return !payload.text && !payload.fileAttachments?.length && !payload.contexts?.length
}
export const useMothershipDraftsStore = create<MothershipDraftsState>()(
devtools(
persist(
(set) => ({
drafts: {},
setDraft: (key, payload) =>
set((s) => {
if (isEmpty(payload)) {
if (!(key in s.drafts)) return s
const { [key]: _, ...rest } = s.drafts
return { drafts: rest }
}
return { drafts: { ...s.drafts, [key]: payload } }
}),
clearDraft: (key) =>
set((s) => {
if (!(key in s.drafts)) return s
const { [key]: _, ...rest } = s.drafts
return { drafts: rest }
}),
}),
{
name: 'mothership-drafts:v1',
partialize: (state) => ({ drafts: state.drafts }),
}
),
{ name: 'mothership-drafts-store' }
)
)
@@ -0,0 +1,180 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
import type { QueuedMothershipMessage } from '@/stores/mothership-queue/types'
const message = (id: string, content = `content-${id}`): QueuedMothershipMessage => ({
id,
content,
})
describe('useMothershipQueueStore', () => {
beforeEach(() => {
useMothershipQueueStore.getState().reset()
})
describe('enqueue / remove', () => {
it('appends to the chat bucket', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().enqueue('chat-A', message('m2'))
expect(useMothershipQueueStore.getState().queues['chat-A']?.map((m) => m.id)).toEqual([
'm1',
'm2',
])
})
it('keeps buckets isolated per chat', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().enqueue('chat-B', message('n1'))
const state = useMothershipQueueStore.getState()
expect(state.queues['chat-A']?.map((m) => m.id)).toEqual(['m1'])
expect(state.queues['chat-B']?.map((m) => m.id)).toEqual(['n1'])
})
it('removes the chat bucket entirely when the last message is removed', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().remove('chat-A', 'm1')
expect(useMothershipQueueStore.getState().queues['chat-A']).toBeUndefined()
})
it('clears editing when the editing message is removed', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
useMothershipQueueStore.getState().remove('chat-A', 'm1')
expect(useMothershipQueueStore.getState().editing['chat-A']).toBeUndefined()
})
it('preserves editing when a different message is removed', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().enqueue('chat-A', message('m2'))
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
useMothershipQueueStore.getState().remove('chat-A', 'm2')
expect(useMothershipQueueStore.getState().editing['chat-A']).toBe('m1')
})
})
describe('insertAt', () => {
it('inserts at the requested index', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().enqueue('chat-A', message('m3'))
useMothershipQueueStore.getState().insertAt('chat-A', 1, message('m2'))
expect(useMothershipQueueStore.getState().queues['chat-A']?.map((m) => m.id)).toEqual([
'm1',
'm2',
'm3',
])
})
it('clamps an out-of-range index to the end', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().insertAt('chat-A', 99, message('m2'))
expect(useMothershipQueueStore.getState().queues['chat-A']?.map((m) => m.id)).toEqual([
'm1',
'm2',
])
})
it('ignores duplicate ids', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().insertAt('chat-A', 0, message('m1'))
expect(useMothershipQueueStore.getState().queues['chat-A']?.length).toBe(1)
})
})
describe('replaceAt', () => {
it('overwrites content while preserving id and index', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1', 'orig-1'))
useMothershipQueueStore.getState().enqueue('chat-A', message('m2', 'orig-2'))
useMothershipQueueStore.getState().enqueue('chat-A', message('m3', 'orig-3'))
useMothershipQueueStore.getState().replaceAt('chat-A', 'm2', { content: 'edited-2' })
const queue = useMothershipQueueStore.getState().queues['chat-A']
expect(queue?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3'])
expect(queue?.[1]?.content).toBe('edited-2')
})
it('is a no-op when the id is no longer in the queue', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
const before = useMothershipQueueStore.getState().queues['chat-A']
useMothershipQueueStore.getState().replaceAt('chat-A', 'missing', { content: 'x' })
expect(useMothershipQueueStore.getState().queues['chat-A']).toBe(before)
})
it('strips queuedSendHandoff on edit so a fresh handoff is minted at send time', () => {
const original: QueuedMothershipMessage = {
id: 'm1',
content: 'orig',
queuedSendHandoff: { id: 'm1', supersededStreamId: 'stream-x' },
}
useMothershipQueueStore.getState().enqueue('chat-A', original)
useMothershipQueueStore.getState().replaceAt('chat-A', 'm1', { content: 'edited' })
const replaced = useMothershipQueueStore.getState().queues['chat-A']?.[0]
expect(replaced?.queuedSendHandoff).toBeUndefined()
expect(replaced?.content).toBe('edited')
})
})
describe('migrate', () => {
it('moves both queue and editing from sentinel to resolved chatId', () => {
const pendingKey = 'pending::abc'
useMothershipQueueStore.getState().enqueue(pendingKey, message('m1'))
useMothershipQueueStore.getState().setEditing(pendingKey, 'm1')
useMothershipQueueStore.getState().migrate(pendingKey, 'chat-X')
const state = useMothershipQueueStore.getState()
expect(state.queues[pendingKey]).toBeUndefined()
expect(state.editing[pendingKey]).toBeUndefined()
expect(state.queues['chat-X']?.map((m) => m.id)).toEqual(['m1'])
expect(state.editing['chat-X']).toBe('m1')
})
it('is a no-op when source and target are the same', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
const before = useMothershipQueueStore.getState().queues['chat-A']
useMothershipQueueStore.getState().migrate('chat-A', 'chat-A')
expect(useMothershipQueueStore.getState().queues['chat-A']).toBe(before)
})
it('is a no-op when the source bucket is empty', () => {
const before = useMothershipQueueStore.getState().queues
useMothershipQueueStore.getState().migrate('nope', 'chat-X')
expect(useMothershipQueueStore.getState().queues).toBe(before)
})
it('merges into an existing destination bucket instead of overwriting', () => {
useMothershipQueueStore.getState().enqueue('chat-X', message('existing-1'))
useMothershipQueueStore.getState().enqueue('chat-X', message('existing-2'))
useMothershipQueueStore.getState().enqueue('pending::abc', message('pending-1'))
useMothershipQueueStore.getState().migrate('pending::abc', 'chat-X')
expect(useMothershipQueueStore.getState().queues['chat-X']?.map((m) => m.id)).toEqual([
'existing-1',
'existing-2',
'pending-1',
])
expect(useMothershipQueueStore.getState().queues['pending::abc']).toBeUndefined()
})
})
describe('clearChat', () => {
it('drops queue and editing for the chat', () => {
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
useMothershipQueueStore.getState().clearChat('chat-A')
const state = useMothershipQueueStore.getState()
expect(state.queues['chat-A']).toBeUndefined()
expect(state.editing['chat-A']).toBeUndefined()
})
})
describe('setEditing', () => {
it('stores and clears the editing id', () => {
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
expect(useMothershipQueueStore.getState().editing['chat-A']).toBe('m1')
useMothershipQueueStore.getState().setEditing('chat-A', null)
expect(useMothershipQueueStore.getState().editing['chat-A']).toBeUndefined()
})
})
})
+179
View File
@@ -0,0 +1,179 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import type { MothershipQueueState, QueuedMothershipMessage } from '@/stores/mothership-queue/types'
const logger = createLogger('MothershipQueueStore')
/**
* Per-tab sessionStorage adapter — no-ops on SSR and tolerates quota errors.
*
* We persist to sessionStorage (not localStorage like `mothership-drafts`)
* because the queue auto-drains on rehydrate: tab close should not fire those
* sends days later.
*/
const sessionStorageAdapter = {
getItem: (name: string): string | null => {
if (typeof sessionStorage === 'undefined') return null
try {
return sessionStorage.getItem(name)
} catch (error) {
logger.warn('Failed to read mothership queue from sessionStorage', toError(error))
return null
}
},
setItem: (name: string, value: string): void => {
if (typeof sessionStorage === 'undefined') return
try {
sessionStorage.setItem(name, value)
} catch (error) {
logger.warn('Failed to persist mothership queue to sessionStorage', toError(error))
}
},
removeItem: (name: string): void => {
if (typeof sessionStorage === 'undefined') return
try {
sessionStorage.removeItem(name)
} catch (error) {
logger.warn('Failed to remove mothership queue from sessionStorage', toError(error))
}
},
}
const initialState = {
queues: {} as Record<string, QueuedMothershipMessage[]>,
editing: {} as Record<string, string>,
}
const omitKey = <V>(record: Record<string, V>, key: string): Record<string, V> => {
if (!(key in record)) return record
const { [key]: _removed, ...rest } = record
return rest
}
const setQueueForChat = (
queues: Record<string, QueuedMothershipMessage[]>,
chatKey: string,
next: QueuedMothershipMessage[]
): Record<string, QueuedMothershipMessage[]> =>
next.length === 0 ? omitKey(queues, chatKey) : { ...queues, [chatKey]: next }
// Drop the volatile `queuedSendHandoff` from the persisted snapshot — its
// stream reference is meaningless after reload; the dispatcher mints a fresh
// one at send time if needed.
const stripVolatile = (message: QueuedMothershipMessage): QueuedMothershipMessage => {
if (!message.queuedSendHandoff) return message
const { queuedSendHandoff: _drop, ...rest } = message
return rest
}
export const useMothershipQueueStore = create<MothershipQueueState>()(
devtools(
persist(
(set) => ({
...initialState,
enqueue: (chatKey, message) =>
set((state) => ({
queues: setQueueForChat(state.queues, chatKey, [
...(state.queues[chatKey] ?? []),
message,
]),
})),
insertAt: (chatKey, index, message) =>
set((state) => {
const current = state.queues[chatKey] ?? []
if (current.some((m) => m.id === message.id)) return state
const next = [...current]
next.splice(Math.max(0, Math.min(index, next.length)), 0, message)
return { queues: setQueueForChat(state.queues, chatKey, next) }
}),
replaceAt: (chatKey, id, patch) =>
set((state) => {
const current = state.queues[chatKey] ?? []
const index = current.findIndex((m) => m.id === id)
if (index === -1) return state
const next = [...current]
// Strip `queuedSendHandoff` — references the stream active at
// original enqueue time; the dispatcher mints a fresh one at send.
const { queuedSendHandoff: _stale, ...rest } = next[index]
next[index] = {
...rest,
content: patch.content,
fileAttachments: patch.fileAttachments,
contexts: patch.contexts,
}
return { queues: setQueueForChat(state.queues, chatKey, next) }
}),
remove: (chatKey, id) =>
set((state) => {
const current = state.queues[chatKey] ?? []
const next = current.filter((m) => m.id !== id)
const wasEditingThis = state.editing[chatKey] === id
if (next.length === current.length) {
return wasEditingThis ? { editing: omitKey(state.editing, chatKey) } : state
}
return {
queues: setQueueForChat(state.queues, chatKey, next),
...(wasEditingThis ? { editing: omitKey(state.editing, chatKey) } : {}),
}
}),
setEditing: (chatKey, id) =>
set((state) => ({
editing:
id === null ? omitKey(state.editing, chatKey) : { ...state.editing, [chatKey]: id },
})),
migrate: (fromKey, toKey) =>
set((state) => {
if (fromKey === toKey) return state
const fromQueue = state.queues[fromKey]
const fromEditing = state.editing[fromKey]
if (!fromQueue && fromEditing === undefined) return state
const queues = omitKey(state.queues, fromKey)
if (fromQueue && fromQueue.length > 0) {
// Merge defensively in case a stale bucket survived in
// sessionStorage. FIFO: existing first, then the resolved stream.
const existing = state.queues[toKey] ?? []
queues[toKey] = [...existing, ...fromQueue]
}
const editing = omitKey(state.editing, fromKey)
if (fromEditing !== undefined) {
editing[toKey] = fromEditing
}
return { queues, editing }
}),
clearChat: (chatKey) =>
set((state) => ({
queues: omitKey(state.queues, chatKey),
editing: omitKey(state.editing, chatKey),
})),
reset: () => set(initialState),
}),
{
name: 'mothership-queue',
storage: createJSONStorage(() => sessionStorageAdapter),
// `editing` is intentionally omitted — the composer that holds the
// edit text is component-local and empty after reload, so a persisted
// editing flag would render an in-edit row with nothing bound.
partialize: (state) => ({
queues: Object.fromEntries(
Object.entries(state.queues).map(([key, messages]) => [
key,
messages.map(stripVolatile),
])
),
}),
}
),
{ name: 'mothership-queue-store' }
)
)
+30
View File
@@ -0,0 +1,30 @@
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
// Volatile — lets the dispatcher claim an in-flight stream's slot. Not persisted.
export interface QueuedSendHandoffSeed {
id: string
chatId?: string
supersededStreamId: string | null
userMessageId?: string
}
export type QueuedMothershipMessage = QueuedMessage & {
queuedSendHandoff?: QueuedSendHandoffSeed
}
// Mutable fields an in-place edit overwrites; id and index are preserved by `replaceAt`.
export type QueuedMessageEditPatch = Pick<QueuedMessage, 'content' | 'fileAttachments' | 'contexts'>
export interface MothershipQueueState {
queues: Record<string, QueuedMothershipMessage[]>
editing: Record<string, string>
enqueue: (chatKey: string, message: QueuedMothershipMessage) => void
insertAt: (chatKey: string, index: number, message: QueuedMothershipMessage) => void
replaceAt: (chatKey: string, id: string, patch: QueuedMessageEditPatch) => void
remove: (chatKey: string, id: string) => void
setEditing: (chatKey: string, id: string | null) => void
migrate: (fromKey: string, toKey: string) => void
clearChat: (chatKey: string) => void
reset: () => void
}
@@ -0,0 +1,488 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerEmitFunctions, useOperationQueueStore } from '@/stores/operation-queue/store'
describe('operation queue room gating', () => {
beforeEach(() => {
vi.clearAllMocks()
useOperationQueueStore.setState({
operations: [],
workflowOperationVersions: {},
isProcessing: false,
hasOperationError: false,
})
registerEmitFunctions(vi.fn(), vi.fn(), vi.fn(), null)
})
afterEach(() => {
useOperationQueueStore.setState({
operations: [],
workflowOperationVersions: {},
isProcessing: false,
hasOperationError: false,
})
registerEmitFunctions(vi.fn(), vi.fn(), vi.fn(), null)
})
it('does not process workflow operations while no workflow is registered', () => {
const workflowEmit = vi.fn()
registerEmitFunctions(workflowEmit, vi.fn(), vi.fn(), null)
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
expect(workflowEmit).not.toHaveBeenCalled()
})
it('waits until the matching workflow is registered before emitting', () => {
const workflowEmit = vi.fn()
registerEmitFunctions(workflowEmit, vi.fn(), vi.fn(), null)
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
registerEmitFunctions(workflowEmit, vi.fn(), vi.fn(), 'workflow-b')
expect(workflowEmit).not.toHaveBeenCalled()
registerEmitFunctions(workflowEmit, vi.fn(), vi.fn(), 'workflow-a')
expect(workflowEmit).toHaveBeenCalledWith(
'workflow-a',
'replace-state',
'workflow',
{ state: {} },
'op-1'
)
useOperationQueueStore.getState().confirmOperation('op-1')
})
it('reverts the operation to pending without retrying when the emit is skipped', () => {
const skippingEmit = vi.fn(() => false)
registerEmitFunctions(skippingEmit, vi.fn(), vi.fn(), 'workflow-a')
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
expect(skippingEmit).toHaveBeenCalledTimes(1)
const state = useOperationQueueStore.getState()
expect(state.isProcessing).toBe(false)
expect(state.hasOperationError).toBe(false)
expect(state.operations).toEqual([
expect.objectContaining({ id: 'op-1', status: 'pending', retryCount: 0 }),
])
})
it('emits a previously skipped operation once the room becomes joinable', () => {
const skippingEmit = vi.fn(() => false)
registerEmitFunctions(skippingEmit, vi.fn(), vi.fn(), 'workflow-a')
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
expect(skippingEmit).toHaveBeenCalledTimes(1)
const sendingEmit = vi.fn(() => true)
registerEmitFunctions(sendingEmit, vi.fn(), vi.fn(), 'workflow-a')
expect(sendingEmit).toHaveBeenCalledWith(
'workflow-a',
'replace-state',
'workflow',
{ state: {} },
'op-1'
)
expect(useOperationQueueStore.getState().operations).toEqual([
expect.objectContaining({ id: 'op-1', status: 'processing' }),
])
useOperationQueueStore.getState().confirmOperation('op-1')
})
it('triggers offline mode for a non-retryable failure and recovers via clearError', () => {
registerEmitFunctions(
vi.fn(() => true),
vi.fn(),
vi.fn(),
'workflow-a'
)
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
useOperationQueueStore.getState().failOperation('op-1', false)
expect(useOperationQueueStore.getState().hasOperationError).toBe(true)
expect(useOperationQueueStore.getState().operations).toEqual([])
useOperationQueueStore.getState().clearError()
expect(useOperationQueueStore.getState().hasOperationError).toBe(false)
})
it('triggers offline mode once retries exhaust for retryable failures', () => {
registerEmitFunctions(
vi.fn(() => true),
vi.fn(),
vi.fn(),
'workflow-a'
)
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
useOperationQueueStore.getState().failOperation('op-1', true)
useOperationQueueStore.getState().failOperation('op-1', true)
useOperationQueueStore.getState().failOperation('op-1', true)
expect(useOperationQueueStore.getState().hasOperationError).toBe(false)
useOperationQueueStore.getState().failOperation('op-1', true)
expect(useOperationQueueStore.getState().hasOperationError).toBe(true)
expect(useOperationQueueStore.getState().operations).toEqual([])
})
it('reports pending operations per workflow', () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
expect(useOperationQueueStore.getState().hasPendingOperations('workflow-a')).toBe(true)
expect(useOperationQueueStore.getState().hasPendingOperations('workflow-b')).toBe(false)
})
it('tracks local operation activity per workflow', () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
expect(useOperationQueueStore.getState().workflowOperationVersions['workflow-a']).toBe(1)
expect(
useOperationQueueStore.getState().workflowOperationVersions['workflow-b']
).toBeUndefined()
})
it('coalesces pending subblock updates to the latest value for the same field', () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: {
blockId: 'block-1',
subblockId: 'prompt',
value: 'old value',
},
},
})
useOperationQueueStore.getState().addToQueue({
id: 'op-2',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: {
blockId: 'block-1',
subblockId: 'prompt',
value: 'new value',
},
},
})
expect(useOperationQueueStore.getState().operations).toEqual([
expect.objectContaining({
id: 'op-2',
operation: expect.objectContaining({
payload: expect.objectContaining({ value: 'new value' }),
}),
}),
])
})
it('does not coalesce matching subblock updates across workflows', () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: {
blockId: 'block-1',
subblockId: 'prompt',
value: 'workflow a value',
},
},
})
useOperationQueueStore.getState().addToQueue({
id: 'op-2',
workflowId: 'workflow-b',
userId: 'user-1',
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: {
blockId: 'block-1',
subblockId: 'prompt',
value: 'workflow b value',
},
},
})
expect(useOperationQueueStore.getState().operations).toEqual([
expect.objectContaining({
id: 'op-1',
workflowId: 'workflow-a',
}),
expect.objectContaining({
id: 'op-2',
workflowId: 'workflow-b',
}),
])
})
it('coalesces variable field updates without dropping unrelated fields', () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'variable-update',
target: 'variable',
payload: {
variableId: 'variable-1',
field: 'value',
value: 'old value',
},
},
})
useOperationQueueStore.getState().addToQueue({
id: 'op-2',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'variable-update',
target: 'variable',
payload: {
variableId: 'variable-1',
field: 'name',
value: 'Variable Name',
},
},
})
useOperationQueueStore.getState().addToQueue({
id: 'op-3',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'variable-update',
target: 'variable',
payload: {
variableId: 'variable-1',
field: 'value',
value: 'new value',
},
},
})
expect(useOperationQueueStore.getState().operations).toEqual([
expect.objectContaining({
id: 'op-2',
operation: expect.objectContaining({
payload: expect.objectContaining({ field: 'name', value: 'Variable Name' }),
}),
}),
expect.objectContaining({
id: 'op-3',
operation: expect.objectContaining({
payload: expect.objectContaining({ field: 'value', value: 'new value' }),
}),
}),
])
})
it('does not coalesce matching variable updates across workflows', () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'variable-update',
target: 'variable',
payload: {
variableId: 'variable-1',
field: 'value',
value: 'workflow a value',
},
},
})
useOperationQueueStore.getState().addToQueue({
id: 'op-2',
workflowId: 'workflow-b',
userId: 'user-1',
operation: {
operation: 'variable-update',
target: 'variable',
payload: {
variableId: 'variable-1',
field: 'value',
value: 'workflow b value',
},
},
})
expect(useOperationQueueStore.getState().operations).toEqual([
expect.objectContaining({
id: 'op-1',
workflowId: 'workflow-a',
}),
expect.objectContaining({
id: 'op-2',
workflowId: 'workflow-b',
}),
])
})
it('waits for matching workflow operations to drain', async () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
const drained = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a')
useOperationQueueStore.getState().confirmOperation('op-1')
await expect(drained).resolves.toBe(true)
})
it('does not wait on operations from other workflows', async () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
await expect(
useOperationQueueStore.getState().waitForWorkflowOperations('workflow-b')
).resolves.toBe(true)
})
it('stops waiting when an operation error is reported', async () => {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
const drained = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a')
useOperationQueueStore.setState({ hasOperationError: true })
await expect(drained).resolves.toBe(false)
})
it('stops waiting when matching workflow operations do not drain before timeout', async () => {
vi.useFakeTimers()
try {
useOperationQueueStore.getState().addToQueue({
id: 'op-1',
workflowId: 'workflow-a',
userId: 'user-1',
operation: {
operation: 'replace-state',
target: 'workflow',
payload: { state: {} },
},
})
const drained = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a', 100)
await vi.advanceTimersByTimeAsync(100)
await expect(drained).resolves.toBe(false)
} finally {
vi.useRealTimers()
}
})
})
+679
View File
@@ -0,0 +1,679 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import type {
OperationQueueState,
QueuedOperation,
SubblockUpdateEmit,
VariableUpdateEmit,
WorkflowOperationEmit,
} from './types'
function isBlockStillPresent(blockId: string | undefined): boolean {
if (!blockId) return true
try {
const { useWorkflowStore } = require('@/stores/workflows/workflow/store')
return Boolean(useWorkflowStore.getState().blocks[blockId])
} catch {
return true
}
}
function isVariableStillPresent(variableId: string | undefined): boolean {
if (!variableId) return true
try {
const { useVariablesStore } = require('@/stores/variables/store')
return Boolean(useVariablesStore.getState().variables[variableId])
} catch {
return true
}
}
const logger = createLogger('OperationQueue')
/** Timeout for subblock/variable operations before considering them failed */
const SUBBLOCK_VARIABLE_TIMEOUT_MS = 15000
/** Timeout for structural operations before considering them failed */
const STRUCTURAL_TIMEOUT_MS = 5000
/** Maximum retry attempts for subblock/variable operations */
const SUBBLOCK_VARIABLE_MAX_RETRIES = 5
/** Maximum retry attempts for structural operations */
const STRUCTURAL_MAX_RETRIES = 3
/** Maximum retry delay cap for subblock/variable operations */
const SUBBLOCK_VARIABLE_MAX_RETRY_DELAY_MS = 3000
/** Base retry delay multiplier (1s, 2s, 3s for linear) */
const RETRY_DELAY_BASE_MS = 1000
const retryTimeouts = new Map<string, NodeJS.Timeout>()
const operationTimeouts = new Map<string, NodeJS.Timeout>()
const DEFAULT_WORKFLOW_DRAIN_TIMEOUT_MS = 20000
let emitWorkflowOperation: WorkflowOperationEmit | null = null
let emitSubblockUpdate: SubblockUpdateEmit | null = null
let emitVariableUpdate: VariableUpdateEmit | null = null
export function registerEmitFunctions(
workflowEmit: WorkflowOperationEmit,
subblockEmit: SubblockUpdateEmit,
variableEmit: VariableUpdateEmit,
workflowId: string | null
) {
emitWorkflowOperation = workflowEmit
emitSubblockUpdate = subblockEmit
emitVariableUpdate = variableEmit
currentRegisteredWorkflowId = workflowId
if (workflowId) {
useOperationQueueStore.getState().processNextOperation()
}
}
let currentRegisteredWorkflowId: string | null = null
/** Targets whose payload id refers to a canvas block (subflow ids are loop/parallel blocks). */
const BLOCK_SCOPED_TARGETS = ['block', 'subblock', 'subflow']
/**
* Drops a failed operation whose target entity no longer exists locally (e.g. it
* was removed by a remote collaborator while the operation was in flight), so a
* stale per-entity failure does not trip offline mode. Applies only to block-,
* subblock-, subflow-, and variable-scoped operations; structural operations
* (workflow/blocks/edges) have no single target entity and always fall through
* to offline mode. Returns true when the operation was dropped.
*/
function dropOperationForMissingTarget(operation: QueuedOperation): boolean {
const { target, payload } = operation.operation
const isVariableOperation = target === 'variable'
if (!isVariableOperation && !BLOCK_SCOPED_TARGETS.includes(target)) {
return false
}
const targetId = isVariableOperation
? payload?.variableId || payload?.id
: payload?.blockId || payload?.id
const targetStillPresent = isVariableOperation
? isVariableStillPresent(targetId)
: isBlockStillPresent(targetId)
if (!targetId || targetStillPresent) {
return false
}
logger.debug(
isVariableOperation
? 'Dropping failed operation for deleted variable'
: 'Dropping failed operation for deleted block',
{
operationId: operation.id,
targetId,
}
)
useOperationQueueStore.setState((s) => ({
operations: s.operations.filter((op) => op.id !== operation.id),
isProcessing: false,
}))
useOperationQueueStore.getState().processNextOperation()
return true
}
export const useOperationQueueStore = create<OperationQueueState>((set, get) => ({
operations: [],
workflowOperationVersions: {},
isProcessing: false,
hasOperationError: false,
addToQueue: (operation) => {
set((state) => ({
workflowOperationVersions: {
...state.workflowOperationVersions,
[operation.workflowId]: (state.workflowOperationVersions[operation.workflowId] ?? 0) + 1,
},
}))
let shouldDropPendingOperation = (_op: QueuedOperation) => false
if (
operation.operation.operation === 'subblock-update' &&
operation.operation.target === 'subblock'
) {
const { blockId, subblockId } = operation.operation.payload
shouldDropPendingOperation = (op) =>
op.status === 'pending' &&
op.workflowId === operation.workflowId &&
op.operation.operation === 'subblock-update' &&
op.operation.target === 'subblock' &&
op.operation.payload?.blockId === blockId &&
op.operation.payload?.subblockId === subblockId
}
if (
operation.operation.operation === 'variable-update' &&
operation.operation.target === 'variable'
) {
const { variableId, field } = operation.operation.payload
shouldDropPendingOperation = (op) =>
op.status === 'pending' &&
op.workflowId === operation.workflowId &&
op.operation.operation === 'variable-update' &&
op.operation.target === 'variable' &&
op.operation.payload?.variableId === variableId &&
op.operation.payload?.field === field
}
const state = get()
const existingOp = state.operations.find((op) => op.id === operation.id)
if (existingOp) {
logger.debug('Skipping duplicate operation ID', {
operationId: operation.id,
existingStatus: existingOp.status,
})
return
}
const duplicateContent = state.operations.find(
(op) =>
!shouldDropPendingOperation(op) &&
op.operation.operation === operation.operation.operation &&
op.operation.target === operation.operation.target &&
op.workflowId === operation.workflowId &&
((operation.operation.target === 'block' &&
op.operation.payload?.id === operation.operation.payload?.id) ||
(operation.operation.target !== 'block' &&
JSON.stringify(op.operation.payload) === JSON.stringify(operation.operation.payload)))
)
const isReplaceStateWorkflowOp =
operation.operation.target === 'workflow' && operation.operation.operation === 'replace-state'
if (duplicateContent && !isReplaceStateWorkflowOp) {
logger.debug('Skipping duplicate operation content', {
operationId: operation.id,
existingOperationId: duplicateContent.id,
operation: operation.operation.operation,
target: operation.operation.target,
existingStatus: duplicateContent.status,
payload:
operation.operation.target === 'block'
? { id: operation.operation.payload?.id }
: operation.operation.payload,
})
return
}
const queuedOp: QueuedOperation = {
...operation,
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
}
logger.debug('Adding operation to queue', {
operationId: queuedOp.id,
operation: queuedOp.operation,
})
set((state) => ({
operations: [...state.operations.filter((op) => !shouldDropPendingOperation(op)), queuedOp],
}))
get().processNextOperation()
},
confirmOperation: (operationId) => {
const state = get()
const operation = state.operations.find((op) => op.id === operationId)
const newOperations = state.operations.filter((op) => op.id !== operationId)
const retryTimeout = retryTimeouts.get(operationId)
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeouts.delete(operationId)
}
const operationTimeout = operationTimeouts.get(operationId)
if (operationTimeout) {
clearTimeout(operationTimeout)
operationTimeouts.delete(operationId)
}
logger.debug('Removing operation from queue', {
operationId,
remainingOps: newOperations.length,
})
set({ operations: newOperations, isProcessing: false })
get().processNextOperation()
},
failOperation: (operationId: string, retryable = true) => {
const state = get()
const operation = state.operations.find((op) => op.id === operationId)
if (!operation) {
logger.warn('Attempted to fail operation that does not exist in queue', { operationId })
return
}
const operationTimeout = operationTimeouts.get(operationId)
if (operationTimeout) {
clearTimeout(operationTimeout)
operationTimeouts.delete(operationId)
}
if (!retryable) {
if (dropOperationForMissingTarget(operation)) {
return
}
logger.error(
'Operation failed with non-retryable error - state out of sync, triggering offline mode',
{
operationId,
operation: operation.operation.operation,
target: operation.operation.target,
}
)
get().triggerOfflineMode()
return
}
const isSubblockOrVariable =
(operation.operation.operation === 'subblock-update' &&
operation.operation.target === 'subblock') ||
(operation.operation.operation === 'variable-update' &&
operation.operation.target === 'variable')
const maxRetries = isSubblockOrVariable ? SUBBLOCK_VARIABLE_MAX_RETRIES : STRUCTURAL_MAX_RETRIES
if (operation.retryCount < maxRetries) {
const newRetryCount = operation.retryCount + 1
// Faster retries for subblock/variable, exponential for structural
const delay = isSubblockOrVariable
? Math.min(RETRY_DELAY_BASE_MS * newRetryCount, SUBBLOCK_VARIABLE_MAX_RETRY_DELAY_MS)
: 2 ** newRetryCount * RETRY_DELAY_BASE_MS
logger.warn(
`Operation failed, retrying in ${delay}ms (attempt ${newRetryCount}/${maxRetries})`,
{
operationId,
retryCount: newRetryCount,
operation: operation.operation.operation,
}
)
set((state) => ({
operations: state.operations.map((op) =>
op.id === operationId
? { ...op, retryCount: newRetryCount, status: 'pending' as const }
: op
),
isProcessing: false,
}))
const timeout = setTimeout(() => {
retryTimeouts.delete(operationId)
get().processNextOperation()
}, delay)
retryTimeouts.set(operationId, timeout)
} else {
if (dropOperationForMissingTarget(operation)) {
return
}
logger.error('Operation failed after max retries, triggering offline mode', {
operationId,
operation: operation.operation.operation,
retryCount: operation.retryCount,
})
get().triggerOfflineMode()
}
},
handleOperationTimeout: (operationId: string) => {
const state = get()
const operation = state.operations.find((op) => op.id === operationId)
if (!operation) {
logger.debug('Ignoring timeout for operation not in queue', { operationId })
return
}
logger.warn('Operation timeout detected - treating as failure to trigger retries', {
operationId,
})
get().failOperation(operationId)
},
processNextOperation: () => {
const state = get()
if (state.isProcessing) {
return
}
if (!currentRegisteredWorkflowId) {
return
}
const nextOperation = state.operations.find(
(op) => op.status === 'pending' && op.workflowId === currentRegisteredWorkflowId
)
if (!nextOperation) {
return
}
set((state) => ({
operations: state.operations.map((op) =>
op.id === nextOperation.id ? { ...op, status: 'processing' as const } : op
),
isProcessing: true,
}))
logger.debug('Processing operation sequentially', {
operationId: nextOperation.id,
operation: nextOperation.operation,
retryCount: nextOperation.retryCount,
})
const { operation: op, target, payload } = nextOperation.operation
let emitted = false
if (op === 'subblock-update' && target === 'subblock') {
if (emitSubblockUpdate) {
emitted = emitSubblockUpdate(
payload.blockId,
payload.subblockId,
payload.value,
nextOperation.id,
nextOperation.workflowId
)
}
} else if (op === 'variable-update' && target === 'variable') {
if (emitVariableUpdate) {
emitted = emitVariableUpdate(
payload.variableId,
payload.field,
payload.value,
nextOperation.id,
nextOperation.workflowId
)
}
} else {
if (emitWorkflowOperation) {
emitted = emitWorkflowOperation(
nextOperation.workflowId,
op,
target,
payload,
nextOperation.id
)
}
}
if (!emitted) {
logger.debug('Emit skipped for operation - leaving it pending until the room is joinable', {
operationId: nextOperation.id,
operation: nextOperation.operation.operation,
workflowId: nextOperation.workflowId,
})
set((state) => ({
operations: state.operations.map((o) =>
o.id === nextOperation.id ? { ...o, status: 'pending' as const } : o
),
isProcessing: false,
}))
return
}
const isSubblockOrVariable =
(nextOperation.operation.operation === 'subblock-update' &&
nextOperation.operation.target === 'subblock') ||
(nextOperation.operation.operation === 'variable-update' &&
nextOperation.operation.target === 'variable')
const timeoutDuration = isSubblockOrVariable
? SUBBLOCK_VARIABLE_TIMEOUT_MS
: STRUCTURAL_TIMEOUT_MS
const timeoutId = setTimeout(() => {
logger.warn(`Operation timeout - no server response after ${timeoutDuration}ms`, {
operationId: nextOperation.id,
operation: nextOperation.operation.operation,
})
operationTimeouts.delete(nextOperation.id)
get().handleOperationTimeout(nextOperation.id)
}, timeoutDuration)
operationTimeouts.set(nextOperation.id, timeoutId)
},
hasPendingOperations: (workflowId: string) => {
return get().operations.some((op) => op.workflowId === workflowId)
},
waitForWorkflowOperations: (
workflowId: string,
timeoutMs = DEFAULT_WORKFLOW_DRAIN_TIMEOUT_MS
) => {
if (!get().hasPendingOperations(workflowId)) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
let unsubscribe = () => {}
const timeout = setTimeout(() => {
unsubscribe()
resolve(false)
}, timeoutMs)
unsubscribe = useOperationQueueStore.subscribe((state) => {
if (state.hasOperationError) {
clearTimeout(timeout)
unsubscribe()
resolve(false)
return
}
if (!state.operations.some((op) => op.workflowId === workflowId)) {
clearTimeout(timeout)
unsubscribe()
resolve(true)
}
})
})
},
cancelOperationsForBlock: (blockId: string) => {
logger.debug('Canceling all operations for block', { blockId })
const state = get()
const operationsToCancel = state.operations.filter((op) => {
const { target, payload, operation } = op.operation
if (target === 'block' && payload?.id === blockId) return true
if (target === 'subblock' && payload?.blockId === blockId) return true
if (target === 'blocks') {
if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) {
return payload.blocks.some((b: { id: string }) => b.id === blockId)
}
if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) {
return payload.ids.includes(blockId)
}
if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) {
return payload.updates.some((u: { id: string }) => u.id === blockId)
}
}
return false
})
operationsToCancel.forEach((op) => {
const operationTimeout = operationTimeouts.get(op.id)
if (operationTimeout) {
clearTimeout(operationTimeout)
operationTimeouts.delete(op.id)
}
const retryTimeout = retryTimeouts.get(op.id)
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeouts.delete(op.id)
}
})
const newOperations = state.operations.filter((op) => {
const { target, payload, operation } = op.operation
if (target === 'block' && payload?.id === blockId) return false
if (target === 'subblock' && payload?.blockId === blockId) return false
if (target === 'blocks') {
if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) {
if (payload.blocks.some((b: { id: string }) => b.id === blockId)) return false
}
if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) {
if (payload.ids.includes(blockId)) return false
}
if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) {
if (payload.updates.some((u: { id: string }) => u.id === blockId)) return false
}
}
return true
})
set({
operations: newOperations,
isProcessing: false,
})
logger.debug('Cancelled operations for block', {
blockId,
cancelledOperations: operationsToCancel.length,
})
get().processNextOperation()
},
cancelOperationsForVariable: (variableId: string) => {
logger.debug('Canceling all operations for variable', { variableId })
const state = get()
const operationsToCancel = state.operations.filter(
(op) =>
(op.operation.target === 'variable' && op.operation.payload?.variableId === variableId) ||
(op.operation.target === 'variable' &&
op.operation.payload?.sourceVariableId === variableId)
)
operationsToCancel.forEach((op) => {
const operationTimeout = operationTimeouts.get(op.id)
if (operationTimeout) {
clearTimeout(operationTimeout)
operationTimeouts.delete(op.id)
}
const retryTimeout = retryTimeouts.get(op.id)
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeouts.delete(op.id)
}
})
const newOperations = state.operations.filter(
(op) =>
!(
(op.operation.target === 'variable' && op.operation.payload?.variableId === variableId) ||
(op.operation.target === 'variable' &&
op.operation.payload?.sourceVariableId === variableId)
)
)
set({
operations: newOperations,
isProcessing: false,
})
logger.debug('Cancelled operations for variable', {
variableId,
cancelledOperations: operationsToCancel.length,
})
get().processNextOperation()
},
cancelOperationsForWorkflow: (workflowId: string) => {
const state = get()
retryTimeouts.forEach((timeout, opId) => {
const op = state.operations.find((o) => o.id === opId)
if (op && op.workflowId === workflowId) {
clearTimeout(timeout)
retryTimeouts.delete(opId)
}
})
operationTimeouts.forEach((timeout, opId) => {
const op = state.operations.find((o) => o.id === opId)
if (op && op.workflowId === workflowId) {
clearTimeout(timeout)
operationTimeouts.delete(opId)
}
})
set((s) => ({
operations: s.operations.filter((op) => op.workflowId !== workflowId),
isProcessing: false,
}))
},
triggerOfflineMode: () => {
logger.error('Operation failed after retries - triggering offline mode')
retryTimeouts.forEach((timeout) => clearTimeout(timeout))
retryTimeouts.clear()
operationTimeouts.forEach((timeout) => clearTimeout(timeout))
operationTimeouts.clear()
set({
operations: [],
isProcessing: false,
hasOperationError: true,
})
},
clearError: () => {
set({ hasOperationError: false })
},
}))
/**
* Hook to access operation queue state and actions.
* Uses getState() for actions to avoid unnecessary re-renders.
* Only subscribes to the specific state values needed.
*/
export function useOperationQueue() {
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const actions = useOperationQueueStore.getState()
return {
get queue() {
return useOperationQueueStore.getState().operations
},
get isProcessing() {
return useOperationQueueStore.getState().isProcessing
},
hasOperationError,
addToQueue: actions.addToQueue,
confirmOperation: actions.confirmOperation,
failOperation: actions.failOperation,
processNextOperation: actions.processNextOperation,
hasPendingOperations: actions.hasPendingOperations,
waitForWorkflowOperations: actions.waitForWorkflowOperations,
cancelOperationsForBlock: actions.cancelOperationsForBlock,
cancelOperationsForVariable: actions.cancelOperationsForVariable,
triggerOfflineMode: actions.triggerOfflineMode,
clearError: actions.clearError,
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Emit functions return whether the payload was actually sent over the socket.
* A `false` return means the emit was skipped (room not joined/visible) and the
* operation should stay pending instead of waiting on a confirmation timeout.
*/
export type WorkflowOperationEmit = (
workflowId: string,
operation: string,
target: string,
payload: any,
operationId?: string
) => boolean
export type SubblockUpdateEmit = (
blockId: string,
subblockId: string,
value: any,
operationId: string | undefined,
workflowId: string
) => boolean
export type VariableUpdateEmit = (
variableId: string,
field: string,
value: any,
operationId: string | undefined,
workflowId: string
) => boolean
export interface QueuedOperation {
id: string
operation: {
operation: string
target: string
payload: any
}
workflowId: string
timestamp: number
retryCount: number
status: 'pending' | 'processing' | 'confirmed' | 'failed'
userId: string
}
export interface OperationQueueState {
operations: QueuedOperation[]
workflowOperationVersions: Record<string, number>
isProcessing: boolean
hasOperationError: boolean
addToQueue: (operation: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>) => void
confirmOperation: (operationId: string) => void
failOperation: (operationId: string, retryable?: boolean) => void
handleOperationTimeout: (operationId: string) => void
processNextOperation: () => void
hasPendingOperations: (workflowId: string) => boolean
waitForWorkflowOperations: (workflowId: string, timeoutMs?: number) => Promise<boolean>
cancelOperationsForBlock: (blockId: string) => void
cancelOperationsForVariable: (variableId: string) => void
cancelOperationsForWorkflow: (workflowId: string) => void
triggerOfflineMode: () => void
clearError: () => void
}
+1
View File
@@ -0,0 +1 @@
export { usePanelEditorSearchStore, usePanelEditorStore } from './store'
+145
View File
@@ -0,0 +1,145 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type {
WorkflowSearchRange,
WorkflowSearchTarget,
WorkflowSearchValuePath,
} from '@/lib/workflows/search-replace/types'
import { EDITOR_CONNECTIONS_HEIGHT } from '@/stores/constants'
import { usePanelStore } from '../store'
let renameCallback: (() => void) | null = null
export type ActiveSearchTargetKind = WorkflowSearchTarget['kind']
export interface ActiveSearchTarget {
matchId: string
blockId: string
subBlockId: string
canonicalSubBlockId: string
valuePath: WorkflowSearchValuePath
kind: string
targetKind: ActiveSearchTargetKind
subBlockType: string
rawValue: string
searchText: string
query: string
range?: WorkflowSearchRange
structuredOccurrenceIndex?: number
displayLabel?: string
resourceGroupKey?: string
}
/**
* State for the Editor panel.
* Tracks the currently selected block to edit its subblocks/values and connections panel height.
*/
interface PanelEditorState {
/** Currently selected block identifier, or null when nothing is selected */
currentBlockId: string | null
/** Sets the current selected block identifier (use null to clear) */
setCurrentBlockId: (blockId: string | null) => void
/** Clears the current selection */
clearCurrentBlock: () => void
/** Height of the connections section in pixels */
connectionsHeight: number
/** Sets the connections section height */
setConnectionsHeight: (height: number) => void
/** Toggle connections between collapsed (min height) and expanded (default height) */
toggleConnectionsCollapsed: () => void
/** Register the rename callback (called by Editor on mount) */
registerRenameCallback: (callback: (() => void) | null) => void
/** Trigger rename mode by invoking the registered callback */
triggerRename: () => void
}
interface PanelEditorSearchState {
/** Ephemeral workflow search target used for scrolling/highlighting editor fields */
activeSearchTarget: ActiveSearchTarget | null
/** Sets an active search target to highlight in the editor */
setActiveSearchTarget: (target: ActiveSearchTarget | null) => void
}
export const usePanelEditorSearchStore = create<PanelEditorSearchState>()((set) => ({
activeSearchTarget: null,
setActiveSearchTarget: (target) => {
set({ activeSearchTarget: target })
if (target) {
usePanelStore.getState().setActiveTab('editor')
}
},
}))
/**
* Editor panel store.
* Persisted to preserve selection across navigations/refreshes.
*/
export const usePanelEditorStore = create<PanelEditorState>()(
persist(
(set, get) => ({
currentBlockId: null,
connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT,
registerRenameCallback: (callback) => {
renameCallback = callback
},
triggerRename: () => {
renameCallback?.()
},
setCurrentBlockId: (blockId) => {
set({ currentBlockId: blockId })
if (blockId !== null) {
usePanelStore.getState().setActiveTab('editor')
}
},
clearCurrentBlock: () => {
set({ currentBlockId: null })
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
},
setConnectionsHeight: (height) => {
const clampedHeight = Math.max(
EDITOR_CONNECTIONS_HEIGHT.MIN,
Math.min(EDITOR_CONNECTIONS_HEIGHT.MAX, height)
)
set({ connectionsHeight: clampedHeight })
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty(
'--editor-connections-height',
`${clampedHeight}px`
)
}
},
toggleConnectionsCollapsed: () => {
const currentState = get()
const isAtMinHeight = currentState.connectionsHeight <= 35
const newHeight = isAtMinHeight
? EDITOR_CONNECTIONS_HEIGHT.DEFAULT
: EDITOR_CONNECTIONS_HEIGHT.MIN
set({ connectionsHeight: newHeight })
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty(
'--editor-connections-height',
`${newHeight}px`
)
}
},
}),
{
name: 'panel-editor-state',
partialize: (state) => ({
currentBlockId: state.currentBlockId,
connectionsHeight: state.connectionsHeight,
}),
onRehydrateStorage: () => (state) => {
if (state && typeof window !== 'undefined') {
document.documentElement.style.setProperty(
'--editor-connections-height',
`${state.connectionsHeight || EDITOR_CONNECTIONS_HEIGHT.DEFAULT}px`
)
}
},
}
)
)
+8
View File
@@ -0,0 +1,8 @@
// Main panel store
// Editor
export { usePanelEditorSearchStore, usePanelEditorStore } from './editor'
export { usePanelStore } from './store'
// Toolbar
export { useToolbarStore } from './toolbar'
export type { ChatContext, PanelTab } from './types'
+64
View File
@@ -0,0 +1,64 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { PANEL_WIDTH } from '@/stores/constants'
import type { PanelState, PanelTab } from '@/stores/panel/types'
/**
* Default panel tab
*/
const DEFAULT_TAB: PanelTab = 'copilot'
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
panelWidth: PANEL_WIDTH.DEFAULT,
setPanelWidth: (width) => {
// Only enforce minimum - maximum is enforced dynamically by the resize hook
const clampedWidth = Math.max(PANEL_WIDTH.MIN, width)
set({ panelWidth: clampedWidth })
// Update CSS variable for immediate visual feedback
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--panel-width', `${clampedWidth}px`)
}
},
activeTab: DEFAULT_TAB,
setActiveTab: (tab) => {
set({ activeTab: tab })
// Remove data attribute once React takes control
if (typeof document !== 'undefined') {
document.documentElement.removeAttribute('data-panel-active-tab')
}
},
isResizing: false,
setIsResizing: (isResizing) => {
set({ isResizing })
},
_hasHydrated: false,
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })
},
}),
{
name: 'panel-state',
/**
* Persist only the durable panel preferences. `activeTab` MUST be kept:
* the blocking script in `app/layout.tsx` reads it from this persisted
* `panel-state` entry to set `data-panel-active-tab` before hydration,
* preventing a tab flash. The transient `isResizing` drag flag and the
* `_hasHydrated` hydration marker are excluded.
*/
partialize: (state) => ({
panelWidth: state.panelWidth,
activeTab: state.activeTab,
}),
onRehydrateStorage: () => (state) => {
// Sync CSS variables with stored state after rehydration
if (state && typeof window !== 'undefined') {
document.documentElement.style.setProperty('--panel-width', `${state.panelWidth}px`)
// Remove the data attribute so CSS rules stop interfering
document.documentElement.removeAttribute('data-panel-active-tab')
}
},
}
)
)
+1
View File
@@ -0,0 +1 @@
export { useToolbarStore } from './store'
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export type ToolbarSectionKey = 'triggers' | 'blocks' | 'customBlocks' | 'tools'
interface ToolbarState {
expandedSections: Record<ToolbarSectionKey, boolean>
setSectionExpanded: (key: ToolbarSectionKey, expanded: boolean) => void
}
const initialState: Pick<ToolbarState, 'expandedSections'> = {
expandedSections: { triggers: true, blocks: true, customBlocks: true, tools: true },
}
export const useToolbarStore = create<ToolbarState>()(
devtools(
persist(
(set) => ({
...initialState,
setSectionExpanded: (key, expanded) =>
set((state) => ({
expandedSections: { ...state.expandedSections, [key]: expanded },
})),
}),
{
name: 'toolbar-state',
partialize: (state) => ({ expandedSections: state.expandedSections }),
}
),
{ name: 'toolbar-store' }
)
)
+38
View File
@@ -0,0 +1,38 @@
/**
* Available panel tabs
*/
export type PanelTab = 'copilot' | 'editor' | 'toolbar'
/**
* Panel state interface
*/
export interface PanelState {
panelWidth: number
setPanelWidth: (width: number) => void
activeTab: PanelTab
setActiveTab: (tab: PanelTab) => void
/** Whether the panel is currently being resized */
isResizing: boolean
/** Updates the panel resize state */
setIsResizing: (isResizing: boolean) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}
export type ChatContext =
| { kind: 'past_chat'; chatId: string; label: string }
| { kind: 'workflow'; workflowId: string; label: string }
| { kind: 'current_workflow'; workflowId: string; label: string }
| { kind: 'blocks'; blockIds: string[]; label: string }
| { kind: 'logs'; executionId?: string; label: string }
| { kind: 'workflow_block'; workflowId: string; blockId: string; label: string }
| { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'table'; tableId: string; label: string }
| { kind: 'file'; fileId: string; label: string }
| { kind: 'folder'; folderId: string; label: string }
| { kind: 'filefolder'; fileFolderId: string; label: string }
| { kind: 'scheduledtask'; scheduleId: string; label: string }
| { kind: 'docs'; label: string }
| { kind: 'slash_command'; command: string; label: string }
| { kind: 'integration'; blockType: string; label: string }
| { kind: 'skill'; skillId: string; label: string }
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { PresenceState } from '@/stores/presence/types'
/**
* Live collaborator presence for the active workflow room.
*
* Presence is high-frequency (cursor frames arrive many times per second), so it
* lives in its own store rather than the broad socket context. Only presence
* consumers (`<Cursors>`, `<Avatars>`) subscribe to it, so cursor frames no
* longer re-render emitter-only `useSocket()` consumers such as `WorkflowContent`.
*/
export const usePresenceStore = create<PresenceState>()(
devtools(
(set) => ({
presenceUsers: [],
setPresenceUsers: (users) => set({ presenceUsers: users }),
updatePresenceUsers: (updater) =>
set((state) => ({ presenceUsers: updater(state.presenceUsers) })),
clearPresenceUsers: () => set({ presenceUsers: [] }),
}),
{ name: 'presence-store' }
)
)
+23
View File
@@ -0,0 +1,23 @@
/**
* A collaborator present in the active workflow room. Mirrors the presence
* payload broadcast by the realtime server (`presence-update`, cursor/selection
* deltas, and `join-workflow-success`).
*/
export interface PresenceUser {
socketId: string
userId: string
userName: string
avatarUrl?: string | null
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
}
export interface PresenceState {
presenceUsers: PresenceUser[]
/** Replace the full presence list (join success, presence-update). */
setPresenceUsers: (users: PresenceUser[]) => void
/** Apply a functional update to the presence list (cursor/selection deltas). */
updatePresenceUsers: (updater: (prev: PresenceUser[]) => PresenceUser[]) => void
/** Clear presence when leaving or losing the workflow room. */
clearPresenceUsers: () => void
}
+2
View File
@@ -0,0 +1,2 @@
export { useProvidersStore } from './store'
export type { ProviderName } from './types'
+64
View File
@@ -0,0 +1,64 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import type { OpenRouterModelInfo, ProvidersStore } from './types'
const logger = createLogger('ProvidersStore')
export const useProvidersStore = create<ProvidersStore>((set, get) => ({
providers: {
base: { models: [], isLoading: false },
ollama: { models: [], isLoading: false },
'ollama-cloud': { models: [], isLoading: false },
vllm: { models: [], isLoading: false },
litellm: { models: [], isLoading: false },
openrouter: { models: [], isLoading: false },
fireworks: { models: [], isLoading: false },
together: { models: [], isLoading: false },
baseten: { models: [], isLoading: false },
},
openRouterModelInfo: {},
setProviderModels: (provider, models) => {
logger.info(`Updated ${provider} models`, { count: models.length })
set((state) => ({
providers: {
...state.providers,
[provider]: {
...state.providers[provider],
models,
},
},
}))
},
setProviderLoading: (provider, isLoading) => {
set((state) => ({
providers: {
...state.providers,
[provider]: {
...state.providers[provider],
isLoading,
},
},
}))
},
setOpenRouterModelInfo: (modelInfo: Record<string, OpenRouterModelInfo>) => {
const structuredOutputCount = Object.values(modelInfo).filter(
(m) => m.supportsStructuredOutputs
).length
logger.info('Updated OpenRouter model info', {
count: Object.keys(modelInfo).length,
withStructuredOutputs: structuredOutputCount,
})
set({ openRouterModelInfo: modelInfo })
},
getProvider: (provider) => {
return get().providers[provider]
},
getOpenRouterModelInfo: (modelId: string) => {
return get().openRouterModelInfo[modelId]
},
}))
+36
View File
@@ -0,0 +1,36 @@
export type ProviderName =
| 'ollama'
| 'ollama-cloud'
| 'vllm'
| 'litellm'
| 'openrouter'
| 'fireworks'
| 'together'
| 'baseten'
| 'base'
export interface OpenRouterModelInfo {
id: string
contextLength?: number
supportsStructuredOutputs?: boolean
supportsTools?: boolean
pricing?: {
input: number
output: number
}
}
interface ProviderState {
models: string[]
isLoading: boolean
}
export interface ProvidersStore {
providers: Record<ProviderName, ProviderState>
openRouterModelInfo: Record<string, OpenRouterModelInfo>
setProviderModels: (provider: ProviderName, models: string[]) => void
setProviderLoading: (provider: ProviderName, isLoading: boolean) => void
setOpenRouterModelInfo: (modelInfo: Record<string, OpenRouterModelInfo>) => void
getProvider: (provider: ProviderName) => ProviderState
getOpenRouterModelInfo: (modelId: string) => OpenRouterModelInfo | undefined
}
+56
View File
@@ -0,0 +1,56 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface SettingsDirtyStore {
isDirty: boolean
/** Leave action deferred until the user confirms discard. */
pendingLeave: (() => void) | null
setDirty: (dirty: boolean) => void
/**
* Call before leaving the current settings surface. If clean, runs `leave` immediately
* and returns `true`. If dirty, stashes `leave` and returns `false` so the shared
* discard dialog can confirm before running it.
*/
requestLeave: (leave: () => void) => boolean
/** Clears dirty + pending state and runs the deferred leave action. */
confirmLeave: () => void
/** Cancels a pending leave without clearing dirty state. */
cancelLeave: () => void
/** Resets all state — call on component unmount. */
reset: () => void
}
const initialState = {
isDirty: false,
pendingLeave: null as (() => void) | null,
}
export const useSettingsDirtyStore = create<SettingsDirtyStore>()(
devtools(
(set, get) => ({
...initialState,
setDirty: (dirty) => set({ isDirty: dirty }),
requestLeave: (leave) => {
if (!get().isDirty) {
leave()
return true
}
set({ pendingLeave: leave })
return false
},
confirmLeave: () => {
const { pendingLeave } = get()
set({ ...initialState })
pendingLeave?.()
},
cancelLeave: () => set({ pendingLeave: null }),
reset: () => set({ ...initialState }),
}),
{ name: 'settings-dirty-store' }
)
)
+34
View File
@@ -0,0 +1,34 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it } from 'vitest'
import { readCollapsedCookie } from './store'
function setCookie(value: string) {
document.cookie = `sidebar_collapsed=${value}; path=/`
}
afterEach(() => {
document.cookie = 'sidebar_collapsed=; path=/; max-age=0'
})
describe('readCollapsedCookie', () => {
it('is true only for an exact value of 1', () => {
setCookie('1')
expect(readCollapsedCookie()).toBe(true)
})
it('is false for 0', () => {
setCookie('0')
expect(readCollapsedCookie()).toBe(false)
})
it('does not treat a substring value like 10 as collapsed', () => {
setCookie('10')
expect(readCollapsedCookie()).toBe(false)
})
it('is false when the cookie is absent', () => {
expect(readCollapsedCookie()).toBe(false)
})
})
+110
View File
@@ -0,0 +1,110 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import type { SidebarState } from './types'
/**
* Clamps an expanded sidebar width into the valid range for the current
* viewport. The upper bound can never drop below {@link SIDEBAR_WIDTH.MIN}, so a
* narrow window (where `innerWidth * MAX_PERCENTAGE < MIN`) still yields a width
* at or above the minimum instead of collapsing the sidebar to nothing.
*/
function clampSidebarWidth(width: number): number {
if (!Number.isFinite(width)) return SIDEBAR_WIDTH.DEFAULT
const max =
typeof window === 'undefined'
? Number.POSITIVE_INFINITY
: Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
return Math.min(Math.max(width, SIDEBAR_WIDTH.MIN), max)
}
function applySidebarWidth(width: number) {
if (typeof window === 'undefined') return
const value = Number.isFinite(width) ? width : SIDEBAR_WIDTH.DEFAULT
document.documentElement.style.setProperty('--sidebar-width', `${value}px`)
}
/**
* The `sidebar_collapsed` cookie is the single source of truth for collapse: the
* server layout reads it to render the correct structure on the first paint
* (it can't read `localStorage`), and the client seeds its initial state from it
* below. Width is the only field persisted to `localStorage`.
*/
function applyCollapsedCookie(collapsed: boolean) {
if (typeof document === 'undefined') return
document.cookie = `sidebar_collapsed=${collapsed ? '1' : '0'}; path=/; max-age=31536000; samesite=lax`
}
/** Reads the collapse state the server saw, so the client store seeds identically. Matches the
* cookie value strictly (`=1`) so `sidebar_collapsed=10` and the like aren't read as collapsed. */
export function readCollapsedCookie(): boolean {
if (typeof document === 'undefined') return false
return document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/)?.[1] === '1'
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set, get) => ({
workspaceDropdownOpen: false,
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
isCollapsed: readCollapsedCookie(),
_hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
if (get().isCollapsed) return
const clampedWidth = clampSidebarWidth(width)
set({ sidebarWidth: clampedWidth })
applySidebarWidth(clampedWidth)
},
toggleCollapsed: () => {
const { isCollapsed, sidebarWidth } = get()
const nextCollapsed = !isCollapsed
set({ isCollapsed: nextCollapsed })
applyCollapsedCookie(nextCollapsed)
applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : clampSidebarWidth(sidebarWidth))
},
syncWidth: () => {
const { isCollapsed, sidebarWidth } = get()
if (isCollapsed) {
applySidebarWidth(SIDEBAR_WIDTH.COLLAPSED)
return
}
const clampedWidth = clampSidebarWidth(sidebarWidth)
if (clampedWidth !== sidebarWidth) set({ sidebarWidth: clampedWidth })
applySidebarWidth(clampedWidth)
},
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
}),
{
name: 'sidebar-state',
/**
* Width is hydrated manually from a client-only effect (see Sidebar) so
* `_hasHydrated` is deterministically `false` during SSR and the first
* client render — both of which read collapse from the cookie-seeded prop.
* This is zustand's documented SSR pattern; it avoids relying on auto
* hydration's behavior when `localStorage` is absent on the server.
*/
skipHydration: true,
onRehydrateStorage: () => (state) => {
if (state) {
state.setHasHydrated(true)
const width = state.isCollapsed
? SIDEBAR_WIDTH.COLLAPSED
: clampSidebarWidth(state.sidebarWidth)
applySidebarWidth(width)
}
},
/** Only width is persisted; collapse lives in the cookie. */
partialize: (state) => ({ sidebarWidth: state.sidebarWidth }),
/**
* Never lets a legacy persisted `isCollapsed` override the cookie-seeded
* value — the cookie is the source of truth (handles migration cleanly).
*/
merge: (persisted, current) => ({
...current,
...(persisted as Partial<SidebarState>),
isCollapsed: current.isCollapsed,
}),
}
)
)
+21
View File
@@ -0,0 +1,21 @@
/**
* Sidebar state interface
*/
export interface SidebarState {
workspaceDropdownOpen: boolean
sidebarWidth: number
/** Whether the sidebar is collapsed to icon-only mode */
isCollapsed: boolean
_hasHydrated: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void
/** Toggles sidebar between collapsed and expanded states */
toggleCollapsed: () => void
/**
* Re-applies the `--sidebar-width` CSS variable from current state, clamped to
* the viewport. Self-heals cases where the variable was left at its `0px`
* default (e.g. soft navigation) or grew wider than a now-smaller window.
*/
syncWidth: () => void
setHasHydrated: (hasHydrated: boolean) => void
}
+121
View File
@@ -0,0 +1,121 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
/**
* An in-flight client upload, shown optimistically before its server import row exists or the
* table list has refreshed. Keyed by `uploadId`: a `pending_*` id (creating a new table, no row
* yet) or the target tableId (append/replace into an existing table).
*/
export interface ImportUpload {
uploadId: string
workspaceId: string
title: string
/** Byte-based upload percent from the client XHR. */
percent?: number
}
/**
* Client-only state for the import tray. The importing/terminal rows themselves are derived from
* the table list (React Query) — this store holds only what the server doesn't: optimistic uploads,
* which terminal completions to surface this session, canceled ids, and the menu's open state.
*/
interface ImportTrayState {
uploads: Record<string, ImportUpload>
/** Terminal (`ready`/`failed`) table ids to surface as a card this session. */
notified: Record<string, true>
/** Ids (upload or table) canceled so callbacks/derivation don't resurrect them. */
canceledIds: Record<string, true>
/**
* Server-listed rows the user dismissed (export jobIds). Unlike `notified` — an allow-list for
* table-derived terminals — export terminals are listed by the server for a visibility window,
* so dismissal needs a deny-list.
*/
dismissedIds: Record<string, true>
menuOpen: boolean
startUpload: (upload: ImportUpload) => void
setUploadPercent: (uploadId: string, percent: number) => void
endUpload: (uploadId: string) => void
/** Surface a terminal completion as a tray card. */
notify: (tableId: string) => void
/** Remove a terminal card (manual dismiss or auto-clear). */
dismiss: (tableId: string) => void
/** Hide a server-listed job row (export) for the rest of the session. */
dismissJob: (jobId: string) => void
/** Flag an id canceled and drop any optimistic upload for it. */
cancel: (id: string) => void
isCanceled: (id: string) => boolean
/** Returns whether the id was canceled and clears the flag (one-shot, for the kickoff handler). */
consumeCanceled: (id: string) => boolean
setMenuOpen: (open: boolean) => void
reset: () => void
}
const initialState = {
uploads: {} as Record<string, ImportUpload>,
notified: {} as Record<string, true>,
canceledIds: {} as Record<string, true>,
dismissedIds: {} as Record<string, true>,
menuOpen: false,
}
export const useImportTrayStore = create<ImportTrayState>()(
devtools(
(set, get) => ({
...initialState,
startUpload: (upload) =>
set((state) => ({ uploads: { ...state.uploads, [upload.uploadId]: upload } })),
setUploadPercent: (uploadId, percent) =>
set((state) => {
const prev = state.uploads[uploadId]
if (!prev) return state
return { uploads: { ...state.uploads, [uploadId]: { ...prev, percent } } }
}),
endUpload: (uploadId) =>
set((state) => {
if (!state.uploads[uploadId]) return state
const { [uploadId]: _removed, ...rest } = state.uploads
return { uploads: rest }
}),
notify: (tableId) => set((state) => ({ notified: { ...state.notified, [tableId]: true } })),
dismiss: (tableId) =>
set((state) => {
if (!state.notified[tableId]) return state
const { [tableId]: _removed, ...rest } = state.notified
return { notified: rest }
}),
dismissJob: (jobId) =>
set((state) => ({ dismissedIds: { ...state.dismissedIds, [jobId]: true } })),
cancel: (id) =>
set((state) => {
const { [id]: _removed, ...uploads } = state.uploads
return { uploads, canceledIds: { ...state.canceledIds, [id]: true } }
}),
isCanceled: (id) => Boolean(get().canceledIds[id]),
consumeCanceled: (id) => {
const was = Boolean(get().canceledIds[id])
if (was) {
set((state) => {
const { [id]: _removed, ...rest } = state.canceledIds
return { canceledIds: rest }
})
}
return was
},
setMenuOpen: (open) => set({ menuOpen: open }),
reset: () => set(initialState),
}),
{ name: 'import-tray-store' }
)
)
+194
View File
@@ -0,0 +1,194 @@
/**
* Zustand store for table undo/redo stacks.
* Ephemeral — no persistence. Stacks are keyed by tableId.
*/
import { generateShortId } from '@sim/utils/id'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { TableUndoAction, TableUndoStacks, TableUndoState, UndoEntry } from './types'
const STACK_CAPACITY = 100
const EMPTY_STACKS: TableUndoStacks = { undo: [], redo: [] }
let undoRedoInProgress = false
function patchRowIdInEntry(entry: UndoEntry, oldRowId: string, newRowId: string): UndoEntry {
const { action } = entry
switch (action.type) {
case 'update-cell':
if (action.rowId === oldRowId) {
return { ...entry, action: { ...action, rowId: newRowId } }
}
break
case 'clear-cells': {
const hasMatch = action.cells.some((c) => c.rowId === oldRowId)
if (hasMatch) {
const patched = action.cells.map((c) =>
c.rowId === oldRowId ? { ...c, rowId: newRowId } : c
)
return { ...entry, action: { ...action, cells: patched } }
}
break
}
case 'update-cells': {
const hasMatch = action.cells.some((c) => c.rowId === oldRowId)
if (hasMatch) {
const patched = action.cells.map((c) =>
c.rowId === oldRowId ? { ...c, rowId: newRowId } : c
)
return { ...entry, action: { ...action, cells: patched } }
}
break
}
case 'create-row':
if (action.rowId === oldRowId) {
return { ...entry, action: { ...action, rowId: newRowId } }
}
break
case 'create-rows': {
const hasMatch = action.rows.some((r) => r.rowId === oldRowId)
if (hasMatch) {
const patched = action.rows.map((r) =>
r.rowId === oldRowId ? { ...r, rowId: newRowId } : r
)
return { ...entry, action: { ...action, rows: patched } }
}
break
}
case 'delete-rows': {
const hasMatch = action.rows.some((r) => r.rowId === oldRowId)
if (hasMatch) {
const patched = action.rows.map((r) =>
r.rowId === oldRowId ? { ...r, rowId: newRowId } : r
)
return { ...entry, action: { ...action, rows: patched } }
}
break
}
case 'delete-column': {
const hasMatch = action.cellData.some((c) => c.rowId === oldRowId)
if (hasMatch) {
const patched = action.cellData.map((c) =>
c.rowId === oldRowId ? { ...c, rowId: newRowId } : c
)
return { ...entry, action: { ...action, cellData: patched } }
}
break
}
}
return entry
}
/**
* Run a function without recording undo entries. Supports async functions —
* `undoRedoInProgress` stays true until the returned Promise settles, so
* mutations inside `executeAction` don't accidentally push new undo entries.
*/
export async function runWithoutRecording<T>(fn: () => T | Promise<T>): Promise<T> {
undoRedoInProgress = true
try {
return await fn()
} finally {
undoRedoInProgress = false
}
}
export const useTableUndoStore = create<TableUndoState>()(
devtools(
(set, get) => ({
stacks: {},
push: (tableId: string, action: TableUndoAction) => {
if (undoRedoInProgress) return
const entry: UndoEntry = { id: generateShortId(), action, timestamp: Date.now() }
set((state) => {
const current = state.stacks[tableId] ?? EMPTY_STACKS
const undoStack = [entry, ...current.undo].slice(0, STACK_CAPACITY)
return {
stacks: {
...state.stacks,
[tableId]: { undo: undoStack, redo: [] },
},
}
})
},
popUndo: (tableId: string) => {
const current = get().stacks[tableId] ?? EMPTY_STACKS
if (current.undo.length === 0) return null
const [entry, ...rest] = current.undo
set((state) => ({
stacks: {
...state.stacks,
[tableId]: {
undo: rest,
redo: [entry, ...current.redo],
},
},
}))
return entry
},
popRedo: (tableId: string) => {
const current = get().stacks[tableId] ?? EMPTY_STACKS
if (current.redo.length === 0) return null
const [entry, ...rest] = current.redo
set((state) => ({
stacks: {
...state.stacks,
[tableId]: {
undo: [entry, ...current.undo],
redo: rest,
},
},
}))
return entry
},
patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => {
set((state) => {
const stacks = state.stacks[tableId]
if (!stacks) return state
const patchedRedo = stacks.redo.map((entry) =>
patchRowIdInEntry(entry, oldRowId, newRowId)
)
return {
stacks: {
...state.stacks,
[tableId]: { ...stacks, redo: patchedRedo },
},
}
})
},
patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => {
set((state) => {
const stacks = state.stacks[tableId]
if (!stacks) return state
const patchedUndo = stacks.undo.map((entry) =>
patchRowIdInEntry(entry, oldRowId, newRowId)
)
return {
stacks: {
...state.stacks,
[tableId]: { ...stacks, undo: patchedUndo },
},
}
})
},
clear: (tableId: string) => {
set((state) => {
const { [tableId]: _, ...rest } = state.stacks
return { stacks: rest }
})
},
}),
{ name: 'table-undo-store' }
)
)
+100
View File
@@ -0,0 +1,100 @@
/**
* Type definitions for table undo/redo actions.
*/
import type { ColumnDefinition } from '@/lib/table'
export interface DeletedRowSnapshot {
rowId: string
data: Record<string, unknown>
position: number
/** Fractional order key, when present — restore re-inserts at this exact key. */
orderKey?: string
}
export type TableUndoAction =
| {
type: 'update-cell'
rowId: string
columnName: string
previousValue: unknown
newValue: unknown
}
| { type: 'clear-cells'; cells: Array<{ rowId: string; data: Record<string, unknown> }> }
| {
type: 'update-cells'
cells: Array<{
rowId: string
oldData: Record<string, unknown>
newData: Record<string, unknown>
}>
}
| {
type: 'create-row'
rowId: string
orderKey?: string
data?: Record<string, unknown>
}
| {
type: 'create-rows'
rows: Array<{
rowId: string
orderKey?: string
data: Record<string, unknown>
}>
}
| { type: 'delete-rows'; rows: DeletedRowSnapshot[] }
// `columnName` is the display name (for re-create); `columnId` is the stable
// storage key used for the delete/update lookup and id-keyed metadata cleanup.
| { type: 'create-column'; columnName: string; columnId?: string; position: number }
| {
type: 'delete-column'
columnName: string
columnId?: string
columnType: ColumnDefinition['type']
columnPosition: number
columnUnique: boolean
columnRequired: boolean
cellData: Array<{ rowId: string; value: unknown }>
previousOrder: string[] | null
previousWidth: number | null
previousPinnedColumns: string[] | null
}
// `oldName`/`newName` are display names; `columnId` is the stable lookup key.
| { type: 'rename-column'; oldName: string; newName: string; columnId?: string }
| {
type: 'update-column-type'
columnName: string
previousType: ColumnDefinition['type']
newType: ColumnDefinition['type']
}
| {
type: 'toggle-column-constraint'
columnName: string
constraint: 'unique' | 'required'
previousValue: boolean
newValue: boolean
}
| { type: 'rename-table'; tableId: string; previousName: string; newName: string }
| { type: 'reorder-columns'; previousOrder: string[]; newOrder: string[] }
export interface UndoEntry {
id: string
action: TableUndoAction
timestamp: number
}
export interface TableUndoStacks {
undo: UndoEntry[]
redo: UndoEntry[]
}
export interface TableUndoState {
stacks: Record<string, TableUndoStacks>
push: (tableId: string, action: TableUndoAction) => void
popUndo: (tableId: string) => UndoEntry | null
popRedo: (tableId: string) => UndoEntry | null
patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => void
patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => void
clear: (tableId: string) => void
}
@@ -0,0 +1,9 @@
export {
clearExecutionPointer,
consolePersistence,
loadExecutionPointer,
saveExecutionPointer,
} from './storage'
export { useConsoleEntry, useTerminalConsoleStore, useWorkflowConsoleEntries } from './store'
export type { ConsoleEntry, ConsoleUpdate } from './types'
export { safeConsoleStringify } from './utils'
+320
View File
@@ -0,0 +1,320 @@
import { createLogger } from '@sim/logger'
import { get, set } from 'idb-keyval'
import type { ConsoleEntry } from './types'
const logger = createLogger('ConsoleStorage')
const STORE_KEY = 'terminal-console-store'
const MIGRATION_KEY = 'terminal-console-store-migrated'
/**
* Interval for persisting terminal state during active executions.
* Kept short enough that a hard refresh during execution still has
* recent rows available once the tab-local reconnect pointer resumes.
*/
const EXECUTION_PERSIST_INTERVAL_MS = 5_000
/**
* Shape of terminal console data persisted to IndexedDB.
*/
export interface PersistedConsoleData {
workflowEntries: Record<string, ConsoleEntry[]>
isOpen: boolean
}
let migrationPromise: Promise<void> | null = null
async function migrateFromLocalStorage(): Promise<void> {
if (typeof window === 'undefined') return
try {
const migrated = await get<boolean>(MIGRATION_KEY)
if (migrated) return
const localData = localStorage.getItem(STORE_KEY)
if (localData) {
await set(STORE_KEY, localData)
localStorage.removeItem(STORE_KEY)
logger.info('Migrated console store to IndexedDB')
}
await set(MIGRATION_KEY, true)
} catch (error) {
logger.warn('Migration from localStorage failed', { error })
}
}
if (typeof window !== 'undefined') {
migrationPromise = migrateFromLocalStorage().finally(() => {
migrationPromise = null
})
}
/**
* Loads persisted console data from IndexedDB.
* Handles three historical storage formats:
* 1. Zustand persist wrapper: `{ state: { entries: [...] }, version }` (original flat format)
* 2. Zustand persist wrapper: `{ state: { workflowEntries: {...} }, version }` (refactored format)
* 3. Raw data: `{ workflowEntries: {...}, isOpen }` (current format)
*/
export async function loadConsoleData(): Promise<PersistedConsoleData | null> {
if (typeof window === 'undefined') return null
if (migrationPromise) {
await migrationPromise
}
try {
const raw = await get<string>(STORE_KEY)
if (!raw) return null
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
if (!parsed || typeof parsed !== 'object') return null
const data = parsed.state ?? parsed
if (Array.isArray(data.entries) && !data.workflowEntries) {
const workflowEntries: Record<string, ConsoleEntry[]> = {}
for (const entry of data.entries) {
if (!entry?.workflowId) continue
const wfId = entry.workflowId
if (!workflowEntries[wfId]) workflowEntries[wfId] = []
workflowEntries[wfId].push(entry)
}
return { workflowEntries, isOpen: Boolean(data.isOpen) }
}
return {
workflowEntries: data.workflowEntries ?? {},
isOpen: Boolean(data.isOpen),
}
} catch (error) {
logger.warn('Failed to load console data from IndexedDB', { error })
return null
}
}
let activeWrite: Promise<void> | null = null
interface PersistOptions {
merge?: boolean
}
function entryTimestamp(entry: ConsoleEntry): number {
return Date.parse(entry.endedAt ?? entry.startedAt ?? entry.timestamp)
}
function shouldReplaceEntry(existing: ConsoleEntry, incoming: ConsoleEntry): boolean {
if (existing.isRunning && !incoming.isRunning) return true
if (!existing.isRunning && incoming.isRunning) return false
return entryTimestamp(incoming) >= entryTimestamp(existing)
}
function mergeEntries(
existingEntries: ConsoleEntry[] = [],
incomingEntries: ConsoleEntry[] = []
): ConsoleEntry[] {
const entriesById = new Map<string, ConsoleEntry>()
const orderedIds: string[] = []
for (const entry of existingEntries) {
entriesById.set(entry.id, entry)
orderedIds.push(entry.id)
}
for (const entry of incomingEntries) {
const existing = entriesById.get(entry.id)
if (!existing) {
entriesById.set(entry.id, entry)
orderedIds.push(entry.id)
continue
}
if (shouldReplaceEntry(existing, entry)) {
entriesById.set(entry.id, entry)
}
}
return orderedIds
.map((id) => entriesById.get(id))
.filter((entry): entry is ConsoleEntry => !!entry)
}
function mergePersistedConsoleData(
existing: PersistedConsoleData | null,
incoming: PersistedConsoleData
): PersistedConsoleData {
if (!existing) return incoming
const workflowIds = new Set([
...Object.keys(existing.workflowEntries),
...Object.keys(incoming.workflowEntries),
])
const workflowEntries: Record<string, ConsoleEntry[]> = {}
for (const workflowId of workflowIds) {
const entries = mergeEntries(
existing.workflowEntries[workflowId],
incoming.workflowEntries[workflowId]
)
if (entries.length > 0) workflowEntries[workflowId] = entries
}
return {
workflowEntries,
isOpen: incoming.isOpen,
}
}
function writeToIndexedDB(
data: PersistedConsoleData,
{ merge = true }: PersistOptions = {}
): Promise<void> {
const doWrite = async () => {
try {
const nextData = merge ? mergePersistedConsoleData(await loadConsoleData(), data) : data
const serialized = JSON.stringify(nextData)
await set(STORE_KEY, serialized)
} catch (error) {
logger.warn('IndexedDB write failed', { error })
}
}
activeWrite = (activeWrite ?? Promise.resolve()).then(doWrite)
return activeWrite
}
/**
* Execution-aware persistence manager for the terminal console store.
*
* Writes happen only at meaningful lifecycle boundaries:
* - When an execution ends (success, error, cancel)
* - On explicit user actions (clear console)
* - On page hide (crash safety)
* - Every 30s during very long active executions (safety net)
*
* During normal execution, no serialization or IndexedDB writes occur,
* keeping the hot path completely free of persistence overhead.
*/
class ConsolePersistenceManager {
private dataProvider: (() => PersistedConsoleData) | null = null
private safetyTimer: ReturnType<typeof setTimeout> | null = null
private activeExecutions = 0
private needsInitialPersist = false
/**
* Binds the data provider function used to snapshot current state.
* Called once during store initialization.
*/
bind(provider: () => PersistedConsoleData): void {
this.dataProvider = provider
}
/**
* Signals that a workflow execution has started.
* Starts the long-execution safety-net timer if this is the first active execution.
*/
executionStarted(): void {
this.activeExecutions++
this.needsInitialPersist = true
if (this.activeExecutions === 1) {
this.startSafetyTimer()
}
}
/**
* Called by the store when a running entry is added during an active execution.
* Triggers one immediate persist so refreshes can hydrate visible terminal rows,
* then disables until the next execution starts.
*/
onRunningEntryAdded(): void {
if (!this.needsInitialPersist) return
this.needsInitialPersist = false
this.persist()
}
/**
* Signals that a workflow execution has ended (success, error, or cancel).
* Triggers an immediate persist and stops the safety timer if no executions remain.
*/
executionEnded(): void {
this.activeExecutions = Math.max(0, this.activeExecutions - 1)
this.persist()
if (this.activeExecutions === 0) {
this.stopSafetyTimer()
}
}
/**
* Triggers an immediate persist. Used for explicit user actions
* like clearing the console, and for page-hide durability.
*/
persist(options?: PersistOptions): Promise<void> {
if (!this.dataProvider) return Promise.resolve()
return writeToIndexedDB(this.dataProvider(), options)
}
private startSafetyTimer(): void {
this.stopSafetyTimer()
this.safetyTimer = setInterval(() => {
this.persist()
}, EXECUTION_PERSIST_INTERVAL_MS)
}
private stopSafetyTimer(): void {
if (this.safetyTimer !== null) {
clearInterval(this.safetyTimer)
this.safetyTimer = null
}
}
}
export const consolePersistence = new ConsolePersistenceManager()
const EXEC_POINTER_PREFIX = 'terminal-active-execution:'
/**
* Lightweight pointer to an in-flight execution, persisted immediately on
* execution start so the reconnect flow can find it even if no console
* entries have been written yet. Stored in sessionStorage so ownership stays
* scoped to the browser tab that started the run.
*/
export interface ExecutionPointer {
workflowId: string
executionId: string
lastEventId: number
}
export async function loadExecutionPointer(workflowId: string): Promise<ExecutionPointer | null> {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(`${EXEC_POINTER_PREFIX}${workflowId}`)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed?.executionId) return null
return parsed as ExecutionPointer
} catch {
return null
}
}
export function saveExecutionPointer(pointer: ExecutionPointer): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
try {
window.sessionStorage.setItem(
`${EXEC_POINTER_PREFIX}${pointer.workflowId}`,
JSON.stringify(pointer)
)
} catch {
return Promise.resolve()
}
return Promise.resolve()
}
export function clearExecutionPointer(workflowId: string): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
try {
window.sessionStorage.removeItem(`${EXEC_POINTER_PREFIX}${workflowId}`)
} catch {
return Promise.resolve()
}
return Promise.resolve()
}
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.unmock('@/stores/terminal')
vi.unmock('@/stores/terminal/console/store')
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
describe('terminal console store', () => {
beforeEach(() => {
useTerminalConsoleStore.setState({
workflowEntries: {},
entryIdsByBlockExecution: {},
entryLocationById: {},
isOpen: false,
_hasHydrated: true,
})
})
it('normalizes oversized payloads when adding console entries', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
output: {
a: 'x'.repeat(100_000),
b: 'y'.repeat(100_000),
c: 'z'.repeat(100_000),
d: 'q'.repeat(100_000),
e: 'r'.repeat(100_000),
f: 's'.repeat(100_000),
},
})
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.output).toMatchObject({
__simTruncated: true,
})
})
it('normalizes oversized replaceOutput updates', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
output: { ok: true },
})
useTerminalConsoleStore.getState().updateConsole(
'block-1',
{
executionOrder: 1,
replaceOutput: {
a: 'x'.repeat(100_000),
b: 'y'.repeat(100_000),
c: 'z'.repeat(100_000),
d: 'q'.repeat(100_000),
e: 'r'.repeat(100_000),
f: 's'.repeat(100_000),
},
},
'exec-1'
)
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.output).toMatchObject({
__simTruncated: true,
})
})
it('updates one workflow without replacing unrelated workflow arrays', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
output: { ok: true },
})
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-2',
blockId: 'block-2',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-2',
executionOrder: 1,
output: { ok: true },
})
const before = useTerminalConsoleStore.getState()
const workflowTwoEntries = before.workflowEntries['wf-2']
useTerminalConsoleStore.getState().updateConsole(
'block-1',
{
executionOrder: 1,
replaceOutput: { status: 'updated' },
},
'exec-1'
)
const after = useTerminalConsoleStore.getState()
expect(after.workflowEntries['wf-2']).toBe(workflowTwoEntries)
expect(after.getWorkflowEntries('wf-1')[0].output).toMatchObject({ status: 'updated' })
})
describe('cancelRunningEntries', () => {
it('flips a plain running entry to canceled', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
startedAt: new Date(Date.now() - 1000).toISOString(),
})
useTerminalConsoleStore.getState().cancelRunningEntries('wf-1')
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.isCanceled).toBe(true)
expect(entry.isRunning).toBe(false)
})
it('only cancels running entries for the requested execution when provided', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function 1',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
})
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-2',
blockName: 'Function 2',
blockType: 'function',
executionId: 'exec-2',
executionOrder: 2,
isRunning: true,
})
useTerminalConsoleStore.getState().cancelRunningEntries('wf-1', 'exec-1')
const entries = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entries.find((entry) => entry.executionId === 'exec-1')).toMatchObject({
isCanceled: true,
isRunning: false,
})
expect(entries.find((entry) => entry.executionId === 'exec-2')).toMatchObject({
isRunning: true,
})
})
})
describe('finishRunningEntries', () => {
it('settles running entries without marking them canceled', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
startedAt: new Date(Date.now() - 1000).toISOString(),
})
useTerminalConsoleStore.getState().finishRunningEntries('wf-1', 'exec-1')
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.isCanceled).toBe(false)
expect(entry.isRunning).toBe(false)
expect(entry.endedAt).toBeDefined()
})
})
})
+836
View File
@@ -0,0 +1,836 @@
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { sendMothershipMessage } from '@/lib/mothership/events'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
import type { NormalizedBlockOutput } from '@/executor/types'
import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings'
import { useExecutionStore } from '@/stores/execution'
import { consolePersistence, loadConsoleData } from '@/stores/terminal/console/storage'
import type {
ConsoleEntry,
ConsoleEntryLocation,
ConsoleStore,
ConsoleUpdate,
} from '@/stores/terminal/console/types'
import {
normalizeConsoleError,
normalizeConsoleInput,
normalizeConsoleOutput,
safeConsoleStringify,
trimWorkflowConsoleEntries,
} from '@/stores/terminal/console/utils'
const logger = createLogger('TerminalConsoleStore')
const EMPTY_CONSOLE_ENTRIES: ConsoleEntry[] = []
const updateBlockOutput = (
existingOutput: NormalizedBlockOutput | undefined,
contentUpdate: string
): NormalizedBlockOutput => {
return {
...(existingOutput || {}),
content: contentUpdate,
}
}
const isStreamingOutput = (output: any): boolean => {
if (typeof ReadableStream !== 'undefined' && output instanceof ReadableStream) {
return true
}
if (typeof output !== 'object' || !output) {
return false
}
return (
output.isStreaming === true ||
('executionData' in output &&
typeof output.executionData === 'object' &&
output.executionData?.isStreaming === true) ||
'stream' in output
)
}
const shouldSkipEntry = (output: any): boolean => {
if (typeof output !== 'object' || !output) {
return false
}
if ('stream' in output && 'executionData' in output) {
return true
}
if ('stream' in output && 'execution' in output) {
return true
}
return false
}
const getBlockExecutionKey = (blockId: string, executionId?: string): string =>
`${executionId ?? 'no-execution'}:${blockId}`
const matchesEntryForUpdate = (
entry: ConsoleEntry,
blockId: string,
executionId: string | undefined,
update: string | ConsoleUpdate
): boolean => {
if (entry.blockId !== blockId || entry.executionId !== executionId) {
return false
}
if (typeof update !== 'object') {
return true
}
if (update.executionOrder !== undefined && entry.executionOrder !== update.executionOrder) {
return false
}
if (update.iterationCurrent !== undefined && entry.iterationCurrent !== update.iterationCurrent) {
return false
}
if (
update.iterationContainerId !== undefined &&
entry.iterationContainerId !== update.iterationContainerId
) {
return false
}
if (
update.childWorkflowBlockId !== undefined &&
entry.childWorkflowBlockId !== update.childWorkflowBlockId
) {
return false
}
if (
update.childWorkflowInstanceId !== undefined &&
entry.childWorkflowInstanceId !== undefined &&
entry.childWorkflowInstanceId !== update.childWorkflowInstanceId
) {
return false
}
return true
}
function cloneWorkflowEntries(
workflowEntries: Record<string, ConsoleEntry[]>
): Record<string, ConsoleEntry[]> {
return { ...workflowEntries }
}
function removeWorkflowIndexes(
workflowId: string,
entries: ConsoleEntry[],
entryIdsByBlockExecution: Record<string, string[]>,
entryLocationById: Record<string, ConsoleEntryLocation>
): void {
for (const entry of entries) {
delete entryLocationById[entry.id]
const blockExecutionKey = getBlockExecutionKey(entry.blockId, entry.executionId)
const existingIds = entryIdsByBlockExecution[blockExecutionKey]
if (!existingIds) {
continue
}
const nextIds = existingIds.filter((entryId) => entryId !== entry.id)
if (nextIds.length === 0) {
delete entryIdsByBlockExecution[blockExecutionKey]
} else {
entryIdsByBlockExecution[blockExecutionKey] = nextIds
}
}
}
function indexWorkflowEntries(
workflowId: string,
entries: ConsoleEntry[],
entryIdsByBlockExecution: Record<string, string[]>,
entryLocationById: Record<string, ConsoleEntryLocation>
): void {
entries.forEach((entry, index) => {
entryLocationById[entry.id] = { workflowId, index }
const blockExecutionKey = getBlockExecutionKey(entry.blockId, entry.executionId)
const existingIds = entryIdsByBlockExecution[blockExecutionKey]
if (existingIds) {
entryIdsByBlockExecution[blockExecutionKey] = [...existingIds, entry.id]
} else {
entryIdsByBlockExecution[blockExecutionKey] = [entry.id]
}
})
}
function rebuildWorkflowStateMaps(workflowEntries: Record<string, ConsoleEntry[]>) {
const entryIdsByBlockExecution: Record<string, string[]> = {}
const entryLocationById: Record<string, ConsoleEntryLocation> = {}
Object.entries(workflowEntries).forEach(([workflowId, entries]) => {
indexWorkflowEntries(workflowId, entries, entryIdsByBlockExecution, entryLocationById)
})
return { entryIdsByBlockExecution, entryLocationById }
}
function replaceWorkflowEntries(
state: ConsoleStore,
workflowId: string,
nextEntries: ConsoleEntry[]
): Pick<ConsoleStore, 'workflowEntries' | 'entryIdsByBlockExecution' | 'entryLocationById'> {
const workflowEntries = cloneWorkflowEntries(state.workflowEntries)
const entryIdsByBlockExecution = { ...state.entryIdsByBlockExecution }
const entryLocationById = { ...state.entryLocationById }
const previousEntries = workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
removeWorkflowIndexes(workflowId, previousEntries, entryIdsByBlockExecution, entryLocationById)
if (nextEntries.length === 0) {
delete workflowEntries[workflowId]
} else {
workflowEntries[workflowId] = nextEntries
indexWorkflowEntries(workflowId, nextEntries, entryIdsByBlockExecution, entryLocationById)
}
return { workflowEntries, entryIdsByBlockExecution, entryLocationById }
}
function appendWorkflowEntry(
state: ConsoleStore,
workflowId: string,
newEntry: ConsoleEntry,
trimmedEntries: ConsoleEntry[]
): Pick<ConsoleStore, 'workflowEntries' | 'entryIdsByBlockExecution' | 'entryLocationById'> {
const workflowEntries = cloneWorkflowEntries(state.workflowEntries)
const previousEntries = workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
workflowEntries[workflowId] = trimmedEntries
const entryLocationById = { ...state.entryLocationById }
const entryIdsByBlockExecution = { ...state.entryIdsByBlockExecution }
const survivingIds = new Set(trimmedEntries.map((e) => e.id))
const droppedEntries = previousEntries.filter((e) => !survivingIds.has(e.id))
if (droppedEntries.length > 0) {
removeWorkflowIndexes(workflowId, droppedEntries, entryIdsByBlockExecution, entryLocationById)
}
trimmedEntries.forEach((entry, index) => {
entryLocationById[entry.id] = { workflowId, index }
})
const blockExecutionKey = getBlockExecutionKey(newEntry.blockId, newEntry.executionId)
const existingIds = entryIdsByBlockExecution[blockExecutionKey]
if (existingIds) {
if (!existingIds.includes(newEntry.id)) {
entryIdsByBlockExecution[blockExecutionKey] = [...existingIds, newEntry.id]
}
} else {
entryIdsByBlockExecution[blockExecutionKey] = [newEntry.id]
}
return { workflowEntries, entryIdsByBlockExecution, entryLocationById }
}
interface NotifyBlockErrorParams {
error: unknown
blockName: string
blockId: string
executionId?: string
logContext: Record<string, unknown>
}
/**
* A single block failure surfaces through both `addConsole` (initial entry)
* and `updateConsole` (streaming/finalize), so the same logical error asks to
* toast twice within the same tick. Collapse them inside a short window. The
* key is scoped to the block execution (not just block + message), so a re-run
* or a different execution still toasts — even within the window, and even
* after the stack was cleared on navigation.
*/
const NOTIFY_DEDUP_WINDOW_MS = 1500
const recentErrorNotifications = new Map<string, number>()
const notifyBlockError = ({
error,
blockName,
blockId,
executionId,
logContext,
}: NotifyBlockErrorParams) => {
const settings = getQueryClient().getQueryData<GeneralSettings>(generalSettingsKeys.settings())
const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true
if (!isErrorNotificationsEnabled) return
try {
const errorMessage = normalizeConsoleError(error) ?? String(error)
const displayName = blockName || 'Unknown Block'
const copilotMessage = `${errorMessage}\n\nError in ${displayName}.\n\nPlease fix this.`
const now = Date.now()
for (const [key, shownAt] of recentErrorNotifications) {
if (now - shownAt >= NOTIFY_DEDUP_WINDOW_MS) recentErrorNotifications.delete(key)
}
const dedupKey = `${getBlockExecutionKey(blockId, executionId)}: ${errorMessage}`
const lastShownAt = recentErrorNotifications.get(dedupKey)
if (lastShownAt !== undefined && now - lastShownAt < NOTIFY_DEDUP_WINDOW_MS) return
recentErrorNotifications.set(dedupKey, now)
toast.error(displayName, {
description: errorMessage,
action: {
label: 'Fix in Chat',
onClick: () => sendMothershipMessage(copilotMessage),
},
})
} catch (notificationError) {
logger.error('Failed to create block error notification', {
...logContext,
error: notificationError,
})
}
}
export const useTerminalConsoleStore = create<ConsoleStore>()(
devtools((set, get) => ({
workflowEntries: {},
entryIdsByBlockExecution: {},
entryLocationById: {},
isOpen: false,
_hasHydrated: false,
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => {
if (shouldSkipEntry(entry.output)) {
return get().getWorkflowEntries(entry.workflowId)[0] as ConsoleEntry | undefined
}
const redactedEntry = { ...entry }
if (
!isStreamingOutput(entry.output) &&
redactedEntry.output &&
typeof redactedEntry.output === 'object'
) {
redactedEntry.output = redactApiKeys(redactedEntry.output)
}
if (redactedEntry.input && typeof redactedEntry.input === 'object') {
redactedEntry.input = redactApiKeys(redactedEntry.input)
}
const createdEntry: ConsoleEntry = {
...redactedEntry,
id: generateId(),
timestamp: new Date().toISOString(),
input: normalizeConsoleInput(redactedEntry.input),
output: normalizeConsoleOutput(redactedEntry.output),
error: normalizeConsoleError(redactedEntry.error),
warning:
typeof redactedEntry.warning === 'string'
? (normalizeConsoleError(redactedEntry.warning) ?? undefined)
: redactedEntry.warning,
}
set((state) => {
const workflowEntries = state.workflowEntries[entry.workflowId] ?? EMPTY_CONSOLE_ENTRIES
const nextWorkflowEntries = trimWorkflowConsoleEntries([createdEntry, ...workflowEntries])
return appendWorkflowEntry(state, entry.workflowId, createdEntry, nextWorkflowEntries)
})
if (createdEntry.isRunning) {
consolePersistence.onRunningEntryAdded()
}
if (createdEntry.error && createdEntry.blockType !== 'cancelled') {
notifyBlockError({
error: createdEntry.error,
blockName: createdEntry.blockName || 'Unknown Block',
blockId: createdEntry.blockId,
executionId: createdEntry.executionId,
logContext: { entryId: createdEntry.id },
})
}
return createdEntry
},
clearWorkflowConsole: (workflowId: string) => {
set((state) => replaceWorkflowEntries(state, workflowId, EMPTY_CONSOLE_ENTRIES))
useExecutionStore.getState().clearRunPath(workflowId)
consolePersistence.persist({ merge: false })
},
clearExecutionEntries: (executionId: string) =>
set((state) => {
const nextWorkflowEntries = cloneWorkflowEntries(state.workflowEntries)
let didChange = false
Object.entries(nextWorkflowEntries).forEach(([workflowId, entries]) => {
const filteredEntries = entries.filter((entry) => entry.executionId !== executionId)
if (filteredEntries.length !== entries.length) {
nextWorkflowEntries[workflowId] = filteredEntries
didChange = true
}
})
if (!didChange) {
return state
}
const normalizedEntries = Object.fromEntries(
Object.entries(nextWorkflowEntries).filter(([, entries]) => entries.length > 0)
)
return {
workflowEntries: normalizedEntries,
...rebuildWorkflowStateMaps(normalizedEntries),
}
}),
exportConsoleCSV: (workflowId: string) => {
const entries = get().getWorkflowEntries(workflowId)
if (entries.length === 0) {
return
}
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
let stringValue = typeof value === 'object' ? safeConsoleStringify(value) : String(value)
if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n')) {
stringValue = `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const headers = [
'timestamp',
'blockName',
'blockType',
'startedAt',
'endedAt',
'durationMs',
'success',
'input',
'output',
'error',
'warning',
]
const csvRows = [
headers.join(','),
...entries.map((entry) =>
[
formatCSVValue(entry.timestamp),
formatCSVValue(entry.blockName),
formatCSVValue(entry.blockType),
formatCSVValue(entry.startedAt),
formatCSVValue(entry.endedAt),
formatCSVValue(entry.durationMs),
formatCSVValue(entry.success),
formatCSVValue(entry.input),
formatCSVValue(entry.output),
formatCSVValue(entry.error),
formatCSVValue(entry.warning),
].join(',')
),
]
const csvContent = csvRows.join('\n')
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const filename = `terminal-console-${workflowId}-${timestamp}.csv`
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
},
getWorkflowEntries: (workflowId) => {
return get().workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
},
toggleConsole: () => {
set((state) => ({ isOpen: !state.isOpen }))
},
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => {
set((state) => {
const candidateIds =
state.entryIdsByBlockExecution[getBlockExecutionKey(blockId, executionId)] ?? []
if (candidateIds.length === 0) {
return state
}
const workflowId = state.entryLocationById[candidateIds[0]]?.workflowId
if (!workflowId) {
return state
}
const currentEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
let nextEntries: ConsoleEntry[] | null = null
for (const candidateId of candidateIds) {
const location = state.entryLocationById[candidateId]
if (!location || location.workflowId !== workflowId) continue
const source = nextEntries ?? currentEntries
const entry = source[location.index]
if (!entry || entry.id !== candidateId) continue
if (!matchesEntryForUpdate(entry, blockId, executionId, update)) continue
if (!nextEntries) {
nextEntries = [...currentEntries]
}
if (typeof update === 'string') {
const newOutput = normalizeConsoleOutput(updateBlockOutput(entry.output, update))
nextEntries[location.index] = { ...entry, output: newOutput }
continue
}
const updatedEntry = { ...entry }
if (update.content !== undefined) {
updatedEntry.output = normalizeConsoleOutput(
updateBlockOutput(entry.output, update.content)
)
}
if (update.replaceOutput !== undefined) {
const redactedOutput =
typeof update.replaceOutput === 'object' && update.replaceOutput !== null
? redactApiKeys(update.replaceOutput)
: update.replaceOutput
updatedEntry.output = normalizeConsoleOutput(redactedOutput)
} else if (update.output !== undefined) {
const mergedOutput = {
...(entry.output || {}),
...update.output,
}
updatedEntry.output =
typeof mergedOutput === 'object'
? normalizeConsoleOutput(redactApiKeys(mergedOutput))
: normalizeConsoleOutput(mergedOutput)
}
if (update.blockName !== undefined) {
updatedEntry.blockName = update.blockName
}
if (update.blockType !== undefined) {
updatedEntry.blockType = update.blockType
}
if (update.error !== undefined) {
updatedEntry.error = normalizeConsoleError(update.error)
}
if (update.warning !== undefined) {
updatedEntry.warning = normalizeConsoleError(update.warning) ?? undefined
}
if (update.success !== undefined) {
updatedEntry.success = update.success
}
if (update.startedAt !== undefined) {
updatedEntry.startedAt = update.startedAt
}
if (update.endedAt !== undefined) {
updatedEntry.endedAt = update.endedAt
}
if (update.durationMs !== undefined) {
updatedEntry.durationMs = update.durationMs
}
if (update.input !== undefined) {
updatedEntry.input =
typeof update.input === 'object' && update.input !== null
? normalizeConsoleInput(redactApiKeys(update.input))
: normalizeConsoleInput(update.input)
}
if (update.isRunning !== undefined) {
updatedEntry.isRunning = update.isRunning
}
if (update.isCanceled !== undefined) {
updatedEntry.isCanceled = update.isCanceled
}
if (update.iterationCurrent !== undefined) {
updatedEntry.iterationCurrent = update.iterationCurrent
}
if (update.iterationTotal !== undefined) {
updatedEntry.iterationTotal = update.iterationTotal
}
if (update.iterationType !== undefined) {
updatedEntry.iterationType = update.iterationType
}
if (update.iterationContainerId !== undefined) {
updatedEntry.iterationContainerId = update.iterationContainerId
}
if (update.parentIterations !== undefined) {
updatedEntry.parentIterations = update.parentIterations
}
if (update.childWorkflowBlockId !== undefined) {
updatedEntry.childWorkflowBlockId = update.childWorkflowBlockId
}
if (update.childWorkflowName !== undefined) {
updatedEntry.childWorkflowName = update.childWorkflowName
}
if (update.childWorkflowInstanceId !== undefined) {
updatedEntry.childWorkflowInstanceId = update.childWorkflowInstanceId
}
nextEntries[location.index] = updatedEntry
}
if (!nextEntries) {
return state
}
const workflowEntriesClone = cloneWorkflowEntries(state.workflowEntries)
workflowEntriesClone[workflowId] = nextEntries
return {
workflowEntries: workflowEntriesClone,
entryIdsByBlockExecution: state.entryIdsByBlockExecution,
entryLocationById: state.entryLocationById,
}
})
if (typeof update === 'object' && update.error) {
const matchingEntry = get()
.getWorkflowEntries(
get().entryLocationById[
(get().entryIdsByBlockExecution[getBlockExecutionKey(blockId, executionId)] ??
[])[0] ?? ''
]?.workflowId ?? ''
)
.find((entry) => matchesEntryForUpdate(entry, blockId, executionId, update))
notifyBlockError({
error: update.error,
blockName: update.blockName || matchingEntry?.blockName || 'Unknown Block',
blockId,
executionId,
logContext: { blockId },
})
}
},
cancelRunningEntries: (workflowId: string, executionId?: string) => {
set((state) => {
const now = new Date()
const workflowEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
let didChange = false
const updatedEntries = workflowEntries.map((entry) => {
if (
entry.workflowId === workflowId &&
entry.isRunning &&
(executionId === undefined || entry.executionId === executionId)
) {
didChange = true
const durationMs = entry.startedAt
? now.getTime() - new Date(entry.startedAt).getTime()
: entry.durationMs
return {
...entry,
isRunning: false,
isCanceled: true,
endedAt: now.toISOString(),
durationMs,
}
}
return entry
})
if (!didChange) {
return state
}
return replaceWorkflowEntries(state, workflowId, updatedEntries)
})
},
finishRunningEntries: (workflowId: string, executionId?: string) => {
set((state) => {
const now = new Date()
const workflowEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
let didChange = false
const updatedEntries = workflowEntries.map((entry) => {
if (
entry.workflowId === workflowId &&
entry.isRunning &&
(executionId === undefined || entry.executionId === executionId)
) {
didChange = true
const durationMs = entry.startedAt
? now.getTime() - new Date(entry.startedAt).getTime()
: entry.durationMs
return {
...entry,
isRunning: false,
isCanceled: false,
endedAt: now.toISOString(),
durationMs,
}
}
return entry
})
if (!didChange) {
return state
}
return replaceWorkflowEntries(state, workflowId, updatedEntries)
})
},
}))
)
/**
* Hydrates the console store from IndexedDB on startup.
* Applies the same normalization and trimming as the old persist merge.
*/
async function hydrateConsoleStore(): Promise<void> {
try {
const data = await loadConsoleData()
if (!data) {
useTerminalConsoleStore.setState({ _hasHydrated: true })
return
}
const oneHourAgo = Date.now() - 60 * 60 * 1000
const workflowEntries = Object.fromEntries(
Object.entries(data.workflowEntries).map(([workflowId, entries]) => [
workflowId,
trimWorkflowConsoleEntries(
entries.map((entry, index) => {
let updated = entry
if (entry.executionOrder === undefined) {
updated = { ...updated, executionOrder: index + 1 }
}
if (
entry.isRunning &&
entry.startedAt &&
new Date(entry.startedAt).getTime() < oneHourAgo
) {
updated = { ...updated, isRunning: false }
}
updated = {
...updated,
input: normalizeConsoleInput(updated.input),
output: normalizeConsoleOutput(updated.output),
error: normalizeConsoleError(updated.error),
warning:
typeof updated.warning === 'string'
? (normalizeConsoleError(updated.warning) ?? undefined)
: updated.warning,
}
return updated
})
),
])
)
const currentState = useTerminalConsoleStore.getState()
const mergedWorkflowEntries = { ...workflowEntries }
for (const [wfId, currentEntries] of Object.entries(currentState.workflowEntries)) {
if (currentEntries.length > 0) {
const persistedEntries = mergedWorkflowEntries[wfId] ?? []
const persistedIds = new Set(persistedEntries.map((e) => e.id))
const newEntries = currentEntries.filter((e) => !persistedIds.has(e.id))
if (newEntries.length > 0) {
mergedWorkflowEntries[wfId] = trimWorkflowConsoleEntries([
...newEntries,
...persistedEntries,
])
}
}
}
useTerminalConsoleStore.setState({
workflowEntries: mergedWorkflowEntries,
...rebuildWorkflowStateMaps(mergedWorkflowEntries),
isOpen: data.isOpen,
_hasHydrated: true,
})
} catch (error) {
logger.error('Failed to hydrate console store', { error })
useTerminalConsoleStore.setState({ _hasHydrated: true })
}
}
if (typeof window !== 'undefined') {
consolePersistence.bind(() => {
const state = useTerminalConsoleStore.getState()
return {
workflowEntries: state.workflowEntries,
isOpen: state.isOpen,
}
})
hydrateConsoleStore()
window.addEventListener('pagehide', () => consolePersistence.persist())
}
export function useWorkflowConsoleEntries(workflowId?: string): ConsoleEntry[] {
return useTerminalConsoleStore(
useShallow((state) => {
if (!workflowId) {
return EMPTY_CONSOLE_ENTRIES
}
return state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
})
)
}
export function useConsoleEntry(entryId?: string | null): ConsoleEntry | null {
return useTerminalConsoleStore((state) => {
if (!entryId) {
return null
}
const location = state.entryLocationById[entryId]
if (!location) {
return null
}
const entry = state.workflowEntries[location.workflowId]?.[location.index]
if (!entry || entry.id !== entryId) {
return null
}
return entry
})
}
+83
View File
@@ -0,0 +1,83 @@
import type { ParentIteration } from '@/executor/execution/types'
import type { NormalizedBlockOutput } from '@/executor/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface ConsoleEntry {
id: string
timestamp: string
workflowId: string
blockId: string
blockName: string
blockType: string
executionId?: string
startedAt?: string
executionOrder: number
endedAt?: string
durationMs?: number
success?: boolean
input?: any
output?: NormalizedBlockOutput
error?: string | Error | null
warning?: string
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
iterationContainerId?: string
parentIterations?: ParentIteration[]
isRunning?: boolean
isCanceled?: boolean
/** ID of the workflow block in the parent execution that spawned this child block */
childWorkflowBlockId?: string
/** Display name of the child workflow this block belongs to */
childWorkflowName?: string
/** Per-invocation unique ID linking this workflow block to its child block events */
childWorkflowInstanceId?: string
}
export interface ConsoleUpdate {
content?: string
output?: Partial<NormalizedBlockOutput>
replaceOutput?: NormalizedBlockOutput
blockName?: string
blockType?: string
executionOrder?: number
error?: string | Error | null
warning?: string
success?: boolean
startedAt?: string
endedAt?: string
durationMs?: number
input?: any
isRunning?: boolean
isCanceled?: boolean
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
iterationContainerId?: string
parentIterations?: ParentIteration[]
childWorkflowBlockId?: string
childWorkflowName?: string
childWorkflowInstanceId?: string
}
export interface ConsoleEntryLocation {
workflowId: string
index: number
}
export interface ConsoleStore {
workflowEntries: Record<string, ConsoleEntry[]>
entryIdsByBlockExecution: Record<string, string[]>
entryLocationById: Record<string, ConsoleEntryLocation>
isOpen: boolean
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => ConsoleEntry | undefined
clearWorkflowConsole: (workflowId: string) => void
clearExecutionEntries: (executionId: string) => void
exportConsoleCSV: (workflowId: string) => void
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
toggleConsole: () => void
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
cancelRunningEntries: (workflowId: string, executionId?: string) => void
finishRunningEntries: (workflowId: string, executionId?: string) => void
_hasHydrated: boolean
}
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ConsoleEntry } from './types'
import {
normalizeConsoleOutput,
safeConsoleStringify,
TERMINAL_CONSOLE_LIMITS,
trimConsoleEntries,
} from './utils'
function makeEntry(id: string, executionId: string, workflowId = 'wf-1'): ConsoleEntry {
return {
id,
executionId,
workflowId,
blockId: `block-${id}`,
blockName: `Block ${id}`,
blockType: 'function',
executionOrder: Number.parseInt(id.replace(/\D/g, ''), 10) || 0,
timestamp: '2025-01-01T00:00:00.000Z',
}
}
describe('terminal console utils', () => {
it('safely stringifies circular values', () => {
const circular: { name: string; self?: unknown } = { name: 'root' }
circular.self = circular
const result = safeConsoleStringify(circular)
expect(result).toContain('[Circular]')
expect(result).toContain('"name": "root"')
})
it('preserves small objects nested at the agent tool-call depth', () => {
const output = normalizeConsoleOutput({
toolCalls: {
list: [
{
name: 'table_query_rows',
result: {
rows: [{ data: { deal_id: 'DEAL-001', client_name: 'Jennifer Martinez' } }],
},
},
],
},
}) as {
toolCalls: { list: Array<{ result: { rows: Array<{ data: Record<string, unknown> }> } }> }
}
const row = output.toolCalls.list[0].result.rows[0]
expect(row).not.toBe('[Truncated object]')
expect(row.data.deal_id).toBe('DEAL-001')
expect(row.data.client_name).toBe('Jennifer Martinez')
})
it('resolves true circular references without infinite recursion', () => {
const circular: { name: string; self?: unknown } = { name: 'root' }
circular.self = circular
const output = normalizeConsoleOutput(circular) as { name: string; self: unknown }
expect(output.name).toBe('root')
expect(output.self).toBe('[Circular]')
})
it('renders a value shared across sibling positions fully (not circular)', () => {
const shared = { x: 1 }
const output = normalizeConsoleOutput({ a: shared, b: shared }) as {
a: { x: number }
b: { x: number }
}
expect(output.a).toEqual({ x: 1 })
expect(output.b).toEqual({ x: 1 })
})
it('truncates structures nested beyond MAX_DEPTH as a backstop', () => {
let deep: Record<string, unknown> = { value: 'leaf' }
for (let i = 0; i < TERMINAL_CONSOLE_LIMITS.MAX_DEPTH + 2; i++) {
deep = { nested: deep }
}
const serialized = safeConsoleStringify(normalizeConsoleOutput(deep))
expect(serialized).toContain('[Truncated object]')
expect(serialized).not.toContain('leaf')
})
it('truncates oversized nested strings in console output', () => {
const output = normalizeConsoleOutput({
stdout: 'x'.repeat(TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH + 100),
})
expect(output?.stdout).toContain('[truncated 100 chars]')
})
it('caps oversized normalized payloads with a preview object', () => {
const output = normalizeConsoleOutput({
a: 'x'.repeat(100_000),
b: 'y'.repeat(100_000),
c: 'z'.repeat(100_000),
d: 'q'.repeat(100_000),
e: 'r'.repeat(100_000),
f: 's'.repeat(100_000),
}) as Record<string, unknown>
expect(output.__simTruncated).toBe(true)
expect(typeof output.__simPreview).toBe('string')
expect(typeof output.__simByteLength).toBe('number')
})
it('preserves the newest oversized execution by trimming within it first', () => {
const newestEntries = Array.from({ length: 5_100 }, (_, index) =>
makeEntry(`new-${index}`, 'exec-new')
)
const olderEntries = Array.from({ length: 25 }, (_, index) =>
makeEntry(`old-${index}`, 'exec-old')
)
const trimmed = trimConsoleEntries([...newestEntries, ...olderEntries])
expect(trimmed).toHaveLength(TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW)
expect(trimmed.every((entry) => entry.executionId === 'exec-new')).toBe(true)
expect(trimmed[0].id).toBe('new-0')
expect(trimmed.at(-1)?.id).toBe(`new-${TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW - 1}`)
})
it('keeps older whole executions when they still fit after the newest run', () => {
const newestEntries = Array.from({ length: 4_990 }, (_, index) =>
makeEntry(`new-${index}`, 'exec-new')
)
const olderEntries = Array.from({ length: 10 }, (_, index) =>
makeEntry(`old-${index}`, 'exec-old')
)
const trimmed = trimConsoleEntries([...newestEntries, ...olderEntries])
expect(trimmed).toHaveLength(5_000)
expect(trimmed.filter((entry) => entry.executionId === 'exec-new')).toHaveLength(4_990)
expect(trimmed.filter((entry) => entry.executionId === 'exec-old')).toHaveLength(10)
})
})
+319
View File
@@ -0,0 +1,319 @@
import type { NormalizedBlockOutput } from '@/executor/types'
import type { ConsoleEntry } from './types'
/**
* Terminal console safety limits used to bound persisted debug data.
*/
export const TERMINAL_CONSOLE_LIMITS = {
MAX_ENTRIES_PER_WORKFLOW: 5000,
MAX_STRING_LENGTH: 50_000,
MAX_OBJECT_KEYS: 100,
MAX_ARRAY_ITEMS: 100,
MAX_DEPTH: 12,
MAX_SERIALIZED_BYTES: 256 * 1024,
MAX_SERIALIZED_PREVIEW_LENGTH: 10_000,
} as const
const textEncoder = new TextEncoder()
/**
* Returns the UTF-8 byte length of a string.
*/
function getByteLength(value: string): number {
return textEncoder.encode(value).length
}
/**
* Truncates a string while preserving a short explanation.
*/
function truncateString(
value: string,
maxLength: number = TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH
): string {
if (value.length <= maxLength) {
return value
}
return `${value.slice(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
}
/**
* Safely stringifies terminal data without throwing on circular or non-JSON-safe values.
*/
export function safeConsoleStringify(value: unknown): string {
const seen = new WeakSet<object>()
try {
return (
JSON.stringify(
value,
(_key, currentValue) => {
if (typeof currentValue === 'bigint') {
return `${currentValue.toString()}n`
}
if (currentValue instanceof Error) {
return {
name: currentValue.name,
message: currentValue.message,
stack: currentValue.stack,
}
}
if (typeof currentValue === 'function') {
return `[Function ${currentValue.name || 'anonymous'}]`
}
if (typeof currentValue === 'symbol') {
return currentValue.toString()
}
if (typeof currentValue === 'object' && currentValue !== null) {
if (seen.has(currentValue)) {
return '[Circular]'
}
seen.add(currentValue)
}
return currentValue
},
2
) ?? ''
)
} catch {
try {
return String(value)
} catch {
return '[Unserializable value]'
}
}
}
/**
* Produces a terminal-safe representation of any value.
*
* Recursion is bounded by two independent guards: `seen` tracks the current
* ancestor chain so true circular references resolve to `[Circular]` (a value
* reused across sibling positions is not a cycle and renders fully), and
* `MAX_DEPTH` is a pathological-nesting backstop. Actual payload size is bounded
* downstream by `truncateString` and `capNormalizedValue`, not by depth.
*/
export function normalizeConsoleValue(
value: unknown,
depth = 0,
seen: WeakSet<object> = new WeakSet()
): unknown {
if (value === null || value === undefined) {
return value
}
if (typeof value === 'string') {
return truncateString(value)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value
}
if (typeof value === 'bigint') {
return `${value.toString()}n`
}
if (typeof value === 'function') {
return `[Function ${value.name || 'anonymous'}]`
}
if (typeof value === 'symbol') {
return value.toString()
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
stack: value.stack ? truncateString(value.stack) : undefined,
}
}
if (depth >= TERMINAL_CONSOLE_LIMITS.MAX_DEPTH) {
return `[Truncated ${Array.isArray(value) ? 'array' : 'object'}]`
}
const objectValue = value as object
if (seen.has(objectValue)) {
return '[Circular]'
}
seen.add(objectValue)
try {
if (Array.isArray(value)) {
const normalizedItems = value
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
.map((item) => normalizeConsoleValue(item, depth + 1, seen))
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
normalizedItems.push(
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
)
}
return normalizedItems
}
const objectEntries = Object.entries(value as Record<string, unknown>)
const normalizedObject: Record<string, unknown> = {}
for (const [key, entryValue] of objectEntries.slice(
0,
TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
)) {
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1, seen)
}
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
normalizedObject.__simTruncatedKeys =
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
}
return normalizedObject
} finally {
seen.delete(objectValue)
}
}
/**
* Applies a final serialized-size cap after recursive normalization.
*/
function capNormalizedValue(value: unknown): unknown {
if (value === null || value === undefined) {
return value
}
const serialized = safeConsoleStringify(value)
const serializedBytes = getByteLength(serialized)
if (serializedBytes <= TERMINAL_CONSOLE_LIMITS.MAX_SERIALIZED_BYTES) {
return value
}
return {
__simTruncated: true,
__simByteLength: serializedBytes,
__simPreview: truncateString(serialized, TERMINAL_CONSOLE_LIMITS.MAX_SERIALIZED_PREVIEW_LENGTH),
}
}
/**
* Normalizes terminal input data before it is stored.
*/
export function normalizeConsoleInput(input: unknown): unknown {
return capNormalizedValue(normalizeConsoleValue(input))
}
/**
* Normalizes terminal output data before it is stored.
*/
export function normalizeConsoleOutput(output: unknown): NormalizedBlockOutput | undefined {
if (output === undefined) {
return undefined
}
return capNormalizedValue(normalizeConsoleValue(output)) as NormalizedBlockOutput
}
/**
* Normalizes terminal error data before it is stored.
*/
export function normalizeConsoleError(error: unknown): string | null | undefined {
if (error === undefined) {
return undefined
}
if (error === null) {
return null
}
return truncateString(
typeof error === 'string' ? error : safeConsoleStringify(normalizeConsoleValue(error))
)
}
/**
* Returns a workflow's entries trimmed to the configured cap.
*/
export function trimWorkflowConsoleEntries(entries: ConsoleEntry[]): ConsoleEntry[] {
if (entries.length <= TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW) {
return entries
}
const executionGroups = new Map<string, ConsoleEntry[]>()
for (const entry of entries) {
const executionId = entry.executionId ?? entry.id
const group = executionGroups.get(executionId)
if (group) {
group.push(entry)
} else {
executionGroups.set(executionId, [entry])
}
}
const executionIds = [...executionGroups.keys()]
const newestExecutionId = executionIds[0]
if (!newestExecutionId) {
return entries.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW)
}
const keptEntryIds = new Set<string>()
let remainingSlots = TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW
const newestExecutionEntries = executionGroups.get(newestExecutionId) ?? []
const newestExecutionToKeep = newestExecutionEntries.slice(0, remainingSlots)
newestExecutionToKeep.forEach((entry) => keptEntryIds.add(entry.id))
remainingSlots -= newestExecutionToKeep.length
for (const executionId of executionIds.slice(1)) {
const executionEntries = executionGroups.get(executionId) ?? []
if (executionEntries.length > remainingSlots) {
continue
}
executionEntries.forEach((entry) => keptEntryIds.add(entry.id))
remainingSlots -= executionEntries.length
if (remainingSlots === 0) {
break
}
}
return entries.filter((entry) => keptEntryIds.has(entry.id))
}
/**
* Applies workflow-level trimming while preserving newest-first order.
*/
export function trimConsoleEntries(entries: ConsoleEntry[]): ConsoleEntry[] {
const workflowGroups = new Map<string, ConsoleEntry[]>()
for (const entry of entries) {
const workflowEntries = workflowGroups.get(entry.workflowId)
if (workflowEntries) {
workflowEntries.push(entry)
} else {
workflowGroups.set(entry.workflowId, [entry])
}
}
const keptEntryIds = new Set<string>()
for (const workflowEntries of workflowGroups.values()) {
trimWorkflowConsoleEntries(workflowEntries).forEach((entry) => keptEntryIds.add(entry.id))
}
return entries.filter((entry) => keptEntryIds.has(entry.id))
}
+12
View File
@@ -0,0 +1,12 @@
export type { ConsoleEntry, ConsoleUpdate } from './console'
export {
clearExecutionPointer,
consolePersistence,
loadExecutionPointer,
safeConsoleStringify,
saveExecutionPointer,
useConsoleEntry,
useTerminalConsoleStore,
useWorkflowConsoleEntries,
} from './console'
export { useTerminalStore } from './store'
+123
View File
@@ -0,0 +1,123 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
import type { TerminalState } from './types'
export const useTerminalStore = create<TerminalState>()(
persist(
(set) => ({
terminalHeight: TERMINAL_HEIGHT.DEFAULT,
lastExpandedHeight: TERMINAL_HEIGHT.DEFAULT,
isResizing: false,
/**
* Updates the terminal height and synchronizes the CSS custom property.
*
* @remarks
* - Enforces a minimum height to keep the resize handle usable.
* - Persists {@link TerminalState.lastExpandedHeight} only when the
* height is expanded above the minimum.
*
* @param height - Desired terminal height in pixels.
*/
setTerminalHeight: (height) => {
const clampedHeight = Math.max(TERMINAL_HEIGHT.MIN, height)
set((state) => ({
terminalHeight: clampedHeight,
lastExpandedHeight:
clampedHeight > TERMINAL_HEIGHT.MIN ? clampedHeight : state.lastExpandedHeight,
}))
// Update CSS variable for immediate visual feedback
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--terminal-height', `${clampedHeight}px`)
}
},
/**
* Updates the terminal resize state used to coordinate layout transitions.
*
* @param isResizing - True while the terminal is being resized via mouse drag.
*/
setIsResizing: (isResizing) => {
set({ isResizing })
},
outputPanelWidth: OUTPUT_PANEL_WIDTH.DEFAULT,
/**
* Updates the output panel width, enforcing the minimum constraint.
*
* @param width - Desired width in pixels for the output panel.
*/
setOutputPanelWidth: (width) => {
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, width)
set({ outputPanelWidth: clampedWidth })
},
openOnRun: true,
/**
* Enables or disables automatic terminal opening when new entries are added.
*
* @param open - Whether the terminal should open on new console entries.
*/
setOpenOnRun: (open) => {
set({ openOnRun: open })
},
wrapText: true,
/**
* Enables or disables text wrapping in the output panel.
*
* @param wrap - Whether output text should wrap.
*/
setWrapText: (wrap) => {
set({ wrapText: wrap })
},
structuredView: true,
/**
* Enables or disables structured view mode in the output panel.
*
* @param structured - Whether output should be displayed as nested blocks.
*/
setStructuredView: (structured) => {
set({ structuredView: structured })
},
/**
* Indicates whether the terminal store has finished client-side hydration.
*/
_hasHydrated: false,
/**
* Marks the store as hydrated on the client.
*
* @param hasHydrated - True when client-side hydration is complete.
*/
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })
},
}),
{
name: 'terminal-state',
/**
* Persist only the durable terminal UI preferences. The transient
* `isResizing` drag flag and the `_hasHydrated` hydration marker are
* excluded so they always start fresh on load.
*/
partialize: (state) => ({
terminalHeight: state.terminalHeight,
lastExpandedHeight: state.lastExpandedHeight,
outputPanelWidth: state.outputPanelWidth,
openOnRun: state.openOnRun,
wrapText: state.wrapText,
structuredView: state.structuredView,
}),
/**
* Synchronizes the `--terminal-height` CSS custom property with the
* persisted store value after client-side rehydration.
*/
onRehydrateStorage: () => (state) => {
if (state && typeof window !== 'undefined') {
document.documentElement.style.setProperty(
'--terminal-height',
`${state.terminalHeight}px`
)
}
},
}
)
)
+41
View File
@@ -0,0 +1,41 @@
/**
* Display mode type for terminal output.
*
* @remarks
* Currently unused but kept for future customization of terminal rendering.
*/
// export type DisplayMode = 'raw' | 'prettier'
/**
* Terminal state persisted across workspace sessions.
*/
export interface TerminalState {
terminalHeight: number
setTerminalHeight: (height: number) => void
lastExpandedHeight: number
outputPanelWidth: number
setOutputPanelWidth: (width: number) => void
openOnRun: boolean
setOpenOnRun: (open: boolean) => void
wrapText: boolean
setWrapText: (wrap: boolean) => void
structuredView: boolean
setStructuredView: (structured: boolean) => void
/**
* Indicates whether the terminal is currently being resized via mouse drag.
*
* @remarks
* This flag is used by other workspace UI elements (e.g. notifications,
* diff controls) to temporarily disable position transitions while the
* terminal height is actively changing, avoiding janky animations.
*/
isResizing: boolean
/**
* Updates the {@link TerminalState.isResizing} flag.
*
* @param isResizing - True while the terminal is being resized.
*/
setIsResizing: (isResizing: boolean) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}
+36
View File
@@ -0,0 +1,36 @@
import { createLogger } from '@sim/logger'
import { del, get, set } from 'idb-keyval'
import type { StateStorage } from 'zustand/middleware'
const logger = createLogger('CodeUndoRedoStorage')
export const codeUndoRedoStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (typeof window === 'undefined') return null
try {
const value = await get<string>(name)
return value ?? null
} catch (error) {
logger.warn('IndexedDB read failed', { name, error })
return null
}
},
setItem: async (name: string, value: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await set(name, value)
} catch (error) {
logger.warn('IndexedDB write failed', { name, error })
}
},
removeItem: async (name: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await del(name)
} catch (error) {
logger.warn('IndexedDB delete failed', { name, error })
}
},
}
+151
View File
@@ -0,0 +1,151 @@
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { codeUndoRedoStorage } from '@/stores/undo-redo/code-storage'
interface CodeUndoRedoEntry {
id: string
createdAt: number
workflowId: string
blockId: string
subBlockId: string
before: string
after: string
}
interface CodeUndoRedoStack {
undo: CodeUndoRedoEntry[]
redo: CodeUndoRedoEntry[]
lastUpdated?: number
}
interface CodeUndoRedoState {
stacks: Record<string, CodeUndoRedoStack>
capacity: number
push: (entry: CodeUndoRedoEntry) => void
undo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
redo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
clear: (workflowId: string, blockId: string, subBlockId: string) => void
}
const DEFAULT_CAPACITY = 500
const MAX_STACKS = 50
function getStackKey(workflowId: string, blockId: string, subBlockId: string): string {
return `${workflowId}:${blockId}:${subBlockId}`
}
const initialState = {
stacks: {} as Record<string, CodeUndoRedoStack>,
capacity: DEFAULT_CAPACITY,
}
export const useCodeUndoRedoStore = create<CodeUndoRedoState>()(
devtools(
persist(
(set, get) => ({
...initialState,
push: (entry) => {
if (entry.before === entry.after) return
const state = get()
const key = getStackKey(entry.workflowId, entry.blockId, entry.subBlockId)
const currentStacks = { ...state.stacks }
const stackKeys = Object.keys(currentStacks)
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
let oldestKey: string | null = null
let oldestTime = Number.POSITIVE_INFINITY
for (const stackKey of stackKeys) {
const t = currentStacks[stackKey].lastUpdated ?? 0
if (t < oldestTime) {
oldestTime = t
oldestKey = stackKey
}
}
if (oldestKey) {
delete currentStacks[oldestKey]
}
}
const stack = currentStacks[key] || { undo: [], redo: [] }
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
currentStacks[key] = {
undo: newUndo,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
},
undo: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.undo.length === 0) return null
const entry = stack.undo[stack.undo.length - 1]
const newUndo = stack.undo.slice(0, -1)
const newRedo = [...stack.redo, entry]
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo.slice(-state.capacity),
lastUpdated: Date.now(),
},
},
})
return entry
},
redo: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.redo.length === 0) return null
const entry = stack.redo[stack.redo.length - 1]
const newRedo = stack.redo.slice(0, -1)
const newUndo = [...stack.undo, entry]
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo.slice(-state.capacity),
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
return entry
},
clear: (workflowId, blockId, subBlockId) => {
const key = getStackKey(workflowId, blockId, subBlockId)
const state = get()
const { [key]: _, ...rest } = state.stacks
set({ stacks: rest })
},
}),
{
name: 'code-undo-redo-store',
storage: createJSONStorage(() => codeUndoRedoStorage),
partialize: (state) => ({
stacks: state.stacks,
capacity: state.capacity,
}),
}
),
{ name: 'code-undo-redo-store' }
)
)
+4
View File
@@ -0,0 +1,4 @@
export { useCodeUndoRedoStore } from './code-store'
export { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from './store'
export * from './types'
export * from './utils'
+765
View File
@@ -0,0 +1,765 @@
/**
* Tests for the undo/redo store.
*
* These tests cover:
* - Basic push/undo/redo operations
* - Stack capacity limits
* - Move operation coalescing
* - Recording suspension
* - Stack pruning
* - Multi-workflow/user isolation
*/
import {
createAddBlockEntry,
createAddEdgeEntry,
createBatchRemoveEdgesEntry,
createBlock,
createMockStorage,
createMoveBlockEntry,
createRemoveBlockEntry,
createUpdateParentEntry,
} from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
describe('useUndoRedoStore', () => {
const workflowId = 'wf-test'
const userId = 'user-test'
beforeEach(() => {
global.localStorage = createMockStorage()
useUndoRedoStore.setState({
stacks: {},
capacity: 100,
})
})
describe('push', () => {
it('should add an operation to the undo stack', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should clear redo stack when pushing new operation', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 0,
})
})
it('should respect capacity limit', () => {
useUndoRedoStore.setState({ capacity: 3 })
const { push, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 5; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
})
it('should limit number of stacks to 5', () => {
const { push } = useUndoRedoStore.getState()
// Create 6 different workflow/user combinations
for (let i = 0; i < 6; i++) {
const wfId = `wf-${i}`
const uId = `user-${i}`
push(wfId, uId, createAddBlockEntry(`block-${i}`, { workflowId: wfId, userId: uId }))
}
const { stacks } = useUndoRedoStore.getState()
expect(Object.keys(stacks).length).toBe(5)
})
it('should remove oldest stack when limit exceeded', () => {
const { push } = useUndoRedoStore.getState()
// Create stacks with varying timestamps
for (let i = 0; i < 5; i++) {
push(`wf-${i}`, `user-${i}`, createAddBlockEntry(`block-${i}`))
}
// Add a 6th stack - should remove the oldest
push('wf-new', 'user-new', createAddBlockEntry('block-new'))
const { stacks } = useUndoRedoStore.getState()
expect(Object.keys(stacks).length).toBe(5)
expect(stacks['wf-new:user-new']).toBeDefined()
})
})
describe('undo', () => {
it('should return the last operation and move it to redo', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
const result = undo(workflowId, userId)
expect(result).toEqual(entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 0,
redoSize: 1,
})
})
it('should return null when undo stack is empty', () => {
const { undo } = useUndoRedoStore.getState()
const result = undo(workflowId, userId)
expect(result).toBeNull()
})
it('should undo operations in LIFO order', () => {
const { push, undo } = useUndoRedoStore.getState()
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
const entry3 = createAddBlockEntry('block-3', { workflowId, userId })
push(workflowId, userId, entry1)
push(workflowId, userId, entry2)
push(workflowId, userId, entry3)
expect(undo(workflowId, userId)).toEqual(entry3)
expect(undo(workflowId, userId)).toEqual(entry2)
expect(undo(workflowId, userId)).toEqual(entry1)
})
})
describe('redo', () => {
it('should return the last undone operation and move it back to undo', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
undo(workflowId, userId)
const result = redo(workflowId, userId)
expect(result).toEqual(entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should return null when redo stack is empty', () => {
const { redo } = useUndoRedoStore.getState()
const result = redo(workflowId, userId)
expect(result).toBeNull()
})
it('should redo operations in LIFO order', () => {
const { push, undo, redo } = useUndoRedoStore.getState()
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
push(workflowId, userId, entry1)
push(workflowId, userId, entry2)
undo(workflowId, userId)
undo(workflowId, userId)
expect(redo(workflowId, userId)).toEqual(entry1)
expect(redo(workflowId, userId)).toEqual(entry2)
})
})
describe('clear', () => {
it('should clear both undo and redo stacks', () => {
const { push, undo, clear, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
clear(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 0,
redoSize: 0,
})
})
it('should only clear stacks for specified workflow/user', () => {
const { push, clear, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-2',
'user-2',
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-2' })
)
clear('wf-1', 'user-1')
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
expect(getStackSizes('wf-2', 'user-2').undoSize).toBe(1)
})
})
describe('clearRedo', () => {
it('should only clear the redo stack', () => {
const { push, undo, clearRedo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
clearRedo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
})
describe('getStackSizes', () => {
it('should return zero sizes for non-existent stack', () => {
const { getStackSizes } = useUndoRedoStore.getState()
expect(getStackSizes('non-existent', 'user')).toEqual({
undoSize: 0,
redoSize: 0,
})
})
it('should return correct sizes', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 1,
})
})
})
describe('setCapacity', () => {
it('should update capacity', () => {
const { setCapacity } = useUndoRedoStore.getState()
setCapacity(50)
expect(useUndoRedoStore.getState().capacity).toBe(50)
})
it('should truncate existing stacks to new capacity', () => {
const { push, setCapacity, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 10; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(10)
setCapacity(5)
expect(getStackSizes(workflowId, userId).undoSize).toBe(5)
})
})
describe('move-block coalescing', () => {
it('should coalesce consecutive moves of the same block', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 10, y: 10 },
after: { x: 20, y: 20 },
})
)
// Should coalesce into a single operation
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should not coalesce moves of different blocks', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
push(
workflowId,
userId,
createMoveBlockEntry('block-2', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 20, y: 20 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
})
it('should skip no-op moves', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 100, y: 100 },
after: { x: 100, y: 100 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
it('should preserve original position when coalescing results in no-op', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
// Move block from (0,0) to (10,10)
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
// Move block back to (0,0) - coalesces to a no-op
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 10, y: 10 },
after: { x: 0, y: 0 },
})
)
// Should result in no operations since it's a round-trip
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
})
describe('recording suspension', () => {
it('should skip operations when recording is suspended', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
})
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
it('should resume recording after suspension ends', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
})
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should handle nested suspension correctly', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(async () => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
})
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
})
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
push(workflowId, userId, createAddBlockEntry('block-4', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
})
describe('pruneInvalidEntries', () => {
it('should remove entries for non-existent blocks', () => {
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
// Add entries for blocks
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
// Prune with only block-1 existing
const graph = {
blocksById: {
'block-1': createBlock({ id: 'block-1' }),
},
edgesById: {},
}
pruneInvalidEntries(workflowId, userId, graph)
// Only the entry for block-1 should remain (inverse is add-block which requires block NOT exist)
// Actually, remove-block inverse is add-block, which is applicable when block doesn't exist
// Let me reconsider: the pruneInvalidEntries checks if the INVERSE is applicable
// For remove-block, inverse is add-block, which is applicable when block doesn't exist
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should remove redo entries with non-applicable operations', () => {
const { push, undo, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
// Prune - block-1 doesn't exist, so remove-block is not applicable
pruneInvalidEntries(workflowId, userId, { blocksById: {}, edgesById: {} })
expect(getStackSizes(workflowId, userId).redoSize).toBe(0)
})
})
describe('workflow/user isolation', () => {
it('should keep stacks isolated by workflow and user', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-1',
'user-2',
createAddBlockEntry('block-2', { workflowId: 'wf-1', userId: 'user-2' })
)
push(
'wf-2',
'user-1',
createAddBlockEntry('block-3', { workflowId: 'wf-2', userId: 'user-1' })
)
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(1)
expect(getStackSizes('wf-1', 'user-2').undoSize).toBe(1)
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
})
it('should not affect other stacks when undoing', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-2',
'user-1',
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-1' })
)
undo('wf-1', 'user-1')
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
})
})
describe('edge cases', () => {
it('should handle rapid consecutive operations', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 50; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(50)
})
it('should handle multiple undo/redo cycles', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
for (let i = 0; i < 10; i++) {
undo(workflowId, userId)
redo(workflowId, userId)
}
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should handle mixed operation types', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 100, y: 100 },
})
)
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
undo(workflowId, userId)
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 2,
})
})
})
describe('edge operations', () => {
it('should handle add-edge operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('batch-add-edges')
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
})
it('should handle batch-remove-edges operations', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
const edgeSnapshot = { id: 'edge-1', source: 'block-1', target: 'block-2' }
push(workflowId, userId, createBatchRemoveEdgesEntry([edgeSnapshot], { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('batch-remove-edges')
expect(entry?.inverse.type).toBe('batch-add-edges')
})
})
describe('update-parent operations', () => {
it('should handle update-parent operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createUpdateParentEntry('block-1', {
workflowId,
userId,
oldParentId: undefined,
newParentId: 'loop-1',
oldPosition: { x: 100, y: 100 },
newPosition: { x: 50, y: 50 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('update-parent')
expect(entry?.inverse.type).toBe('update-parent')
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should correctly swap parent IDs in inverse operation', () => {
const { push, undo } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createUpdateParentEntry('block-1', {
workflowId,
userId,
oldParentId: 'loop-1',
newParentId: 'loop-2',
oldPosition: { x: 0, y: 0 },
newPosition: { x: 100, y: 100 },
})
)
const entry = undo(workflowId, userId)
const inverse = entry?.inverse as UpdateParentOperation
expect(inverse.data.oldParentId).toBe('loop-2')
expect(inverse.data.newParentId).toBe('loop-1')
expect(inverse.data.oldPosition).toEqual({ x: 100, y: 100 })
expect(inverse.data.newPosition).toEqual({ x: 0, y: 0 })
})
})
describe('pruneInvalidEntries with edges', () => {
it('should remove entries for non-existent edges', () => {
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
const edge1 = { id: 'edge-1', source: 'a', target: 'b' }
const edge2 = { id: 'edge-2', source: 'c', target: 'd' }
push(workflowId, userId, createBatchRemoveEdgesEntry([edge1], { workflowId, userId }))
push(workflowId, userId, createBatchRemoveEdgesEntry([edge2], { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
const graph = {
blocksById: {},
edgesById: {
'edge-1': { id: 'edge-1', source: 'a', target: 'b' },
},
}
pruneInvalidEntries(workflowId, userId, graph as any)
// edge-1 exists in graph, so we can't undo its removal (can't add it back) → pruned
// edge-2 doesn't exist, so we can undo its removal (can add it back) → kept
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
})
describe('complex scenarios', () => {
it('should handle a complete workflow creation scenario', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('starter', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('agent-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(
workflowId,
userId,
createMoveBlockEntry('agent-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 200, y: 100 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
undo(workflowId, userId)
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 2, redoSize: 2 })
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 3, redoSize: 1 })
push(workflowId, userId, createAddBlockEntry('agent-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 4, redoSize: 0 })
})
it('should handle loop workflow with child blocks', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('loop-1', { workflowId, userId }))
push(
workflowId,
userId,
createUpdateParentEntry('child-1', {
workflowId,
userId,
oldParentId: undefined,
newParentId: 'loop-1',
})
)
push(
workflowId,
userId,
createMoveBlockEntry('child-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 50, y: 50 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
const moveEntry = undo(workflowId, userId)
expect(moveEntry?.operation.type).toBe('batch-move-blocks')
const parentEntry = undo(workflowId, userId)
expect(parentEntry?.operation.type).toBe('update-parent')
})
})
})
+511
View File
@@ -0,0 +1,511 @@
import { createLogger } from '@sim/logger'
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import type {
BatchAddBlocksOperation,
BatchAddEdgesOperation,
BatchMoveBlocksOperation,
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
Operation,
OperationEntry,
UndoRedoState,
} from '@/stores/undo-redo/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('UndoRedoStore')
const DEFAULT_CAPACITY = 100
const MAX_STACKS = 5
let recordingSuspendDepth = 0
function isRecordingSuspended(): boolean {
return recordingSuspendDepth > 0
}
/**
* Temporarily suspends undo/redo recording while the provided callback runs.
*
* @param callback - Function to execute while recording is disabled.
* @returns The callback result.
*/
export async function runWithUndoRedoRecordingSuspended<T>(
callback: () => Promise<T> | T
): Promise<T> {
recordingSuspendDepth += 1
try {
return await Promise.resolve(callback())
} finally {
recordingSuspendDepth = Math.max(0, recordingSuspendDepth - 1)
}
}
function getStackKey(workflowId: string, userId: string): string {
return `${workflowId}:${userId}`
}
/**
* Custom storage adapter for Zustand's persist middleware.
* We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full,
* Without this, the default storage engine would throw and crash the application.
* and to properly handle SSR/Node.js environments.
*/
const safeStorageAdapter = {
getItem: (name: string): string | null => {
if (typeof localStorage === 'undefined') return null
try {
return localStorage.getItem(name)
} catch (e) {
logger.warn('Failed to read from localStorage', e)
return null
}
},
setItem: (name: string, value: string): void => {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(name, value)
} catch (e) {
// Log warning but don't crash - this handles QuotaExceededError
logger.warn('Failed to save to localStorage', e)
}
},
removeItem: (name: string): void => {
if (typeof localStorage === 'undefined') return
try {
localStorage.removeItem(name)
} catch (e) {
logger.warn('Failed to remove from localStorage', e)
}
},
}
function isOperationApplicable(
operation: Operation,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
): boolean {
switch (operation.type) {
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
const op = operation as BatchRemoveBlocksOperation
return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id]))
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
const op = operation as BatchAddBlocksOperation
return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id])
}
case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: {
const op = operation as BatchMoveBlocksOperation
return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId]))
}
case UNDO_REDO_OPERATIONS.UPDATE_PARENT: {
const blockId = operation.data.blockId
return Boolean(graph.blocksById[blockId])
}
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: {
const op = operation as BatchUpdateParentOperation
return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId]))
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
const op = operation as BatchRemoveEdgesOperation
return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id]))
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: {
const op = operation as BatchAddEdgesOperation
return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id])
}
default:
return true
}
}
export const useUndoRedoStore = create<UndoRedoState>()(
persist(
(set, get) => ({
stacks: {},
capacity: DEFAULT_CAPACITY,
push: (workflowId: string, userId: string, entry: OperationEntry) => {
if (isRecordingSuspended()) {
logger.debug('Skipped push while undo/redo recording suspended', {
workflowId,
userId,
operationType: entry.operation.type,
})
return
}
const key = getStackKey(workflowId, userId)
const state = get()
const currentStacks = { ...state.stacks }
// Limit number of stacks
const stackKeys = Object.keys(currentStacks)
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
let oldestKey: string | null = null
let oldestTime = Number.POSITIVE_INFINITY
for (const k of stackKeys) {
const t = currentStacks[k].lastUpdated ?? 0
if (t < oldestTime) {
oldestTime = t
oldestKey = k
}
}
if (oldestKey) {
delete currentStacks[oldestKey]
}
}
const stack = currentStacks[key] || { undo: [], redo: [] }
// Prevent duplicate diff operations (apply-diff, accept-diff, reject-diff)
if (['apply-diff', 'accept-diff', 'reject-diff'].includes(entry.operation.type)) {
const lastEntry = stack.undo[stack.undo.length - 1]
if (lastEntry && lastEntry.operation.type === entry.operation.type) {
// Check if it's a duplicate by comparing the relevant state data
const lastData = lastEntry.operation.data as any
const newData = entry.operation.data as any
// For each diff operation type, check the relevant state
let isDuplicate = false
if (entry.operation.type === 'apply-diff') {
isDuplicate =
JSON.stringify(lastData.baselineSnapshot?.blocks) ===
JSON.stringify(newData.baselineSnapshot?.blocks) &&
JSON.stringify(lastData.proposedState?.blocks) ===
JSON.stringify(newData.proposedState?.blocks)
} else if (entry.operation.type === 'accept-diff') {
isDuplicate =
JSON.stringify(lastData.afterAccept?.blocks) ===
JSON.stringify(newData.afterAccept?.blocks)
} else if (entry.operation.type === 'reject-diff') {
isDuplicate =
JSON.stringify(lastData.afterReject?.blocks) ===
JSON.stringify(newData.afterReject?.blocks)
}
if (isDuplicate) {
logger.debug('Skipping duplicate diff operation', {
type: entry.operation.type,
workflowId,
userId,
})
return
}
}
}
// Coalesce consecutive batch-move-blocks operations for overlapping blocks
if (entry.operation.type === 'batch-move-blocks') {
const incoming = entry.operation as BatchMoveBlocksOperation
const last = stack.undo[stack.undo.length - 1]
// Skip no-op moves (all moves have same before/after)
const allNoOp = incoming.data.moves.every((move) => {
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
return move.before.x === move.after.x && move.before.y === move.after.y && sameParent
})
if (allNoOp) {
logger.debug('Skipped no-op batch move push')
return
}
if (
last &&
last.operation.type === 'batch-move-blocks' &&
last.inverse.type === 'batch-move-blocks'
) {
const prev = last.operation as BatchMoveBlocksOperation
const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId))
const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId))
// Check if same set of blocks
const sameBlocks =
prevBlockIds.size === incomingBlockIds.size &&
[...prevBlockIds].every((id) => incomingBlockIds.has(id))
if (sameBlocks) {
// Merge: keep earliest before, latest after for each block
const mergedMoves = incoming.data.moves.map((incomingMove) => {
const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)!
return {
blockId: incomingMove.blockId,
before: prevMove.before,
after: incomingMove.after,
}
})
// Check if all moves result in same position (net no-op)
const allSameAfter = mergedMoves.every((move) => {
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
return (
move.before.x === move.after.x && move.before.y === move.after.y && sameParent
)
})
const newUndoCoalesced: OperationEntry[] = allSameAfter
? stack.undo.slice(0, -1)
: (() => {
const op = entry.operation as BatchMoveBlocksOperation
const inv = entry.inverse as BatchMoveBlocksOperation
const newEntry: OperationEntry = {
id: entry.id,
createdAt: entry.createdAt,
operation: {
id: op.id,
type: 'batch-move-blocks',
timestamp: op.timestamp,
workflowId,
userId,
data: { moves: mergedMoves },
},
inverse: {
id: inv.id,
type: 'batch-move-blocks',
timestamp: inv.timestamp,
workflowId,
userId,
data: {
moves: mergedMoves.map((m) => ({
blockId: m.blockId,
before: m.after,
after: m.before,
})),
},
},
}
return [...stack.undo.slice(0, -1), newEntry]
})()
currentStacks[key] = {
undo: newUndoCoalesced,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
logger.debug('Coalesced consecutive batch move operations', {
workflowId,
userId,
blockCount: mergedMoves.length,
undoSize: newUndoCoalesced.length,
})
return
}
}
}
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
currentStacks[key] = {
undo: newUndo,
redo: [],
lastUpdated: Date.now(),
}
set({ stacks: currentStacks })
logger.debug('Pushed operation to undo stack', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
})
},
undo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.undo.length === 0) {
return null
}
const entry = stack.undo[stack.undo.length - 1]
const newUndo = stack.undo.slice(0, -1)
const newRedo = [...stack.redo, entry]
if (newRedo.length > state.capacity) {
newRedo.shift()
}
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
logger.debug('Undo operation', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
redoSize: newRedo.length,
})
return entry
},
redo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.redo.length === 0) {
return null
}
const entry = stack.redo[stack.redo.length - 1]
const newRedo = stack.redo.slice(0, -1)
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
set({
stacks: {
...state.stacks,
[key]: {
undo: newUndo,
redo: newRedo,
lastUpdated: Date.now(),
},
},
})
logger.debug('Redo operation', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
redoSize: newRedo.length,
})
return entry
},
clear: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const { [key]: _, ...rest } = state.stacks
set({ stacks: rest })
logger.debug('Cleared undo/redo stacks', { workflowId, userId })
},
clearRedo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) return
set({
stacks: {
...state.stacks,
[key]: { ...stack, redo: [] },
},
})
logger.debug('Cleared redo stack', { workflowId, userId })
},
getStackSizes: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) {
return { undoSize: 0, redoSize: 0 }
}
return {
undoSize: stack.undo.length,
redoSize: stack.redo.length,
}
},
setCapacity: (capacity: number) => {
const state = get()
const newStacks: typeof state.stacks = {}
for (const [key, stack] of Object.entries(state.stacks)) {
newStacks[key] = {
undo: stack.undo.slice(-capacity),
redo: stack.redo.slice(-capacity),
lastUpdated: stack.lastUpdated,
}
}
set({ capacity, stacks: newStacks })
logger.debug('Set capacity', { capacity })
},
pruneInvalidEntries: (
workflowId: string,
userId: string,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) return
const originalUndoCount = stack.undo.length
const originalRedoCount = stack.redo.length
const validUndo = stack.undo.filter((entry) => isOperationApplicable(entry.inverse, graph))
const validRedo = stack.redo.filter((entry) =>
isOperationApplicable(entry.operation, graph)
)
const prunedUndoCount = originalUndoCount - validUndo.length
const prunedRedoCount = originalRedoCount - validRedo.length
if (prunedUndoCount > 0 || prunedRedoCount > 0) {
set({
stacks: {
...state.stacks,
[key]: { ...stack, undo: validUndo, redo: validRedo },
},
})
logger.debug('Pruned invalid entries', {
workflowId,
userId,
prunedUndo: prunedUndoCount,
prunedRedo: prunedRedoCount,
remainingUndo: validUndo.length,
remainingRedo: validRedo.length,
})
}
},
}),
{
name: 'workflow-undo-redo',
storage: createJSONStorage(() => safeStorageAdapter),
partialize: (state) => ({
stacks: state.stacks,
capacity: state.capacity,
}),
}
)
)
+201
View File
@@ -0,0 +1,201 @@
import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@sim/realtime-protocol/constants'
import type { Edge } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
export type OperationType = UndoRedoOperation
interface BaseOperation {
id: string
type: OperationType
timestamp: number
workflowId: string
userId: string
}
export interface BatchAddBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS
data: {
blockSnapshots: BlockState[]
edgeSnapshots: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
}
}
export interface BatchRemoveBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS
data: {
blockSnapshots: BlockState[]
edgeSnapshots: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
}
}
export interface BatchAddEdgesOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES
data: {
edgeSnapshots: Edge[]
}
}
export interface BatchRemoveEdgesOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES
data: {
edgeSnapshots: Edge[]
}
}
export interface BatchMoveBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS
data: {
moves: Array<{
blockId: string
before: { x: number; y: number; parentId?: string }
after: { x: number; y: number; parentId?: string }
}>
}
}
export interface UpdateParentOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.UPDATE_PARENT
data: {
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
affectedEdges?: Edge[]
}
}
export interface BatchUpdateParentOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT
data: {
updates: Array<{
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
affectedEdges?: Edge[]
}>
}
}
export interface BatchToggleEnabledOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface BatchToggleHandlesOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface BatchToggleLockedOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface BatchUpdateSubblocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS
data: {
updates: Array<{
blockId: string
subBlockId: string
before: unknown
after: unknown
}>
subflowUpdates?: Array<{
blockId: string
blockType: 'loop' | 'parallel'
fieldId: string
before: unknown
after: unknown
}>
}
}
interface ApplyDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
data: {
baselineSnapshot: any // WorkflowState snapshot before diff
proposedState: any // WorkflowState with diff applied
diffAnalysis: any // DiffAnalysis for re-applying markers
}
}
interface AcceptDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.ACCEPT_DIFF
data: {
beforeAccept: any // WorkflowState with diff markers
afterAccept: any // WorkflowState without diff markers
diffAnalysis: any // DiffAnalysis to restore markers on undo
baselineSnapshot: any // Baseline workflow state
}
}
interface RejectDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.REJECT_DIFF
data: {
beforeReject: any // WorkflowState with diff markers
afterReject: any // WorkflowState baseline (after reject)
diffAnalysis: any // DiffAnalysis to restore markers on undo
baselineSnapshot: any // Baseline workflow state
}
}
export type Operation =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
| BatchAddEdgesOperation
| BatchRemoveEdgesOperation
| BatchMoveBlocksOperation
| UpdateParentOperation
| BatchUpdateParentOperation
| BatchToggleEnabledOperation
| BatchToggleHandlesOperation
| BatchToggleLockedOperation
| BatchUpdateSubblocksOperation
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation
export interface OperationEntry {
id: string
operation: Operation
inverse: Operation
createdAt: number
}
export interface UndoRedoState {
stacks: Record<
string,
{
undo: OperationEntry[]
redo: OperationEntry[]
lastUpdated?: number
}
>
capacity: number
push: (workflowId: string, userId: string, entry: OperationEntry) => void
undo: (workflowId: string, userId: string) => OperationEntry | null
redo: (workflowId: string, userId: string) => OperationEntry | null
clear: (workflowId: string, userId: string) => void
clearRedo: (workflowId: string, userId: string) => void
getStackSizes: (workflowId: string, userId: string) => { undoSize: number; redoSize: number }
setCapacity: (capacity: number) => void
pruneInvalidEntries: (
workflowId: string,
userId: string,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
) => void
}
+449
View File
@@ -0,0 +1,449 @@
/**
* @vitest-environment node
*/
import type { Edge } from 'reactflow'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn(),
}))
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { captureLatestEdges, captureLatestSubBlockValues, createInverseOperation } from './utils'
const mockMergeSubblockState = mergeSubblockState as Mock
describe('captureLatestEdges', () => {
const createEdge = (id: string, source: string, target: string): Edge => ({
id,
source,
target,
})
it('should return edges where blockId is the source', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-1'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges where blockId is the target', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges for multiple blocks', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
createEdge('edge-3', 'block-2', 'block-5'),
]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(2)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-3', 'block-2', 'block-5'))
})
it('should return empty array when no edges match', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-99'])
expect(result).toEqual([])
})
it('should return empty array when blockIds is empty', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, [])
expect(result).toEqual([])
})
it('should return edge when block has both source and target edges', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-2', 'block-3'),
createEdge('edge-3', 'block-4', 'block-2'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toHaveLength(3)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-2', 'block-2', 'block-3'))
expect(result).toContainEqual(createEdge('edge-3', 'block-4', 'block-2'))
})
it('should handle empty edges array', () => {
const result = captureLatestEdges([], ['block-1'])
expect(result).toEqual([])
})
it('should not duplicate edges when block appears in multiple blockIds', () => {
const edges = [createEdge('edge-1', 'block-1', 'block-2')]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(1)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
})
})
describe('captureLatestSubBlockValues', () => {
const workflowId = 'wf-test'
const createBlockState = (
id: string,
subBlocks: Record<string, { id: string; type: string; value: unknown }>
): BlockState =>
({
id,
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: Object.fromEntries(
Object.entries(subBlocks).map(([subId, sb]) => [
subId,
{ id: sb.id, type: sb.type, value: sb.value },
])
),
outputs: {},
enabled: true,
}) as BlockState
beforeEach(() => {
vi.clearAllMocks()
})
it('should capture single block with single subblock value', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'console.log("hello")' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'console.log("hello")' },
})
})
it('should capture single block with multiple subblock values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test code' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4' },
temperature: { id: 'temperature', type: 'slider', value: 0.7 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
code: 'test code',
model: 'gpt-4',
temperature: 0.7,
},
})
})
it('should capture multiple blocks with values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
prompt: { id: 'prompt', type: 'long-input', value: 'hello world' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-2') return { 'block-2': blocks['block-2'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-2'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-2': { prompt: 'hello world' },
})
})
it('should skip null values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: null },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
expect(result['block-1']).not.toHaveProperty('empty')
})
it('should skip undefined values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: undefined },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
})
it('should return empty object for block with no subBlocks', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should return empty object for non-existent blockId', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
mockMergeSubblockState.mockReturnValue({})
const result = captureLatestSubBlockValues(blocks, workflowId, ['non-existent'])
expect(result).toEqual({})
})
it('should return empty object when blockIds is empty', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
const result = captureLatestSubBlockValues(blocks, workflowId, [])
expect(result).toEqual({})
expect(mockMergeSubblockState).not.toHaveBeenCalled()
})
it('should handle various value types (string, number, array)', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
text: { id: 'text', type: 'short-input', value: 'string value' },
number: { id: 'number', type: 'slider', value: 42 },
array: {
id: 'array',
type: 'table',
value: [
['a', 'b'],
['c', 'd'],
],
},
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
text: 'string value',
number: 42,
array: [
['a', 'b'],
['c', 'd'],
],
},
})
})
it('should only capture values for blockIds in the list', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
code: { id: 'code', type: 'code', value: 'code 2' },
}),
'block-3': createBlockState('block-3', {
code: { id: 'code', type: 'code', value: 'code 3' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-3') return { 'block-3': blocks['block-3'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-3'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-3': { code: 'code 3' },
})
expect(result).not.toHaveProperty('block-2')
})
it('should handle block without subBlocks property', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should handle empty string values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: '' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: '' },
})
})
it('should handle zero numeric values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
temperature: { id: 'temperature', type: 'slider', value: 0 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { temperature: 0 },
})
})
})
describe('createInverseOperation', () => {
it('inverts batch subblock updates', () => {
const operation = {
id: 'op-1',
type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS,
timestamp: 1,
workflowId: 'workflow-1',
userId: 'user-1',
data: {
updates: [
{
blockId: 'block-1',
subBlockId: 'prompt',
before: 'old',
after: 'new',
},
],
subflowUpdates: [
{
blockId: 'loop-1',
blockType: 'loop',
fieldId: 'subflowIterations',
before: 2,
after: 3,
},
],
},
}
expect(createInverseOperation(operation)).toEqual({
...operation,
data: {
updates: [
{
blockId: 'block-1',
subBlockId: 'prompt',
before: 'new',
after: 'old',
},
],
subflowUpdates: [
{
blockId: 'loop-1',
blockType: 'loop',
fieldId: 'subflowIterations',
before: 3,
after: 2,
},
],
},
})
})
})
+236
View File
@@ -0,0 +1,236 @@
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
import { generateId } from '@sim/utils/id'
import type { Edge } from 'reactflow'
import type {
BatchAddBlocksOperation,
BatchAddEdgesOperation,
BatchMoveBlocksOperation,
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
BatchUpdateSubblocksOperation,
Operation,
OperationEntry,
} from '@/stores/undo-redo/types'
import { mergeSubblockState } from '@/stores/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
return {
id: generateId(),
operation,
inverse,
createdAt: Date.now(),
}
}
export function createInverseOperation(operation: Operation): Operation {
switch (operation.type) {
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
const op = operation as BatchAddBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS,
data: {
blockSnapshots: op.data.blockSnapshots,
edgeSnapshots: op.data.edgeSnapshots,
subBlockValues: op.data.subBlockValues,
},
} as BatchRemoveBlocksOperation
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
const op = operation as BatchRemoveBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS,
data: {
blockSnapshots: op.data.blockSnapshots,
edgeSnapshots: op.data.edgeSnapshots,
subBlockValues: op.data.subBlockValues,
},
} as BatchAddBlocksOperation
}
case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: {
const op = operation as BatchAddEdgesOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES,
data: {
edgeSnapshots: op.data.edgeSnapshots,
},
} as BatchRemoveEdgesOperation
}
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
const op = operation as BatchRemoveEdgesOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES,
data: {
edgeSnapshots: op.data.edgeSnapshots,
},
} as BatchAddEdgesOperation
}
case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: {
const op = operation as BatchMoveBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS,
data: {
moves: op.data.moves.map((m) => ({
blockId: m.blockId,
before: m.after,
after: m.before,
})),
},
} as BatchMoveBlocksOperation
}
case UNDO_REDO_OPERATIONS.UPDATE_PARENT:
return {
...operation,
data: {
blockId: operation.data.blockId,
oldParentId: operation.data.newParentId,
newParentId: operation.data.oldParentId,
oldPosition: operation.data.newPosition,
newPosition: operation.data.oldPosition,
affectedEdges: operation.data.affectedEdges,
},
}
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: {
const op = operation as BatchUpdateParentOperation
return {
...operation,
data: {
updates: op.data.updates.map((u) => ({
blockId: u.blockId,
oldParentId: u.newParentId,
newParentId: u.oldParentId,
oldPosition: u.newPosition,
newPosition: u.oldPosition,
affectedEdges: u.affectedEdges,
})),
},
} as BatchUpdateParentOperation
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF:
return {
...operation,
data: {
baselineSnapshot: operation.data.proposedState,
proposedState: operation.data.baselineSnapshot,
diffAnalysis: operation.data.diffAnalysis,
},
}
case UNDO_REDO_OPERATIONS.ACCEPT_DIFF:
return {
...operation,
data: {
beforeAccept: operation.data.afterAccept,
afterAccept: operation.data.beforeAccept,
diffAnalysis: operation.data.diffAnalysis,
baselineSnapshot: operation.data.baselineSnapshot,
},
}
case UNDO_REDO_OPERATIONS.REJECT_DIFF:
return {
...operation,
data: {
beforeReject: operation.data.afterReject,
afterReject: operation.data.beforeReject,
diffAnalysis: operation.data.diffAnalysis,
baselineSnapshot: operation.data.baselineSnapshot,
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: {
const op = operation as BatchUpdateSubblocksOperation
return {
...operation,
data: {
updates: op.data.updates.map((update) => ({
blockId: update.blockId,
subBlockId: update.subBlockId,
before: update.after,
after: update.before,
})),
subflowUpdates: op.data.subflowUpdates?.map((update) => ({
blockId: update.blockId,
blockType: update.blockType,
fieldId: update.fieldId,
before: update.after,
after: update.before,
})),
},
} as BatchUpdateSubblocksOperation
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
}
}
}
export function captureLatestEdges(edges: Edge[], blockIds: string[]): Edge[] {
return edges.filter((e) => blockIds.includes(e.source) || blockIds.includes(e.target))
}
export function captureLatestSubBlockValues(
blocks: Record<string, BlockState>,
workflowId: string,
blockIds: string[]
): Record<string, Record<string, unknown>> {
const values: Record<string, Record<string, unknown>> = {}
blockIds.forEach((blockId) => {
const merged = mergeSubblockState(blocks, workflowId, blockId)
const block = merged[blockId]
if (block?.subBlocks) {
const blockValues: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
blockValues[subBlockId] = subBlock.value
}
})
if (Object.keys(blockValues).length > 0) {
values[blockId] = blockValues
}
}
})
return values
}
+133
View File
@@ -0,0 +1,133 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { 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' }
)
)
+250
View File
@@ -0,0 +1,250 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
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/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 getErrorMessage(e, 'Invalid format')
}
}
export const useVariablesStore = create<VariablesStore>()(
devtools((set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
addVariable: (variable, providedId?: string) => {
const id = providedId || generateId()
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: generateId(),
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)
},
}))
)
+76
View File
@@ -0,0 +1,76 @@
/**
* 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
/**
* 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[]
}
/**
* 2D position used by the floating variables modal.
*/
export interface VariablesPosition {
x: number
y: number
}
/**
* Dimensions for the floating variables modal.
*/
interface VariablesDimensions {
width: number
height: number
}
/**
* UI-only store interface for the floating variables modal.
* Variable data lives in the variables data store (`@/stores/variables/store`).
*/
export interface VariablesModalStore {
isOpen: boolean
position: VariablesPosition | null
width: number
height: number
setIsOpen: (open: boolean) => void
setPosition: (position: VariablesPosition) => void
setDimensions: (dimensions: VariablesDimensions) => void
resetPosition: () => void
}
+1
View File
@@ -0,0 +1 @@
export { useWorkflowDiffStore } from './store'
+228
View File
@@ -0,0 +1,228 @@
/**
* @vitest-environment node
*
* Tests for the workflow-diff store's status modeling.
*
* Focus: the {@link WorkflowDiffStatus} enum is the single source of truth and
* the legacy `hasActiveDiff` / `isShowingDiff` / `isDiffReady` booleans are
* derived from it, so contradictory combinations are unrepresentable. We assert
* the exhaustive status → boolean mapping and the status transitions driven by
* the tractable actions (`toggleDiffView`, `clearDiff`, `_batchedStateUpdate`).
*
* @remarks
* The store transitively imports the diff engine, serializer, socket
* operations, and the workflow/registry stores, all of which drag in the block
* registry and emcn icon CSS. Every such dependency is mocked so the suite
* loads under the node environment and exercises only the store + its types.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { applyWorkflowStateToStores } = vi.hoisted(() => ({
applyWorkflowStateToStores: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('@/lib/workflows/diff', () => ({
WorkflowDiffEngine: class {
clearDiff = vi.fn()
createDiffFromWorkflowState = vi.fn()
},
stripWorkflowDiffMarkers: vi.fn((s) => s),
}))
vi.mock('@/lib/workflows/operations/socket-operations', () => ({
enqueueReplaceWorkflowState: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/workflows/sanitization/validation', () => ({
validateWorkflowState: vi.fn(() => ({ valid: true, errors: [], sanitizedState: null })),
}))
vi.mock('@/serializer', () => ({
Serializer: class {
serializeWorkflow = vi.fn()
deserializeWorkflow = vi.fn()
},
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: { getState: vi.fn(() => ({ activeWorkflowId: null })) },
}))
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn((blocks) => blocks),
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: vi.fn(() => ({
getWorkflowState: vi.fn(() => ({ blocks: {}, edges: [], loops: {}, parallels: {} })),
blocks: {},
lastSaved: 0,
})),
setState: vi.fn(),
},
}))
vi.mock('@/stores/workflow-diff/utils', () => ({
applyWorkflowStateToStores,
captureBaselineSnapshot: vi.fn(),
cloneWorkflowState: vi.fn((s) => s),
createBatchedUpdater:
(set: (u: Record<string, unknown>) => void) => (updates: Record<string, unknown>) =>
set(updates),
getLatestUserMessageId: vi.fn().mockResolvedValue(null),
persistWorkflowStateToServer: vi.fn().mockResolvedValue(true),
WORKFLOW_DIFF_SETTLED_EVENT: 'workflow-diff-settled',
}))
import { RESET_DIFF_STATE, useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import {
deriveDiffFlags,
type WorkflowDiffState,
type WorkflowDiffStatus,
} from '@/stores/workflow-diff/types'
function seedStatus(status: WorkflowDiffStatus): void {
useWorkflowDiffStore.setState(deriveDiffFlags(status))
}
describe('useWorkflowDiffStore status modeling', () => {
beforeEach(() => {
vi.clearAllMocks()
useWorkflowDiffStore.setState({
...RESET_DIFF_STATE,
pendingExternalUpdates: {},
remoteUpdateVersions: {},
reconcilingWorkflows: {},
reconciliationErrors: {},
} as Partial<WorkflowDiffState>)
})
describe('deriveDiffFlags', () => {
it('maps every status to the documented legacy booleans', () => {
expect(deriveDiffFlags('none')).toEqual({
status: 'none',
hasActiveDiff: false,
isShowingDiff: false,
isDiffReady: false,
})
expect(deriveDiffFlags('staged')).toEqual({
status: 'staged',
hasActiveDiff: true,
isShowingDiff: false,
isDiffReady: true,
})
expect(deriveDiffFlags('showing')).toEqual({
status: 'showing',
hasActiveDiff: true,
isShowingDiff: true,
isDiffReady: true,
})
})
it('keeps hasActiveDiff and isDiffReady in lockstep (legacy invariant)', () => {
for (const status of ['none', 'staged', 'showing'] as const) {
const flags = deriveDiffFlags(status)
expect(flags.hasActiveDiff).toBe(flags.isDiffReady)
}
})
})
describe('initial / reset state', () => {
it('starts in the none-derived state', () => {
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('none')
expect(state.hasActiveDiff).toBe(false)
expect(state.isShowingDiff).toBe(false)
expect(state.isDiffReady).toBe(false)
})
it('RESET_DIFF_STATE carries the none-derived flags and clears diff payload', () => {
expect(RESET_DIFF_STATE.status).toBe('none')
expect(RESET_DIFF_STATE.hasActiveDiff).toBe(false)
expect(RESET_DIFF_STATE.isShowingDiff).toBe(false)
expect(RESET_DIFF_STATE.isDiffReady).toBe(false)
expect(RESET_DIFF_STATE.baselineWorkflow).toBeNull()
expect(RESET_DIFF_STATE.diffAnalysis).toBeNull()
})
})
describe('toggleDiffView', () => {
it('is a guarded no-op when there is no active diff', () => {
seedStatus('none')
useWorkflowDiffStore.getState().toggleDiffView()
expect(useWorkflowDiffStore.getState().status).toBe('none')
})
it('toggles showing → staged (hides the proposed changes)', () => {
seedStatus('showing')
useWorkflowDiffStore.getState().toggleDiffView()
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('staged')
expect(state.hasActiveDiff).toBe(true)
expect(state.isDiffReady).toBe(true)
expect(state.isShowingDiff).toBe(false)
})
it('toggles staged → showing (reveals the proposed changes)', () => {
seedStatus('staged')
useWorkflowDiffStore.getState().toggleDiffView()
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('showing')
expect(state.isShowingDiff).toBe(true)
})
})
describe('clearDiff', () => {
it('returns the store to the none status', () => {
seedStatus('showing')
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('none')
expect(state.hasActiveDiff).toBe(false)
expect(state.isShowingDiff).toBe(false)
expect(state.isDiffReady).toBe(false)
})
})
describe('_batchedStateUpdate (undo/redo writer)', () => {
it('restores the showing status via deriveDiffFlags', () => {
seedStatus('none')
useWorkflowDiffStore.getState()._batchedStateUpdate({
...deriveDiffFlags('showing'),
baselineWorkflow: null,
baselineWorkflowId: 'wf-1',
})
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('showing')
expect(state.hasActiveDiff).toBe(true)
expect(state.isShowingDiff).toBe(true)
expect(state.isDiffReady).toBe(true)
})
it('the derived booleans always agree with the stored status', () => {
for (const status of ['none', 'staged', 'showing', 'none'] as const) {
seedStatus(status)
const state = useWorkflowDiffStore.getState()
expect({
hasActiveDiff: state.hasActiveDiff,
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
}).toEqual({
hasActiveDiff: deriveDiffFlags(status).hasActiveDiff,
isShowingDiff: deriveDiffFlags(status).isShowingDiff,
isDiffReady: deriveDiffFlags(status).isDiffReady,
})
}
})
})
})
+570
View File
@@ -0,0 +1,570 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { stripWorkflowDiffMarkers, WorkflowDiffEngine } from '@/lib/workflows/diff'
import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { Serializer } from '@/serializer'
import { useWorkflowRegistry } from '../workflows/registry/store'
import { mergeSubblockState } from '../workflows/utils'
import { useWorkflowStore } from '../workflows/workflow/store'
import { deriveDiffFlags, type WorkflowDiffActions, type WorkflowDiffState } from './types'
import {
applyWorkflowStateToStores,
captureBaselineSnapshot,
cloneWorkflowState,
createBatchedUpdater,
getLatestUserMessageId,
persistWorkflowStateToServer,
WORKFLOW_DIFF_SETTLED_EVENT,
} from './utils'
const logger = createLogger('WorkflowDiffStore')
const diffEngine = new WorkflowDiffEngine()
/**
* Canonical state patch that clears the diff overlay back to `none`: the
* none-derived flags plus a wipe of all diff payload fields.
*/
export const RESET_DIFF_STATE = {
...deriveDiffFlags('none'),
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
_triggerMessageId: null,
} as const
/**
* Detects when a diff contains no meaningful changes.
*/
function isEmptyDiffAnalysis(
diffAnalysis?: {
new_blocks?: string[]
edited_blocks?: string[]
deleted_blocks?: string[]
field_diffs?: Record<string, { changed_fields: string[] }>
edge_diff?: { new_edges?: string[]; deleted_edges?: string[] }
} | null
): boolean {
if (!diffAnalysis) return false
const hasBlockChanges =
(diffAnalysis.new_blocks?.length || 0) > 0 ||
(diffAnalysis.edited_blocks?.length || 0) > 0 ||
(diffAnalysis.deleted_blocks?.length || 0) > 0
const hasEdgeChanges =
(diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 ||
(diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0
const hasFieldChanges = Object.values(diffAnalysis.field_diffs || {}).some(
(diff) => (diff?.changed_fields?.length || 0) > 0
)
return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges
}
function notifyDiffSettled(workflowId: string | null | undefined): void {
if (!workflowId || typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(WORKFLOW_DIFF_SETTLED_EVENT, { detail: { workflowId } }))
}
export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActions>()(
devtools(
(set, get) => {
const batchedUpdate = createBatchedUpdater(set)
return {
...deriveDiffFlags('none'),
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
pendingExternalUpdates: {},
remoteUpdateVersions: {},
reconcilingWorkflows: {},
reconciliationErrors: {},
_triggerMessageId: null,
_batchedStateUpdate: batchedUpdate,
setProposedChanges: async (proposedState, diffAnalysis, options) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
logger.error('Cannot apply diff without an active workflow')
throw new Error('No active workflow found')
}
// Capture baseline if needed (synchronous, fast)
let baselineWorkflow = get().baselineWorkflow
let baselineWorkflowId = get().baselineWorkflowId
let capturedBaseline = false
if (
options?.baselineWorkflow &&
(!baselineWorkflow || baselineWorkflowId !== activeWorkflowId)
) {
baselineWorkflow = cloneWorkflowState(options.baselineWorkflow)
baselineWorkflowId = activeWorkflowId
capturedBaseline = true
logger.info('Using provided baseline snapshot for diff workflow', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks || {}).length,
})
} else if (!baselineWorkflow || baselineWorkflowId !== activeWorkflowId) {
baselineWorkflow = captureBaselineSnapshot(activeWorkflowId)
baselineWorkflowId = activeWorkflowId
capturedBaseline = true
logger.info('Captured baseline snapshot for diff workflow', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks || {}).length,
})
}
// Create diff (this is fast, just computes the diff)
const diffResult = await diffEngine.createDiffFromWorkflowState(
proposedState,
diffAnalysis,
baselineWorkflow ?? undefined
)
if (!diffResult.success || !diffResult.diff) {
const errorMessage = diffResult.errors?.join(', ') || 'Failed to create diff'
logger.error(errorMessage)
throw new Error(errorMessage)
}
const diffAnalysisResult = diffResult.diff.diffAnalysis || null
if (isEmptyDiffAnalysis(diffAnalysisResult)) {
logger.info('No workflow diff detected; skipping diff view')
diffEngine.clearDiff()
batchedUpdate(RESET_DIFF_STATE)
return
}
const candidateState = diffResult.diff.proposedState
logger.info('[WorkflowDiff] Applying proposed state', {
blockCount: Object.keys(candidateState.blocks || {}).length,
edgeCount: candidateState.edges?.length ?? 0,
hasLoops: !!candidateState.loops,
hasParallels: !!candidateState.parallels,
})
// Validate proposed workflow using serializer round-trip
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(
candidateState.blocks,
candidateState.edges,
candidateState.loops,
candidateState.parallels,
false
)
serializer.deserializeWorkflow(serialized)
// Set diff status BEFORE applying state so that the very first
// React render after the store update already sees diff mode active.
// This prevents a flash frame where blocks render without diff colors.
set({
...deriveDiffFlags('showing'),
baselineWorkflow: baselineWorkflow,
baselineWorkflowId,
diffAnalysis: diffAnalysisResult,
diffMetadata: diffResult.diff.metadata,
diffError: null,
_triggerMessageId: get()._triggerMessageId ?? null,
})
// Now apply the proposed state to stores — React will batch this
// with the diff flag update above into a single render.
applyWorkflowStateToStores(activeWorkflowId, candidateState)
logger.info('[WorkflowDiff] Applied state to stores with diff flags already active')
// Resolve trigger message ID in the background (non-blocking)
if (capturedBaseline && !get()._triggerMessageId) {
getLatestUserMessageId().then((msgId) => {
if (msgId) set({ _triggerMessageId: msgId })
})
}
logger.info('Workflow diff applied optimistically', {
workflowId: activeWorkflowId,
blocks: Object.keys(candidateState.blocks || {}).length,
edges: candidateState.edges?.length || 0,
})
// When skipPersist is set, the server tool (edit_workflow) already
// saved to DB. Both the Socket.IO broadcast and HTTP persist would
// race with subsequent edit_workflow calls and overwrite newer state,
// causing block IDs to thrash.
if (!options?.skipPersist) {
const cleanState = stripWorkflowDiffMarkers(cloneWorkflowState(candidateState))
enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: cleanState,
}).catch((error) => {
logger.warn('Failed to broadcast workflow state (non-blocking)', { error })
})
persistWorkflowStateToServer(activeWorkflowId, candidateState)
.then((persisted) => {
if (!persisted) {
logger.warn('Failed to persist copilot edits (state already applied locally)')
} else {
logger.info('Workflow diff persisted to database', {
workflowId: activeWorkflowId,
})
}
})
.catch((error) => {
logger.warn('Failed to persist workflow state (non-blocking)', { error })
})
}
// Emit event for undo/redo recording
if (!options?.skipRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
detail: {
type: 'apply-diff',
baselineSnapshot: baselineWorkflow,
proposedState: candidateState,
diffAnalysis: diffResult.diff.diffAnalysis,
},
})
)
}
},
clearDiff: ({ restoreBaseline = true } = {}) => {
const { baselineWorkflow, baselineWorkflowId } = get()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (
restoreBaseline &&
baselineWorkflow &&
baselineWorkflowId &&
baselineWorkflowId === activeWorkflowId
) {
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
}
diffEngine.clearDiff()
set(RESET_DIFF_STATE)
notifyDiffSettled(baselineWorkflowId ?? activeWorkflowId)
},
toggleDiffView: () => {
const { status } = get()
if (status === 'none') {
logger.warn('Cannot toggle diff view without an active, ready diff')
return
}
batchedUpdate(deriveDiffFlags(status === 'showing' ? 'staged' : 'showing'))
},
acceptChanges: async (options) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
logger.error('No active workflow ID found when accepting diff')
throw new Error('No active workflow found')
}
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(
currentState.blocks,
activeWorkflowId ?? undefined
)
const mergedState = {
...currentState,
blocks: mergedBlocks,
}
const cleanState = stripWorkflowDiffMarkers(cloneWorkflowState(mergedState))
const validation = validateWorkflowState(cleanState, { sanitize: true })
if (!validation.valid) {
const errorMessage = `Cannot apply changes: ${validation.errors.join('; ')}`
logger.error(errorMessage)
batchedUpdate({ diffError: errorMessage })
throw new Error(errorMessage)
}
const stateToApply = {
...(validation.sanitizedState || cleanState),
lastSaved: useWorkflowStore.getState().lastSaved,
}
// Capture state before accept for undo
const beforeAccept = cloneWorkflowState(mergedState)
const afterAccept = cloneWorkflowState(stateToApply)
const diffAnalysisForUndo = get().diffAnalysis
const baselineForUndo = get().baselineWorkflow
// Clear diff state FIRST to prevent flash of colors
// This must happen synchronously before applying the cleaned state
set(RESET_DIFF_STATE)
// Clear the diff engine
diffEngine.clearDiff()
// Now apply the cleaned state
applyWorkflowStateToStores(activeWorkflowId, stateToApply)
// Emit event for undo/redo recording (unless we're in an undo/redo operation)
if (!options?.skipRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
detail: {
type: 'accept-diff',
beforeAccept,
afterAccept,
diffAnalysis: diffAnalysisForUndo,
baselineSnapshot: baselineForUndo,
},
})
)
}
notifyDiffSettled(activeWorkflowId)
},
rejectChanges: async (options) => {
const { baselineWorkflow, baselineWorkflowId, diffAnalysis } = get()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!baselineWorkflow || !baselineWorkflowId) {
logger.warn('Reject called without baseline workflow')
get().clearDiff({ restoreBaseline: false })
return
}
if (!activeWorkflowId || activeWorkflowId !== baselineWorkflowId) {
logger.warn('Reject called while viewing a different workflow', {
activeWorkflowId,
baselineWorkflowId,
})
get().clearDiff({ restoreBaseline: false })
return
}
// Capture current state (with markers) before rejecting
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(
currentState.blocks,
activeWorkflowId ?? undefined
)
const beforeReject = cloneWorkflowState({
...currentState,
blocks: mergedBlocks,
})
get().setWorkflowReconciliationInProgress(baselineWorkflowId, true)
// Clear diff state FIRST for instant UI feedback
set(RESET_DIFF_STATE)
// Clear the diff engine
diffEngine.clearDiff()
// Apply baseline state locally
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
// Emit event for undo/redo recording synchronously
if (!options?.skipRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
detail: {
type: 'reject-diff',
beforeReject,
afterReject: baselineWorkflow,
diffAnalysis,
baselineSnapshot: baselineWorkflow,
},
})
)
}
logger.info('Broadcasting reject to other users', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks).length,
})
enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: baselineWorkflow,
}).catch((error) => {
logger.error('Failed to broadcast reject to other users:', error)
})
const pendingGenerationBeforePersist =
get().pendingExternalUpdates[baselineWorkflowId] ?? 0
const persisted = await persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow)
if (!persisted) {
logger.error('Failed to persist baseline workflow state')
if (
(get().pendingExternalUpdates[baselineWorkflowId] ?? 0) <=
pendingGenerationBeforePersist
) {
get().clearExternalUpdatePending(baselineWorkflowId)
}
get().setWorkflowReconciliationInProgress(baselineWorkflowId, false)
get().setWorkflowReconciliationError(
baselineWorkflowId,
'Failed to save rejected copilot changes. Refresh and try again.'
)
if (
(get().pendingExternalUpdates[baselineWorkflowId] ?? 0) >
pendingGenerationBeforePersist
) {
notifyDiffSettled(baselineWorkflowId)
}
return
}
if (
(get().pendingExternalUpdates[baselineWorkflowId] ?? 0) <=
pendingGenerationBeforePersist
) {
get().clearExternalUpdatePending(baselineWorkflowId)
}
get().setWorkflowReconciliationError(baselineWorkflowId, null)
get().setWorkflowReconciliationInProgress(baselineWorkflowId, false)
notifyDiffSettled(baselineWorkflowId)
},
reapplyDiffMarkers: () => {
const { hasActiveDiff, isDiffReady, diffAnalysis } = get()
if (!hasActiveDiff || !isDiffReady || !diffAnalysis) {
return
}
const workflowStore = useWorkflowStore.getState()
const currentBlocks = workflowStore.blocks
// Check if any blocks need markers applied (checking the actual property, not just existence)
const needsUpdate =
diffAnalysis.new_blocks?.some((blockId) => {
const block = currentBlocks[blockId]
const blockDiffState = (block as { is_diff?: string } | undefined)?.is_diff
return block && blockDiffState !== 'new'
}) ||
diffAnalysis.edited_blocks?.some((blockId) => {
const block = currentBlocks[blockId]
const blockDiffState = (block as { is_diff?: string } | undefined)?.is_diff
return block && blockDiffState !== 'edited'
})
if (!needsUpdate) {
return
}
const updatedBlocks: Record<string, any> = {}
let hasChanges = false
// Only clone blocks that need diff markers
Object.entries(currentBlocks).forEach(([blockId, block]) => {
const isNewBlock = diffAnalysis.new_blocks?.includes(blockId)
const isEditedBlock = diffAnalysis.edited_blocks?.includes(blockId)
const blockDiffState = (block as { is_diff?: string } | undefined)?.is_diff
if (isNewBlock && blockDiffState !== 'new') {
updatedBlocks[blockId] = { ...block, is_diff: 'new' }
hasChanges = true
} else if (isEditedBlock && blockDiffState !== 'edited') {
updatedBlocks[blockId] = { ...block, is_diff: 'edited' }
// Re-apply field_diffs if available
if (diffAnalysis.field_diffs?.[blockId]) {
updatedBlocks[blockId].field_diffs = diffAnalysis.field_diffs[blockId]
// Clone subblocks and apply markers
const fieldDiff = diffAnalysis.field_diffs[blockId]
updatedBlocks[blockId].subBlocks = { ...block.subBlocks }
fieldDiff.changed_fields.forEach((field) => {
if (updatedBlocks[blockId].subBlocks?.[field]) {
updatedBlocks[blockId].subBlocks[field] = {
...updatedBlocks[blockId].subBlocks[field],
is_diff: 'changed',
}
}
})
}
hasChanges = true
} else {
updatedBlocks[blockId] = block
}
})
// Only update if we actually made changes
if (hasChanges) {
useWorkflowStore.setState({ blocks: updatedBlocks })
logger.info('Re-applied diff markers to workflow blocks')
}
},
markRemoteUpdateSeen: (workflowId) => {
set((state) => ({
remoteUpdateVersions: {
...state.remoteUpdateVersions,
[workflowId]: (state.remoteUpdateVersions[workflowId] ?? 0) + 1,
},
reconciliationErrors: Object.fromEntries(
Object.entries(state.reconciliationErrors).filter(([id]) => id !== workflowId)
),
}))
},
markExternalUpdatePending: (workflowId) => {
const current = get()
set({
pendingExternalUpdates: {
...current.pendingExternalUpdates,
[workflowId]: (current.pendingExternalUpdates[workflowId] ?? 0) + 1,
},
remoteUpdateVersions: {
...current.remoteUpdateVersions,
[workflowId]: (current.remoteUpdateVersions[workflowId] ?? 0) + 1,
},
reconciliationErrors: Object.fromEntries(
Object.entries(current.reconciliationErrors).filter(([id]) => id !== workflowId)
),
})
},
clearExternalUpdatePending: (workflowId) => {
set((state) => {
const { [workflowId]: _removed, ...pendingExternalUpdates } =
state.pendingExternalUpdates
return { pendingExternalUpdates }
})
},
setWorkflowReconciliationInProgress: (workflowId, isReconciling) => {
set((state) => {
const { [workflowId]: _removed, ...reconcilingWorkflows } = state.reconcilingWorkflows
return {
reconcilingWorkflows: isReconciling
? { ...reconcilingWorkflows, [workflowId]: true }
: reconcilingWorkflows,
}
})
},
setWorkflowReconciliationError: (workflowId, error) => {
set((state) => {
const { [workflowId]: _removed, ...reconciliationErrors } = state.reconciliationErrors
return {
reconciliationErrors: error
? { ...reconciliationErrors, [workflowId]: error }
: reconciliationErrors,
}
})
},
}
},
{ name: 'workflow-diff-store' }
)
)
+100
View File
@@ -0,0 +1,100 @@
import type { DiffAnalysis, WorkflowDiff } from '@/lib/workflows/diff'
import type { WorkflowState } from '../workflows/workflow/types'
/**
* The lifecycle stage of the workflow diff overlay.
*
* @remarks
* This is the single source of truth for the diff overlay. The legacy
* `hasActiveDiff` / `isShowingDiff` / `isDiffReady` booleans are derived from
* it via {@link deriveDiffFlags}, which makes contradictory combinations —
* such as "showing a diff that has no active diff" — unrepresentable.
*
* - `none` — no diff staged; the canvas shows the live workflow.
* - `staged` — a diff is staged and ready, but the canvas is showing the
* baseline (proposed changes hidden).
* - `showing` — a diff is staged and ready, and the canvas is showing the
* proposed changes with diff markers.
*/
export type WorkflowDiffStatus = 'none' | 'staged' | 'showing'
export interface WorkflowDiffState {
/** Lifecycle stage of the diff overlay; the source of truth for diff flags */
status: WorkflowDiffStatus
/** Derived from {@link status}: a diff is staged (`staged` or `showing`) */
hasActiveDiff: boolean
/** Derived from {@link status}: the canvas is rendering the proposed changes */
isShowingDiff: boolean
/** Derived from {@link status}: a staged diff is ready to view/toggle */
isDiffReady: boolean
baselineWorkflow: WorkflowState | null
baselineWorkflowId: string | null
diffAnalysis: DiffAnalysis | null
diffMetadata: WorkflowDiff['metadata'] | null
diffError?: string | null
pendingExternalUpdates: Record<string, number>
remoteUpdateVersions: Record<string, number>
reconcilingWorkflows: Record<string, boolean>
reconciliationErrors: Record<string, string>
_triggerMessageId?: string | null
}
interface DiffActionOptions {
/** Skip recording this operation for undo/redo. Used during undo/redo replay. */
skipRecording?: boolean
/** Skip persisting to DB. Use when the server tool already saved (e.g. edit_workflow). */
skipPersist?: boolean
/**
* Explicit baseline snapshot to diff against.
* Use this when the proposed state is fetched asynchronously and the live
* workflow store may have already been updated to that same state.
*/
baselineWorkflow?: WorkflowState
}
export interface WorkflowDiffActions {
setProposedChanges: (
workflowState: WorkflowState,
diffAnalysis?: DiffAnalysis,
options?: DiffActionOptions
) => Promise<void>
clearDiff: (options?: { restoreBaseline?: boolean }) => void
toggleDiffView: () => void
acceptChanges: (options?: DiffActionOptions) => Promise<void>
rejectChanges: (options?: DiffActionOptions) => Promise<void>
reapplyDiffMarkers: () => void
markRemoteUpdateSeen: (workflowId: string) => void
markExternalUpdatePending: (workflowId: string) => void
clearExternalUpdatePending: (workflowId: string) => void
setWorkflowReconciliationInProgress: (workflowId: string, isReconciling: boolean) => void
setWorkflowReconciliationError: (workflowId: string, error: string | null) => void
_batchedStateUpdate: (updates: Partial<WorkflowDiffState>) => void
}
/**
* The {@link WorkflowDiffStatus} fields shared by `status` and its derived
* booleans. Spread this into a state patch so the source of truth and the
* legacy flags never drift apart.
*/
export type DiffStatusFlags = Pick<
WorkflowDiffState,
'status' | 'hasActiveDiff' | 'isShowingDiff' | 'isDiffReady'
>
/**
* Computes the legacy `hasActiveDiff` / `isShowingDiff` / `isDiffReady`
* booleans (plus the `status` itself) from a {@link WorkflowDiffStatus}.
*
* @remarks
* Keeping the derived booleans on the stored state lets existing consumers
* keep reading `state.hasActiveDiff` etc. unchanged while
* {@link WorkflowDiffStatus} remains the single source of truth.
*/
export function deriveDiffFlags(status: WorkflowDiffStatus): DiffStatusFlags {
return {
status,
hasActiveDiff: status !== 'none',
isShowingDiff: status === 'showing',
isDiffReady: status !== 'none',
}
}
@@ -0,0 +1,77 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { useVariablesStore } from '@/stores/variables/store'
import { applyWorkflowVariablesToStore } from '@/stores/workflow-diff/utils'
describe('applyWorkflowVariablesToStore', () => {
beforeEach(() => {
useVariablesStore.setState({
variables: {},
isLoading: false,
error: null,
isEditing: null,
})
})
it('hydrates variables for the target workflow and preserves other workflows', () => {
useVariablesStore.setState({
variables: {
old: {
id: 'old',
workflowId: 'workflow-a',
name: 'oldValue',
type: 'plain',
value: 'stale',
},
other: {
id: 'other',
workflowId: 'workflow-b',
name: 'otherValue',
type: 'plain',
value: 'kept',
},
},
})
applyWorkflowVariablesToStore('workflow-a', {
next: {
id: 'next',
name: 'nextValue',
type: 'number',
value: 42,
},
})
expect(useVariablesStore.getState().variables).toEqual({
other: {
id: 'other',
workflowId: 'workflow-b',
name: 'otherValue',
type: 'plain',
value: 'kept',
},
next: {
id: 'next',
workflowId: 'workflow-a',
name: 'nextValue',
type: 'number',
value: 42,
},
})
})
it('preserves null variable values from persisted workflow state', () => {
applyWorkflowVariablesToStore('workflow-a', {
next: {
id: 'next',
name: 'nullableValue',
type: 'object',
value: null,
},
})
expect(useVariablesStore.getState().variables.next.value).toBeNull()
})
})
+191
View File
@@ -0,0 +1,191 @@
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
putWorkflowNormalizedStateContract,
type WorkflowStateContractInput,
} from '@/lib/api/contracts/workflows'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '../workflows/registry/store'
import { useSubBlockStore } from '../workflows/subblock/store'
import { mergeSubblockState } from '../workflows/utils'
import { useWorkflowStore } from '../workflows/workflow/store'
import type { WorkflowState } from '../workflows/workflow/types'
import type { WorkflowDiffState } from './types'
const logger = createLogger('WorkflowDiffStore')
export const WORKFLOW_DIFF_SETTLED_EVENT = 'workflow-diff-settled'
export function cloneWorkflowState(state: WorkflowState): WorkflowState {
return {
...state,
blocks: structuredClone(state.blocks || {}),
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
}
}
export function extractSubBlockValues(
workflowState: WorkflowState
): Record<string, Record<string, any>> {
const values: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
values[blockId][subBlockId] = subBlock?.value ?? null
})
})
return values
}
export function applyWorkflowStateToStores(
workflowId: string,
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) {
logger.debug('[applyWorkflowStateToStores] Applying state', {
workflowId,
blockCount: Object.keys(workflowState.blocks || {}).length,
edgeCount: workflowState.edges?.length ?? 0,
edgePreview: workflowState.edges?.slice(0, 3).map((e) => `${e.source} -> ${e.target}`),
})
const workflowStore = useWorkflowStore.getState()
const cloned = cloneWorkflowState(workflowState)
logger.debug('[applyWorkflowStateToStores] Cloned state edges', {
clonedEdgeCount: cloned.edges?.length ?? 0,
})
workflowStore.replaceWorkflowState(cloned, options)
const subBlockValues = extractSubBlockValues(workflowState)
useSubBlockStore.getState().setWorkflowValues(workflowId, subBlockValues)
if (Object.hasOwn(workflowState, 'variables')) {
applyWorkflowVariablesToStore(workflowId, workflowState.variables)
}
// Verify what's in the store after apply
const afterState = workflowStore.getWorkflowState()
logger.info('[applyWorkflowStateToStores] Applied workflow state to stores', {
workflowId,
afterEdgeCount: afterState.edges?.length ?? 0,
})
}
export function applyWorkflowVariablesToStore(
workflowId: string,
variables?: WorkflowState['variables'] | null
) {
const stampedVariables: Record<string, Variable> = {}
Object.entries(variables || {}).forEach(([id, variable]) => {
if (!variable?.name) return
stampedVariables[id] = {
id: variable.id || id,
workflowId,
name: variable.name,
type: variable.type || 'plain',
value: Object.hasOwn(variable, 'value') ? variable.value : '',
}
})
useVariablesStore.setState((state) => ({
variables: {
...Object.fromEntries(
Object.entries(state.variables).filter(([, variable]) => variable.workflowId !== workflowId)
),
...stampedVariables,
},
}))
}
export function captureBaselineSnapshot(workflowId: string): WorkflowState {
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(currentState.blocks, workflowId)
return {
...cloneWorkflowState(currentState),
blocks: structuredClone(mergedBlocks),
}
}
export async function persistWorkflowStateToServer(
workflowId: string,
workflowState: WorkflowState
): Promise<boolean> {
try {
const cleanState = stripWorkflowDiffMarkers(cloneWorkflowState(workflowState))
const { dragStartPosition: _dropDragStart, ...stateToSave } = cleanState
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
// Mirror auto-layout-utils sanitization: schema rejects nullable
// sourceHandle/targetHandle (input type is `string | undefined`), but the
// store's Edge type carries `string | null | undefined`. Drop nulls before
// sending so the contract input parses cleanly.
const sanitizedEdges: ContractEdgeInput[] = (stateToSave.edges || []).map((edge: Edge) => {
const { sourceHandle, targetHandle, ...rest } = edge
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
sanitized.sourceHandle = sourceHandle
}
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
sanitized.targetHandle = targetHandle
}
return sanitized
})
const cleanedWorkflowState: WorkflowStateContractInput = {
...stateToSave,
loops: stateToSave.loops || {},
parallels: stateToSave.parallels || {},
edges: sanitizedEdges,
lastSaved: Date.now(),
}
await requestJson(putWorkflowNormalizedStateContract, {
params: { id: workflowId },
body: cleanedWorkflowState,
})
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId === workflowId) {
useWorkflowStore.setState({ lastSaved: Date.now() })
}
return true
} catch (error) {
if (error instanceof ApiClientError) {
logger.error('Failed to persist workflow state after copilot edit', {
status: error.status,
message: error.message,
})
} else {
logger.error('Failed to persist workflow state after copilot edit', error)
}
return false
}
}
export async function getLatestUserMessageId(): Promise<string | null> {
return null
}
export function createBatchedUpdater(set: (updates: Partial<WorkflowDiffState>) => void) {
let updateTimer: NodeJS.Timeout | null = null
const UPDATE_DEBOUNCE_MS = 16
let pendingUpdates: Partial<WorkflowDiffState> = {}
return (updates: Partial<WorkflowDiffState>) => {
Object.assign(pendingUpdates, updates)
if (updateTimer) {
clearTimeout(updateTimer)
}
updateTimer = setTimeout(() => {
set(pendingUpdates)
pendingUpdates = {}
updateTimer = null
}, UPDATE_DEBOUNCE_MS)
}
}
@@ -0,0 +1,42 @@
'use client'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface WorkflowSearchReplacePosition {
x: number
y: number
}
interface WorkflowSearchReplaceState {
isOpen: boolean
position: WorkflowSearchReplacePosition | null
query: string
replacement: string
activeMatchId: string | null
open: () => void
close: () => void
setPosition: (position: WorkflowSearchReplacePosition) => void
setQuery: (query: string) => void
setReplacement: (replacement: string) => void
setActiveMatchId: (matchId: string | null) => void
}
export const useWorkflowSearchReplaceStore = create<WorkflowSearchReplaceState>()(
devtools(
(set) => ({
isOpen: false,
position: null,
query: '',
replacement: '',
activeMatchId: null,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false, activeMatchId: null }),
setPosition: (position) => set({ position }),
setQuery: (query) => set({ query }),
setReplacement: (replacement) => set({ replacement }),
setActiveMatchId: (activeMatchId) => set({ activeMatchId }),
}),
{ name: 'workflow-search-replace-store' }
)
)
+55
View File
@@ -0,0 +1,55 @@
import { createLogger } from '@sim/logger'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('Workflows')
/**
* Get a workflow with its state merged in by ID
* Note: Since localStorage has been removed, this only works for the active workflow
* @param workflowId ID of the workflow to retrieve
* @param workspaceId Workspace containing the workflow metadata
* @returns The workflow with merged state values or null if not found/not active
*/
export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
const workflows = getWorkflows(workspaceId)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const metadata = workflows.find((w) => w.id === workflowId)
if (!metadata) {
logger.warn(`Workflow ${workflowId} not found`)
return null
}
// Since localStorage persistence has been removed, only return data for active workflow
if (workflowId !== activeWorkflowId) {
logger.warn(`Cannot get state for non-active workflow ${workflowId} - localStorage removed`)
return null
}
// Use the current state from the store (only available for active workflow)
const workflowState: WorkflowState = useWorkflowStore.getState().getWorkflowState()
// Merge the subblock values for this specific workflow
const mergedBlocks = mergeSubblockState(workflowState.blocks, workflowId)
return {
id: workflowId,
name: metadata.name,
description: metadata.description,
workspaceId: metadata.workspaceId,
folderId: metadata.folderId,
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
lastSaved: workflowState.lastSaved,
},
}
}
export type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -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')
})
})
+419
View File
@@ -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
+420
View File
@@ -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()}`
}
+169
View File
@@ -0,0 +1,169 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { populateTriggerFieldsFromConfig } from '@/hooks/use-trigger-config-aggregation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { SubBlockStore, SubBlockValue } from '@/stores/workflows/subblock/types'
import { isTriggerValid } from '@/triggers'
const logger = createLogger('SubBlockStore')
/**
* Stable empty fallback for `state.workflowValues[workflowId]` selectors.
* Using a module-level constant avoids returning a fresh `{}` on every
* selector call, which would defeat Zustand's `Object.is` equality.
*/
export const EMPTY_SUBBLOCK_VALUES: Record<string, Record<string, SubBlockValue>> = {}
/**
* Stable empty fallback for a single block's sub-block values.
*/
export const EMPTY_BLOCK_SUBBLOCK_VALUES: Record<string, SubBlockValue> = {}
/**
* SubBlockState stores values for all subblocks in workflows
*
* Important implementation notes:
* 1. Values are stored per workflow, per block, per subblock
* 2. When workflows are synced to the database, the mergeSubblockState function
* in utils.ts combines the block structure with these values
* 3. If a subblock value exists here but not in the block structure
* (e.g., inputFormat in starter block), the merge function will include it
* in the synchronized state to ensure persistence
*/
export const useSubBlockStore = create<SubBlockStore>()(
devtools((set, get) => ({
workflowValues: {},
setValue: (blockId: string, subBlockId: string, value: any) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
let validatedValue = value
if (Array.isArray(value)) {
const isTableData =
value.length > 0 &&
value.some((item) => item && typeof item === 'object' && 'cells' in item)
if (isTableData) {
logger.debug('Validating table data for subblock', { blockId, subBlockId })
validatedValue = value.map((row: any) => {
if (!row || typeof row !== 'object') {
logger.warn('Fixing malformed table row', { blockId, subBlockId, row })
return {
id: generateId(),
cells: { Key: '', Value: '' },
}
}
if (!row.id) {
row.id = generateId()
}
if (!row.cells || typeof row.cells !== 'object') {
logger.warn('Fixing malformed table row cells', { blockId, subBlockId, row })
row.cells = { Key: '', Value: '' }
}
return row
})
}
}
set((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[blockId]: {
...state.workflowValues[activeWorkflowId]?.[blockId],
[subBlockId]: validatedValue,
},
},
},
}))
},
getValue: (blockId: string, subBlockId: string) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return null
return get().workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
clear: () => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
set((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {},
},
}))
},
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => {
const values: Record<string, Record<string, any>> = {}
Object.entries(blocks).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
values[blockId][subBlockId] = (subBlock as SubBlockConfig).value
})
})
set((state) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: values,
},
}))
Object.entries(blocks).forEach(([blockId, block]) => {
const blockConfig = getBlock(block.type)
if (!blockConfig) return
const isTriggerBlock = blockConfig.category === 'triggers' || block.triggerMode === true
if (!isTriggerBlock) return
let triggerId: string | undefined
if (blockConfig.category === 'triggers') {
triggerId = block.type
} else if (block.triggerMode === true && blockConfig.triggers?.enabled) {
const selectedTriggerIdValue = block.subBlocks?.selectedTriggerId?.value
const triggerIdValue = block.subBlocks?.triggerId?.value
triggerId =
(typeof selectedTriggerIdValue === 'string' && isTriggerValid(selectedTriggerIdValue)
? selectedTriggerIdValue
: undefined) ||
(typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue)
? triggerIdValue
: undefined) ||
blockConfig.triggers?.available?.[0]
}
if (!triggerId || !isTriggerValid(triggerId)) {
return
}
const triggerConfigSubBlock = block.subBlocks?.triggerConfig
if (triggerConfigSubBlock?.value && typeof triggerConfigSubBlock.value === 'object') {
populateTriggerFieldsFromConfig(blockId, triggerConfigSubBlock.value, triggerId)
}
})
},
setWorkflowValues: (workflowId: string, values: Record<string, Record<string, any>>) => {
set((state) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: values,
},
}))
},
}))
)
@@ -0,0 +1,23 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Value type for subblock values.
* Uses unknown to support various value types that subblocks can store,
* including strings, numbers, arrays, objects, and other complex structures.
*/
export type SubBlockValue = unknown
interface SubBlockStoreState {
workflowValues: Record<string, Record<string, Record<string, SubBlockValue>>> // Store values per workflow ID
}
export interface SubBlockStore extends SubBlockStoreState {
setValue: (blockId: string, subBlockId: string, value: SubBlockValue) => void
getValue: (blockId: string, subBlockId: string) => SubBlockValue | undefined
clear: () => void
initializeFromWorkflow: (workflowId: string, blocks: Record<string, BlockState>) => void
setWorkflowValues: (
workflowId: string,
values: Record<string, Record<string, SubBlockValue>>
) => void
}
+797
View File
@@ -0,0 +1,797 @@
import {
createAgentBlock,
createBlock,
createFunctionBlock,
createLoopBlock,
createStarterBlock,
} from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants'
import { getUniqueBlockName, regenerateBlockIds } from './utils'
describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => {
expect(normalizeName('MyVariable')).toBe('myvariable')
expect(normalizeName('UPPERCASE')).toBe('uppercase')
expect(normalizeName('MixedCase')).toBe('mixedcase')
})
it.concurrent('should remove spaces', () => {
expect(normalizeName('my variable')).toBe('myvariable')
expect(normalizeName('my variable')).toBe('myvariable')
expect(normalizeName(' spaced ')).toBe('spaced')
})
it.concurrent('should handle both lowercase and space removal', () => {
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
expect(normalizeName('My Block Name')).toBe('myblockname')
expect(normalizeName('API 1')).toBe('api1')
})
it.concurrent('should handle edge cases', () => {
expect(normalizeName('')).toBe('')
expect(normalizeName(' ')).toBe('')
expect(normalizeName('a')).toBe('a')
expect(normalizeName('already_normalized')).toBe('already_normalized')
})
it.concurrent('should preserve non-space special characters except dots', () => {
expect(normalizeName('my-variable')).toBe('my-variable')
expect(normalizeName('my_variable')).toBe('my_variable')
})
it.concurrent('should strip dots since they conflict with the reference path delimiter', () => {
expect(normalizeName('my.variable')).toBe('myvariable')
expect(normalizeName('Trigger.dev 1')).toBe('triggerdev1')
expect(normalizeName('Hunter.io 2')).toBe('hunterio2')
})
it.concurrent('should handle tabs and newlines as whitespace', () => {
expect(normalizeName('my\tvariable')).toBe('myvariable')
expect(normalizeName('my\nvariable')).toBe('myvariable')
expect(normalizeName('my\r\nvariable')).toBe('myvariable')
})
it.concurrent('should handle unicode characters', () => {
expect(normalizeName('Café')).toBe('café')
expect(normalizeName('日本語')).toBe('日本語')
})
it.concurrent('should normalize block names correctly', () => {
expect(normalizeName('Agent 1')).toBe('agent1')
expect(normalizeName('API Block')).toBe('apiblock')
expect(normalizeName('My Custom Block')).toBe('mycustomblock')
})
it.concurrent('should normalize variable names correctly', () => {
expect(normalizeName('jira1')).toBe('jira1')
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
expect(normalizeName('My Variable')).toBe('myvariable')
})
it.concurrent('should produce consistent results for references', () => {
const originalName = 'JIRA TEAM UUID'
const normalized1 = normalizeName(originalName)
const normalized2 = normalizeName(originalName)
expect(normalized1).toBe(normalized2)
expect(normalized1).toBe('jirateamuuid')
})
it.concurrent('should allow matching block references to variable references', () => {
const name = 'API Block'
const blockRef = `<${normalizeName(name)}.output>`
const varRef = `<variable.${normalizeName(name)}>`
expect(blockRef).toBe('<apiblock.output>')
expect(varRef).toBe('<variable.apiblock>')
})
it.concurrent('should handle real-world naming patterns consistently', () => {
const realWorldNames = [
{ input: 'User ID', expected: 'userid' },
{ input: 'API Key', expected: 'apikey' },
{ input: 'OAuth Token', expected: 'oauthtoken' },
{ input: 'Database URL', expected: 'databaseurl' },
{ input: 'STRIPE SECRET KEY', expected: 'stripesecretkey' },
{ input: 'openai api key', expected: 'openaiapikey' },
{ input: 'Customer Name', expected: 'customername' },
{ input: 'Order Total', expected: 'ordertotal' },
]
for (const { input, expected } of realWorldNames) {
expect(normalizeName(input)).toBe(expected)
}
})
})
describe('getUniqueBlockName', () => {
it('should return "Start" for starter blocks', () => {
expect(getUniqueBlockName('Start', {})).toBe('Start')
expect(getUniqueBlockName('Starter', {})).toBe('Start')
expect(getUniqueBlockName('start', {})).toBe('Start')
})
it('should return name with number 1 when no existing blocks', () => {
expect(getUniqueBlockName('Agent', {})).toBe('Agent 1')
expect(getUniqueBlockName('Function', {})).toBe('Function 1')
expect(getUniqueBlockName('Loop', {})).toBe('Loop 1')
})
it('should increment number when existing blocks have same base name', () => {
const existingBlocks = {
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
})
it('should find highest number and increment', () => {
const existingBlocks = {
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
'block-2': createAgentBlock({ id: 'block-2', name: 'Agent 3' }),
'block-3': createAgentBlock({ id: 'block-3', name: 'Agent 2' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 4')
})
it('should handle base name with existing number suffix', () => {
const existingBlocks = {
'block-1': createFunctionBlock({ id: 'block-1', name: 'Function 1' }),
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 2' }),
}
expect(getUniqueBlockName('Function 1', existingBlocks)).toBe('Function 3')
expect(getUniqueBlockName('Function 5', existingBlocks)).toBe('Function 3')
})
it('should be case insensitive when matching base names', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'API 1' }),
'block-2': createBlock({ id: 'block-2', name: 'api 2' }),
}
expect(getUniqueBlockName('API', existingBlocks)).toBe('API 3')
expect(getUniqueBlockName('api', existingBlocks)).toBe('api 3')
})
it('should handle different block types independently', () => {
const existingBlocks = {
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 1' }),
'block-3': createLoopBlock({ id: 'block-3', name: 'Loop 1' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
expect(getUniqueBlockName('Loop', existingBlocks)).toBe('Loop 2')
expect(getUniqueBlockName('Router', existingBlocks)).toBe('Router 1')
})
it('should handle blocks without numbers as having number 0', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'Custom' }),
}
expect(getUniqueBlockName('Custom', existingBlocks)).toBe('Custom 1')
})
it('should handle multi-word base names', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'API Block 1' }),
'block-2': createBlock({ id: 'block-2', name: 'API Block 2' }),
}
expect(getUniqueBlockName('API Block', existingBlocks)).toBe('API Block 3')
})
it('should handle starter blocks even with existing starters', () => {
const existingBlocks = {
'block-1': createStarterBlock({ id: 'block-1', name: 'Start' }),
}
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
expect(getUniqueBlockName('Starter', existingBlocks)).toBe('Start')
})
it('should handle empty string base name', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: ' 1' }),
}
expect(getUniqueBlockName('', existingBlocks)).toBe(' 1')
})
it('should handle complex real-world scenarios', () => {
const existingBlocks = {
starter: createStarterBlock({ id: 'starter', name: 'Start' }),
agent1: createAgentBlock({ id: 'agent1', name: 'Agent 1' }),
agent2: createAgentBlock({ id: 'agent2', name: 'Agent 2' }),
func1: createFunctionBlock({ id: 'func1', name: 'Function 1' }),
loop1: createLoopBlock({ id: 'loop1', name: 'Loop 1' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 3')
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
expect(getUniqueBlockName('Condition', existingBlocks)).toBe('Condition 1')
})
it('should preserve original base name casing in result', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'MyBlock 1' }),
}
expect(getUniqueBlockName('MyBlock', existingBlocks)).toBe('MyBlock 2')
expect(getUniqueBlockName('MYBLOCK', existingBlocks)).toBe('MYBLOCK 2')
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
})
})
describe('regenerateBlockIds', () => {
const positionOffset = { x: 50, y: 50 }
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBe(loopId)
expect(duplicatedBlock.data?.extent).toBe('parent')
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
})
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
const nonExistentParentId = 'non-existent-loop'
const childId = 'child-1'
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: nonExistentParentId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBeUndefined()
expect(duplicatedBlock.data?.extent).toBeUndefined()
})
it('should remap parentId when copying both parent and child together', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const blocksToCopy = {
[loopId]: createLoopBlock({
id: loopId,
name: 'Loop 1',
position: { x: 200, y: 200 },
}),
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
const newLoop = newBlocks.find((b) => b.type === 'loop')
const newChild = newBlocks.find((b) => b.type === 'agent')
expect(newLoop).toBeDefined()
expect(newChild).toBeDefined()
expect(newChild!.data?.parentId).toBe(newLoop!.id)
expect(newChild!.data?.extent).toBe('parent')
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
expect(newChild!.position).toEqual({ x: 100, y: 50 })
})
it('should apply offset to top-level blocks', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
})
it('should generate unique names for duplicated blocks', () => {
const blockId = 'block-1'
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 2')
})
it('should ignore large viewport offset for blocks inside existing subflows', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const largeViewportOffset = { x: 2000, y: 1500 }
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
largeViewportOffset,
existingBlocks,
getUniqueBlockName
)
const duplicatedBlock = Object.values(result.blocks)[0]
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId)
})
it('should unlock pasted block when source is locked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
// Pasted blocks are always unlocked so users can edit them
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should keep pasted block unlocked when source is unlocked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Unlocked Agent',
position: { x: 100, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should unlock all pasted blocks regardless of source locked state', () => {
const lockedId = 'locked-1'
const unlockedId = 'unlocked-1'
const blocksToCopy = {
[lockedId]: createAgentBlock({
id: lockedId,
name: 'Originally Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
[unlockedId]: createFunctionBlock({
id: unlockedId,
name: 'Originally Unlocked Function',
position: { x: 200, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
for (const block of newBlocks) {
expect(block.locked).toBe(false)
}
})
it('should preserve original name when no conflicting block exists', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 1')
})
it('should preserve original name with number suffix when no conflict', () => {
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 3',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Agent 3')
})
it('should increment name when an exact match exists in destination', () => {
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Agent 2')
})
it('should preserve name when only a different-numbered sibling exists', () => {
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 2' }),
}
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 5',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Agent 5')
})
it('should preserve names for multiple blocks when no conflicts', () => {
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
'block-2': createFunctionBlock({
id: 'block-2',
name: 'Function 3',
position: { x: 200, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
const agentBlock = newBlocks.find((b) => b.type === 'agent')
const functionBlock = newBlocks.find((b) => b.type === 'function')
expect(agentBlock!.name).toBe('Agent 1')
expect(functionBlock!.name).toBe('Function 3')
})
it('should handle mixed conflicts: preserve non-conflicting, increment conflicting', () => {
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
'block-2': createFunctionBlock({
id: 'block-2',
name: 'Function 1',
position: { x: 200, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
const agentBlock = newBlocks.find((b) => b.type === 'agent')
const functionBlock = newBlocks.find((b) => b.type === 'function')
expect(agentBlock!.name).toBe('Agent 2')
expect(functionBlock!.name).toBe('Function 1')
})
it('should detect conflicts case-insensitively', () => {
const existingBlocks = {
existing: createBlock({ id: 'existing', name: 'api 1' }),
}
const blocksToCopy = {
'block-1': createBlock({
id: 'block-1',
name: 'API 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('API 2')
})
it('should preserve name without number suffix when no conflict', () => {
const blocksToCopy = {
'block-1': createBlock({
id: 'block-1',
name: 'Custom Block',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Custom Block')
})
it('should avoid collisions between pasted blocks themselves', () => {
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
'block-2': createAgentBlock({
id: 'block-2',
name: 'Agent 1',
position: { x: 200, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
const names = newBlocks.map((b) => b.name)
expect(names).toHaveLength(2)
expect(new Set(names).size).toBe(2)
expect(names).toContain('Agent 1')
expect(names).toContain('Agent 2')
})
})
+610
View File
@@ -0,0 +1,610 @@
import { generateId } from '@sim/utils/id'
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type {
BlockState,
Loop,
Parallel,
Position,
SubBlockState,
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
/** Threshold to detect viewport-based offsets vs small duplicate offsets */
const LARGE_OFFSET_THRESHOLD = 300
/**
* Filters edges to only include valid ones (target exists and is not a trigger block)
*/
export function filterValidEdges(edges: Edge[], blocks: Record<string, BlockState>): Edge[] {
return validateEdges(edges, blocks).valid
}
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return filterUniqueWorkflowEdges(edgesToAdd, currentEdges)
}
export interface RegeneratedState {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
idMap: Map<string, string>
}
/**
* Generates a unique block name by finding the highest number suffix among existing blocks
* with the same base name and incrementing it
* @param baseName - The base name for the block (e.g., "API 1", "Agent", "Loop 3")
* @param existingBlocks - Record of existing blocks to check against
* @returns A unique block name with an appropriate number suffix
*/
export function getUniqueBlockName(baseName: string, existingBlocks: Record<string, any>): string {
// Special case: Start blocks should always be named "Start" without numbers
// This applies to both "Start" and "Starter" base names
const normalizedBaseName = normalizeName(baseName)
if (normalizedBaseName === 'start' || normalizedBaseName === 'starter') {
return 'Start'
}
if (normalizedBaseName === 'response') {
return 'Response'
}
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName
const normalizedBase = normalizeName(namePrefix)
const existingNumbers = Object.values(existingBlocks)
.filter((block) => {
const blockNameMatch = block.name?.match(/^(.*?)(\s+\d+)?$/)
const blockPrefix = blockNameMatch ? blockNameMatch[1].trim() : block.name
return blockPrefix && normalizeName(blockPrefix) === normalizedBase
})
.map((block) => {
const match = block.name?.match(/(\d+)$/)
return match ? Number.parseInt(match[1], 10) : 0
})
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0
if (maxNumber === 0 && existingNumbers.length === 0) {
return `${namePrefix} 1`
}
return `${namePrefix} ${maxNumber + 1}`
}
export interface PrepareBlockStateOptions {
id: string
type: string
name: string
position: Position
data?: Record<string, unknown>
parentId?: string
extent?: 'parent'
triggerMode?: boolean
}
/**
* Prepares a BlockState object from block type and configuration.
* Generates subBlocks and outputs from the block registry.
*/
export function prepareBlockState(options: PrepareBlockStateOptions): BlockState {
const { id, type, name, position, data, parentId, extent, triggerMode = false } = options
const blockConfig = getBlock(type)
const blockData: Record<string, unknown> = { ...(data || {}) }
if (parentId) blockData.parentId = parentId
if (extent) blockData.extent = extent
if (!blockConfig) {
return {
id,
type,
name,
position,
data: blockData,
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode,
height: 0,
}
}
const subBlocks: Record<string, SubBlockState> = {}
if (blockConfig.subBlocks) {
blockConfig.subBlocks.forEach((subBlock) => {
let initialValue: unknown = null
if (typeof subBlock.value === 'function') {
try {
initialValue = subBlock.value({})
} catch {
initialValue = null
}
} else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') {
initialValue = [createDefaultInputFormatField()]
} else if (subBlock.type === 'table') {
initialValue = []
}
subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,
value: initialValue as SubBlockState['value'],
}
})
}
const isTriggerCapable = hasTriggerCapability(blockConfig)
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
const outputs = getEffectiveBlockOutputs(type, subBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
if (blockConfig.subBlocks) {
const canonicalModes = buildDefaultCanonicalModes(blockConfig.subBlocks)
if (Object.keys(canonicalModes).length > 0) {
blockData.canonicalModes = canonicalModes
}
}
return {
id,
type,
name,
position,
data: blockData,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode,
height: 0,
locked: false,
}
}
/**
* Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store
* @param workflowId - ID of the workflow to merge values for
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated values
*/
export function mergeSubblockState(
blocks: Record<string, BlockState>,
workflowId?: string,
blockId?: string
): Record<string, BlockState> {
const subBlockStore = useSubBlockStore.getState()
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
if (workflowId) {
return mergeSubblockStateWithValues(blocks, workflowSubblockValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
return acc
}
const blockSubBlocks = block.subBlocks || {}
const blockValues = workflowSubblockValues[id] || {}
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
(subAcc, [subBlockId, subBlock]) => {
if (!subBlock) {
return subAcc
}
let storedValue = null
if (workflowId) {
if (blockValues[subBlockId] !== undefined) {
storedValue = blockValues[subBlockId]
}
} else {
storedValue = subBlockStore.getValue(id, subBlockId)
}
subAcc[subBlockId] = {
...subBlock,
value: (storedValue !== undefined && storedValue !== null
? storedValue
: subBlock.value) as SubBlockState['value'],
}
return subAcc
},
{} as Record<string, SubBlockState>
)
// Add any values that exist in the store but aren't in the block structure
// This handles cases where block config has been updated but values still exist
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
// Create a minimal subblock structure
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input', // Default type that's safe to use
value: value as SubBlockState['value'],
}
}
})
// Return the full block state with updated subBlocks (including orphaned values)
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
}
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
if (typeof value === 'string') {
let updatedValue = value
nameMap.forEach((newName, oldName) => {
const regex = new RegExp(`<${oldName}\\.`, 'g')
updatedValue = updatedValue.replace(regex, `<${newName}.`)
})
return updatedValue
}
if (Array.isArray(value)) {
return value.map((item) => updateValueReferences(item, nameMap))
}
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value)) {
result[key] = updateValueReferences(val, nameMap)
}
return result
}
return value
}
function updateBlockReferences(
blocks: Record<string, BlockState>,
nameMap: Map<string, string>,
clearTriggerRuntimeValues = false
): void {
Object.entries(blocks).forEach(([_, block]) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
block.subBlocks[subBlockId] = { ...subBlock, value: null }
return
}
if (subBlock.value !== undefined && subBlock.value !== null) {
const updatedValue = updateValueReferences(
subBlock.value,
nameMap
) as SubBlockState['value']
block.subBlocks[subBlockId] = { ...subBlock, value: updatedValue }
}
})
}
})
}
export function regenerateWorkflowIds(
workflowState: WorkflowState,
options: { clearTriggerRuntimeValues?: boolean } = {}
): WorkflowState & { idMap: Map<string, string> } {
const { clearTriggerRuntimeValues = true } = options
const blockIdMap = new Map<string, string>()
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
// First pass: generate new IDs and remap condition/router IDs in subBlocks
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
const newId = generateId()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
nameMap.set(oldNormalizedName, oldNormalizedName)
const newBlock = { ...block, id: newId, subBlocks: structuredClone(block.subBlocks) }
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
newBlocks[newId] = newBlock
})
// Second pass: update parentId references
Object.values(newBlocks).forEach((block) => {
if (block.data?.parentId) {
const newParentId = blockIdMap.get(block.data.parentId)
if (newParentId) {
block.data = { ...block.data, parentId: newParentId }
} else {
// Parent not in the workflow, clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
})
const newEdges = workflowState.edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: generateId(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
if (workflowState.loops) {
Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => {
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
const newParallels: Record<string, Parallel> = {}
if (workflowState.parallels) {
Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => {
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
updateBlockReferences(newBlocks, nameMap, clearTriggerRuntimeValues)
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
metadata: workflowState.metadata,
variables: workflowState.variables,
idMap: blockIdMap,
}
}
/**
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
*/
export function remapConditionIds(
subBlocks: Record<string, SubBlockState>,
subBlockValues: Record<string, unknown>,
oldBlockId: string,
newBlockId: string
): void {
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
const value = subBlockValues[subBlockId] ?? subBlock.value
if (typeof value !== 'string') continue
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) continue
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
const newValue = JSON.stringify(parsed)
subBlock.value = newValue
subBlockValues[subBlockId] = newValue
}
} catch {
// Not valid JSON, skip
}
}
}
export function regenerateBlockIds(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>,
parallels: Record<string, Parallel>,
subBlockValues: Record<string, Record<string, unknown>>,
positionOffset: { x: number; y: number },
existingBlockNames: Record<string, BlockState>,
uniqueNameFn: (name: string, blocks: Record<string, BlockState>) => string
): RegeneratedState & { subBlockValues: Record<string, Record<string, unknown>> } {
const blockIdMap = new Map<string, string>()
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
const newSubBlockValues: Record<string, Record<string, unknown>> = {}
// Track all blocks for name uniqueness (existing + newly processed)
const allBlocksForNaming = { ...existingBlockNames }
// First pass: generate new IDs and names for all blocks
Object.entries(blocks).forEach(([oldId, block]) => {
const newId = generateId()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
const nameConflicts = Object.values(allBlocksForNaming).some(
(existing) => normalizeName(existing.name) === oldNormalizedName
)
const newName = nameConflicts ? uniqueNameFn(block.name, allBlocksForNaming) : block.name
const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName)
// Determine position offset based on parent relationship:
// 1. Parent also being copied: keep exact relative position (parent itself will be offset)
// 2. Parent exists in existing workflow: use provided offset, but cap large viewport-based
// offsets since they don't make sense for relative positions
// 3. Top-level block (no parent): apply full paste offset
const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId]
const hasParentInExistingWorkflow =
block.data?.parentId && existingBlockNames[block.data.parentId]
let newPosition: Position
if (hasParentInPasteSet) {
// Parent also being copied - keep exact relative position
newPosition = { x: block.position.x, y: block.position.y }
} else if (hasParentInExistingWorkflow) {
// Block stays in existing subflow - use provided offset unless it's viewport-based (large)
const isLargeOffset =
Math.abs(positionOffset.x) > LARGE_OFFSET_THRESHOLD ||
Math.abs(positionOffset.y) > LARGE_OFFSET_THRESHOLD
const effectiveOffset = isLargeOffset ? DEFAULT_DUPLICATE_OFFSET : positionOffset
newPosition = {
x: block.position.x + effectiveOffset.x,
y: block.position.y + effectiveOffset.y,
}
} else {
// Top-level block - apply full paste offset
newPosition = {
x: block.position.x + positionOffset.x,
y: block.position.y + positionOffset.y,
}
}
// Placeholder block - we'll update parentId in second pass
const newBlock: BlockState = {
...block,
id: newId,
name: newName,
position: newPosition,
subBlocks: structuredClone(block.subBlocks),
// Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
locked: false,
}
newBlocks[newId] = newBlock
// Add to tracking so next block gets unique name
allBlocksForNaming[newId] = newBlock
if (subBlockValues[oldId]) {
newSubBlockValues[newId] = structuredClone(subBlockValues[oldId])
}
// Remap condition/router IDs in the duplicated block
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
})
// Second pass: update parentId references for nested blocks
// If a block's parent is also being pasted, map to new parentId
// If parent exists in existing workflow, keep the original parentId (block stays in same subflow)
// Otherwise clear the parentId
Object.entries(newBlocks).forEach(([, block]) => {
if (block.data?.parentId) {
const oldParentId = block.data.parentId
const newParentId = blockIdMap.get(oldParentId)
if (newParentId) {
// Parent is being pasted - map to new parent ID
block.data = {
...block.data,
parentId: newParentId,
extent: 'parent',
}
} else if (existingBlockNames[oldParentId] && !existingBlockNames[oldParentId].locked) {
// Parent exists in existing workflow and is not locked - keep original parentId
block.data = {
...block.data,
parentId: oldParentId,
extent: 'parent',
}
} else {
// Parent doesn't exist anywhere OR parent is locked - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
})
const newEdges = edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: generateId(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
Object.entries(loops).forEach(([oldLoopId, loop]) => {
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
const newParallels: Record<string, Parallel> = {}
Object.entries(parallels).forEach(([oldParallelId, parallel]) => {
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
updateBlockReferences(newBlocks, nameMap, false)
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
Object.keys(blockValues).forEach((subBlockId) => {
blockValues[subBlockId] = updateValueReferences(blockValues[subBlockId], nameMap)
})
})
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
subBlockValues: newSubBlockValues,
idMap: blockIdMap,
}
}
@@ -0,0 +1,99 @@
import type { Edge } from 'reactflow'
import { describe, expect, it } from 'vitest'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { BlockState } from '@/stores/workflows/workflow/types'
function makeBlock(id: string, type: string, overrides?: Partial<BlockState>): BlockState {
return {
id,
type,
name: id,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
}
}
function makeEdge(id: string, source: string, target: string): Edge {
return { id, source, target, type: 'default' }
}
describe('validateEdges', () => {
it('accepts an edge between two root-scope blocks', () => {
const blocks = {
a: makeBlock('a', 'starter'),
b: makeBlock('b', 'function'),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(1)
expect(result.dropped).toHaveLength(0)
})
it('drops an edge referencing a missing block', () => {
const blocks = { a: makeBlock('a', 'starter') }
const result = validateEdges([makeEdge('e1', 'a', 'missing')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('edge references a missing block')
})
it('drops an edge touching an annotation-only (note) block', () => {
const blocks = {
a: makeBlock('a', 'note'),
b: makeBlock('b', 'function'),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('edge references an annotation-only block')
})
it('drops an edge targeting a trigger block', () => {
const blocks = {
a: makeBlock('a', 'function'),
b: makeBlock('b', 'function', { triggerMode: true }),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('trigger blocks cannot be edge targets')
})
it('drops an edge crossing loop scope boundaries', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
outer: makeBlock('outer', 'function'),
}
const result = validateEdges([makeEdge('e1', 'inner', 'outer')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toContain('different scopes')
})
it('accepts an edge from a loop container into its own child', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'loop', 'inner')], blocks)
expect(result.valid).toHaveLength(1)
})
it('accepts an edge from a loop child back out to its own container', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'inner', 'loop')], blocks)
expect(result.valid).toHaveLength(1)
})
it('accepts edges between two siblings inside the same loop', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
a: makeBlock('a', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
b: makeBlock('b', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(1)
})
})
@@ -0,0 +1,58 @@
import {
getWorkflowEdgeScopeDropReason,
isWorkflowAnnotationOnlyBlockType,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import type { BlockState } from '@/stores/workflows/workflow/types'
interface DroppedEdge {
edge: Edge
reason: string
}
export interface EdgeValidationResult {
valid: Edge[]
dropped: DroppedEdge[]
}
export function validateEdges(
edges: Edge[],
blocks: Record<string, BlockState>
): EdgeValidationResult {
const valid: Edge[] = []
const dropped: DroppedEdge[] = []
for (const edge of edges) {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) {
dropped.push({ edge, reason: 'edge references a missing block' })
continue
}
if (
isWorkflowAnnotationOnlyBlockType(sourceBlock.type) ||
isWorkflowAnnotationOnlyBlockType(targetBlock.type)
) {
dropped.push({ edge, reason: 'edge references an annotation-only block' })
continue
}
if (TriggerUtils.isTriggerBlock(targetBlock)) {
dropped.push({ edge, reason: 'trigger blocks cannot be edge targets' })
continue
}
const scopeDropReason = getWorkflowEdgeScopeDropReason(edge, blocks)
if (scopeDropReason) {
dropped.push({ edge, reason: scopeDropReason })
continue
}
valid.push(edge)
}
return { valid, dropped }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import type {
BlockData,
BlockLayoutState,
BlockState,
DragStartPosition,
Loop,
LoopBlock,
LoopConfig,
Parallel,
ParallelBlock,
ParallelConfig,
Position,
SubBlockState,
Subflow,
SubflowType,
Variable,
WorkflowState,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
export type {
BlockData,
BlockLayoutState,
BlockState,
DragStartPosition,
Loop,
LoopBlock,
LoopConfig,
Parallel,
ParallelBlock,
ParallelConfig,
Position,
SubBlockState,
Subflow,
SubflowType,
Variable,
WorkflowState,
}
export { isValidSubflowType, SUBFLOW_TYPES } from '@sim/workflow-types/workflow'
export interface WorkflowActions {
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
batchUpdateBlocksWithParent: (
updates: Array<{
id: string
position: { x: number; y: number }
parentId?: string
}>
) => void
batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => void
batchAddBlocks: (
blocks: BlockState[],
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
) => void
batchRemoveBlocks: (ids: string[]) => void
batchToggleEnabled: (ids: string[]) => void
batchToggleHandles: (ids: string[]) => void
batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => void
batchRemoveEdges: (ids: string[]) => void
clear: () => Partial<WorkflowState>
updateLastSaved: () => void
setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: (
id: string,
name: string
) => {
success: boolean
changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }>
}
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void
/**
* Wholesale-replaces `block.data.canonicalModes`, rather than merging one key like
* {@link setBlockCanonicalMode}. Needed when reindexing nested tool-input overrides on
* reorder/removal: a merge can't atomically drop a now-stale index key, and sequential
* per-key sets can clobber each other when two tools swap positions.
*/
setBlockCanonicalModes: (id: string, canonicalModes: Record<string, 'basic' | 'advanced'>) => void
syncDynamicHandleSubblockValue: (blockId: string, subblockId: string, value: unknown) => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
setLoopForEachItems: (loopId: string, items: any) => void
setLoopWhileCondition: (loopId: string, condition: string) => void
setLoopDoWhileCondition: (loopId: string, condition: string) => void
updateParallelCount: (parallelId: string, count: number) => void
updateParallelBatchSize: (parallelId: string, batchSize: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null
getWorkflowState: () => WorkflowState
replaceWorkflowState: (
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
setBlockLocked: (id: string, locked: boolean) => void
batchToggleLocked: (ids: string[]) => void
setCurrentWorkflowId: (workflowId: string | null) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions
@@ -0,0 +1,146 @@
import { createAgentBlock, createLoopBlock } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
import {
convertLoopBlockToLoop,
isAncestorProtected,
isBlockProtected,
} from '@/stores/workflows/workflow/utils'
describe('convertLoopBlockToLoop', () => {
it.concurrent('should keep JSON array string as-is for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 10,
data: { collection: '["item1", "item2", "item3"]' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('["item1", "item2", "item3"]')
expect(result?.iterations).toBe(10)
})
it.concurrent('should keep JSON object string as-is for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '{"key1": "value1", "key2": "value2"}' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('{"key1": "value1", "key2": "value2"}')
})
it.concurrent('should keep string as-is if not valid JSON', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '<blockName.items>' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('<blockName.items>')
})
it.concurrent('should handle empty collection', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('')
})
it.concurrent('should handle for loops without collection parsing', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'for',
count: 5,
data: { collection: '["should", "not", "matter"]' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('for')
expect(result?.iterations).toBe(5)
expect(result?.forEachItems).toBe('["should", "not", "matter"]')
})
})
describe('block lock protection', () => {
it.concurrent('treats deeply nested blocks inside locked containers as protected', () => {
const blocks: Record<string, BlockState> = {
grandparent: createLoopBlock({
id: 'grandparent',
name: 'Grandparent Loop',
locked: true,
}),
parent: createLoopBlock({
id: 'parent',
name: 'Parent Loop',
parentId: 'grandparent',
}),
child: createAgentBlock({
id: 'child',
name: 'Child Agent',
parentId: 'parent',
}),
}
expect(isAncestorProtected('child', blocks)).toBe(true)
expect(isBlockProtected('child', blocks)).toBe(true)
})
it.concurrent(
'does not treat ancestor cycles as protected unless a locked ancestor is found',
() => {
const blocks: Record<string, BlockState> = {
first: createAgentBlock({
id: 'first',
name: 'First Agent',
parentId: 'second',
}),
second: createAgentBlock({
id: 'second',
name: 'Second Agent',
parentId: 'first',
}),
}
expect(isAncestorProtected('first', blocks)).toBe(false)
expect(isBlockProtected('first', blocks)).toBe(false)
}
)
})
+216
View File
@@ -0,0 +1,216 @@
import {
filterAcyclicEdges as filterAcyclicWorkflowEdges,
isWorkflowBlockAncestorLocked,
isWorkflowBlockProtected,
wouldCreateCycle as wouldCreateWorkflowEdgeCycle,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_PARALLEL_BATCH_SIZE = 20
const MAX_PARALLEL_BATCH_SIZE = 20
export function clampParallelBatchSize(batchSize: unknown): number {
const parsed = typeof batchSize === 'number' ? batchSize : Number.parseInt(String(batchSize), 10)
if (Number.isNaN(parsed)) {
return DEFAULT_PARALLEL_BATCH_SIZE
}
return Math.max(1, Math.min(MAX_PARALLEL_BATCH_SIZE, parsed))
}
/**
* Check if adding an edge would create a cycle in the graph.
* Delegates to the shared implementation in `@sim/workflow-types` so the
* client store, the collaborative queueing layer, and the realtime
* persistence layer all agree on the same cyclic edges.
*/
export function wouldCreateCycle(edges: Edge[], sourceId: string, targetId: string): boolean {
return wouldCreateWorkflowEdgeCycle(edges, sourceId, targetId)
}
/**
* Filters a batch of candidate edges down to the ones that do not create a
* cycle against `currentEdges`, evaluated incrementally within the batch.
*/
export function filterAcyclicEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return filterAcyclicWorkflowEdges(edgesToAdd, currentEdges)
}
/**
* Convert UI loop block to executor Loop format
*
* @param loopBlockId - ID of the loop block to convert
* @param blocks - Record of all blocks in the workflow
* @returns Loop object for execution engine or undefined if not a valid loop
*/
export function convertLoopBlockToLoop(
loopBlockId: string,
blocks: Record<string, BlockState>
): Loop | undefined {
const loopBlock = blocks[loopBlockId]
if (!loopBlock || loopBlock.type !== 'loop') return undefined
const loopType = loopBlock.data?.loopType || 'for'
const loop: Loop = {
id: loopBlockId,
nodes: findChildNodes(loopBlockId, blocks),
iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType,
enabled: loopBlock.enabled,
}
loop.forEachItems = loopBlock.data?.collection || ''
loop.whileCondition = loopBlock.data?.whileCondition || ''
loop.doWhileCondition = loopBlock.data?.doWhileCondition || ''
return loop
}
/**
* Convert UI parallel block to executor Parallel format
*
* @param parallelBlockId - ID of the parallel block to convert
* @param blocks - Record of all blocks in the workflow
* @returns Parallel object for execution engine or undefined if not a valid parallel block
*/
export function convertParallelBlockToParallel(
parallelBlockId: string,
blocks: Record<string, BlockState>
): Parallel | undefined {
const parallelBlock = blocks[parallelBlockId]
if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined
const parallelType = parallelBlock.data?.parallelType || 'count'
const validParallelTypes = ['collection', 'count'] as const
const validatedParallelType = validParallelTypes.includes(parallelType as any)
? parallelType
: 'collection'
const distribution =
validatedParallelType === 'collection' ? parallelBlock.data?.collection || '' : undefined
const count = parallelBlock.data?.count || 5
const batchSize = clampParallelBatchSize(parallelBlock.data?.batchSize)
return {
id: parallelBlockId,
nodes: findChildNodes(parallelBlockId, blocks),
distribution,
count,
parallelType: validatedParallelType,
batchSize,
enabled: parallelBlock.enabled,
}
}
/**
* Find all nodes that are children of this container (loop or parallel)
*
* @param containerId - ID of the container to find children for
* @param blocks - Record of all blocks in the workflow
* @returns Array of node IDs that are direct children of this container
*/
export function findChildNodes(containerId: string, blocks: Record<string, BlockState>): string[] {
return Object.values(blocks)
.filter((block) => block.data?.parentId === containerId)
.map((block) => block.id)
}
/**
* Find all descendant nodes, including children, grandchildren, etc.
*
* @param containerId - ID of the container to find descendants for
* @param blocks - Record of all blocks in the workflow
* @returns Array of node IDs that are descendants of this container
*/
export function findAllDescendantNodes(
containerId: string,
blocks: Record<string, BlockState>
): string[] {
const descendants: string[] = []
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const block of Object.values(blocks)) {
if (block.data?.parentId === current) {
descendants.push(block.id)
stack.push(block.id)
}
}
}
return descendants
}
/**
* Checks if any ancestor container of a block is locked.
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any ancestor is locked
*/
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
return isWorkflowBlockAncestorLocked(blockId, blocks)
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if any ancestor container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
return isWorkflowBlockProtected(blockId, blocks)
}
/**
* Builds a complete collection of loops from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of Loop objects for execution engine
*/
export function generateLoopBlocks(blocks: Record<string, BlockState>): Record<string, Loop> {
const loops: Record<string, Loop> = {}
Object.entries(blocks)
.filter(([_, block]) => block.type === 'loop')
.forEach(([id, block]) => {
const loop = convertLoopBlockToLoop(id, blocks)
if (loop) {
loops[id] = loop
}
})
return loops
}
/**
* Builds a complete collection of parallel blocks from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of Parallel objects for execution engine
*/
export function generateParallelBlocks(
blocks: Record<string, BlockState>
): Record<string, Parallel> {
const parallels: Record<string, Parallel> = {}
Object.entries(blocks)
.filter(([_, block]) => block.type === 'parallel')
.forEach(([id, block]) => {
const parallel = convertParallelBlockToParallel(id, blocks)
if (parallel) {
parallels[id] = parallel
}
})
return parallels
}
@@ -0,0 +1,67 @@
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
export interface NormalizationResult {
state: WorkflowState
warnings: string[]
}
function isContainerType(type: string | undefined): boolean {
return type === 'loop' || type === 'parallel'
}
export function normalizeWorkflowState(workflowState: WorkflowState): NormalizationResult {
const warnings: string[] = []
const blocks = structuredClone(workflowState.blocks || {})
for (const [blockId, block] of Object.entries(blocks)) {
if (!block?.type || !block?.name) {
warnings.push(`Dropped invalid block "${blockId}" because it is missing type or name`)
delete blocks[blockId]
}
}
for (const [blockId, block] of Object.entries(blocks)) {
const parentId = block.data?.parentId
if (!parentId) {
continue
}
const parentBlock = blocks[parentId]
const parentIsValidContainer = Boolean(parentBlock && isContainerType(parentBlock.type))
if (!parentIsValidContainer || parentId === blockId) {
warnings.push(`Cleared invalid parentId for block "${blockId}"`)
block.data = {
...(block.data || {}),
parentId: undefined,
extent: undefined,
}
continue
}
if (block.data?.extent !== 'parent') {
block.data = {
...(block.data || {}),
extent: 'parent',
}
}
}
const edgeValidation = validateEdges(workflowState.edges || [], blocks)
warnings.push(
...edgeValidation.dropped.map(({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`)
)
return {
state: {
...workflowState,
blocks,
edges: edgeValidation.valid,
loops: generateLoopBlocks(blocks),
parallels: generateParallelBlocks(blocks),
},
warnings,
}
}