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
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:
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
export { useCanvasModeStore } from './store'
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
useExecutionStore,
|
||||
useIsBlockActive,
|
||||
useIsCurrentWorkflowExecuting,
|
||||
useLastRunEdges,
|
||||
useLastRunPath,
|
||||
} from './store'
|
||||
export { defaultWorkflowExecutionState } from './types'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)'),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePanelEditorSearchStore, usePanelEditorStore } from './store'
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export { useToolbarStore } from './store'
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useProvidersStore } from './store'
|
||||
export type { ProviderName } from './types'
|
||||
@@ -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]
|
||||
},
|
||||
}))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useCodeUndoRedoStore } from './code-store'
|
||||
export { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from './store'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useWorkflowDiffStore } from './store'
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateRandomHex } from '@sim/utils/random'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
|
||||
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
|
||||
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { HydrationState, WorkflowRegistry } from '@/stores/workflows/registry/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowRegistry')
|
||||
const initialHydration: HydrationState = {
|
||||
phase: 'idle',
|
||||
workspaceId: null,
|
||||
workflowId: null,
|
||||
requestId: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
const createRequestId = () => `${Date.now()}-${generateRandomHex(8)}`
|
||||
|
||||
function resetWorkflowStores() {
|
||||
useWorkflowStore.setState({
|
||||
currentWorkflowId: null,
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: Date.now(),
|
||||
})
|
||||
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {},
|
||||
})
|
||||
}
|
||||
|
||||
export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
hydration: initialHydration,
|
||||
clipboard: null,
|
||||
pendingSelection: null,
|
||||
|
||||
switchToWorkspace: (workspaceId: string) => {
|
||||
logger.info(`Switching to workspace: ${workspaceId}`)
|
||||
|
||||
resetWorkflowStores()
|
||||
// Workflow stores are fully reset and reloaded from the server in the new
|
||||
// workspace, so a previously tripped offline mode must not carry over.
|
||||
useOperationQueueStore.getState().clearError()
|
||||
void invalidateWorkflowLists(getQueryClient(), workspaceId)
|
||||
|
||||
set({
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
hydration: {
|
||||
phase: 'idle',
|
||||
workspaceId,
|
||||
workflowId: null,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
loadWorkflowState: async (workflowId: string) => {
|
||||
const workspaceId = get().hydration.workspaceId
|
||||
if (!workspaceId) {
|
||||
const message = `Cannot load workflow ${workflowId} without a workspace scope`
|
||||
logger.error(message)
|
||||
set({ error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const requestId = createRequestId()
|
||||
|
||||
set((state) => ({
|
||||
error: null,
|
||||
hydration: {
|
||||
phase: 'state-loading',
|
||||
workspaceId: workspaceId ?? state.hydration.workspaceId,
|
||||
workflowId,
|
||||
requestId,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
|
||||
try {
|
||||
const workflowData = await getQueryClient().fetchQuery({
|
||||
queryKey: workflowKeys.state(workflowId),
|
||||
queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const deployedAt = workflowData.deployedAt ? workflowData.deployedAt.toISOString() : null
|
||||
|
||||
getQueryClient().setQueryData<WorkflowDeploymentInfo>(
|
||||
deploymentKeys.info(workflowId),
|
||||
(prev) => ({
|
||||
isDeployed: workflowData.isDeployed,
|
||||
deployedAt,
|
||||
apiKey: prev?.apiKey ?? null,
|
||||
needsRedeployment: prev?.needsRedeployment ?? false,
|
||||
isPublicApi: workflowData.isPublicApi,
|
||||
})
|
||||
)
|
||||
|
||||
let workflowState: WorkflowState
|
||||
|
||||
if (workflowData?.state) {
|
||||
const wireState = workflowData.state as Pick<
|
||||
WorkflowState,
|
||||
'blocks' | 'edges' | 'loops' | 'parallels'
|
||||
>
|
||||
workflowState = {
|
||||
currentWorkflowId: workflowId,
|
||||
blocks: wireState.blocks || {},
|
||||
edges: wireState.edges || [],
|
||||
loops: wireState.loops || {},
|
||||
parallels: wireState.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
} else {
|
||||
workflowState = {
|
||||
currentWorkflowId: workflowId,
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Workflow ${workflowId} has no state yet - will load from DB or show empty canvas`
|
||||
)
|
||||
}
|
||||
|
||||
const currentHydration = get().hydration
|
||||
if (
|
||||
currentHydration.requestId !== requestId ||
|
||||
currentHydration.workflowId !== workflowId
|
||||
) {
|
||||
logger.info('Discarding stale workflow hydration result', {
|
||||
workflowId,
|
||||
requestId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().replaceWorkflowState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks || {})
|
||||
|
||||
const wireVariables = workflowData.variables
|
||||
if (wireVariables) {
|
||||
useVariablesStore.setState((state) => {
|
||||
const withoutWorkflow = Object.fromEntries(
|
||||
Object.entries(state.variables).filter(
|
||||
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
|
||||
)
|
||||
)
|
||||
return {
|
||||
variables: {
|
||||
...withoutWorkflow,
|
||||
...(wireVariables as Record<string, Variable>),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('active-workflow-changed', {
|
||||
detail: { workflowId },
|
||||
})
|
||||
)
|
||||
|
||||
set((state) => ({
|
||||
activeWorkflowId: workflowId,
|
||||
error: null,
|
||||
hydration: {
|
||||
phase: 'ready',
|
||||
workspaceId: state.hydration.workspaceId,
|
||||
workflowId,
|
||||
requestId,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info(`Switched to workflow ${workflowId}`)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to load workflow ${workflowId}: Unknown error`
|
||||
logger.error(message)
|
||||
|
||||
const currentHydration = get().hydration
|
||||
if (
|
||||
currentHydration.requestId !== requestId ||
|
||||
currentHydration.workflowId !== workflowId
|
||||
) {
|
||||
logger.info('Discarding stale workflow error', { workflowId, requestId })
|
||||
return
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
error: message,
|
||||
hydration: {
|
||||
phase: 'error',
|
||||
workspaceId: state.hydration.workspaceId,
|
||||
workflowId,
|
||||
requestId: null,
|
||||
error: message,
|
||||
},
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
setActiveWorkflow: async (id: string) => {
|
||||
const { activeWorkflowId, hydration } = get()
|
||||
|
||||
const workflowStoreState = useWorkflowStore.getState()
|
||||
const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0
|
||||
|
||||
const isFullyHydrated =
|
||||
activeWorkflowId === id &&
|
||||
hasWorkflowData &&
|
||||
hydration.phase === 'ready' &&
|
||||
hydration.workflowId === id
|
||||
|
||||
if (isFullyHydrated) {
|
||||
logger.info(`Already active workflow ${id} with data loaded, skipping switch`)
|
||||
return
|
||||
}
|
||||
|
||||
await get().loadWorkflowState(id)
|
||||
},
|
||||
|
||||
markWorkflowCreating: (workflowId: string) => {
|
||||
set((state) => ({
|
||||
error: null,
|
||||
hydration: {
|
||||
phase: 'creating' as const,
|
||||
workspaceId: state.hydration.workspaceId,
|
||||
workflowId,
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
logger.info(`Marked workflow ${workflowId} as creating`)
|
||||
},
|
||||
|
||||
markWorkflowCreated: (workflowId: string | null) => {
|
||||
const { hydration } = get()
|
||||
|
||||
if (!workflowId) {
|
||||
if (hydration.phase === 'creating') {
|
||||
set((state) => ({
|
||||
hydration: {
|
||||
...state.hydration,
|
||||
phase: 'idle' as const,
|
||||
workflowId: null,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (hydration.phase !== 'creating' || hydration.workflowId !== workflowId) {
|
||||
logger.info(
|
||||
`Ignoring markWorkflowCreated for ${workflowId} — hydration is ${hydration.phase}/${hydration.workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Workflow ${workflowId} created, loading state`)
|
||||
get()
|
||||
.loadWorkflowState(workflowId)
|
||||
.catch((error) => {
|
||||
logger.error(`Failed to load newly created workflow ${workflowId}:`, error)
|
||||
})
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logger.info('Logging out - clearing all workflow data')
|
||||
|
||||
resetWorkflowStores()
|
||||
|
||||
// Clear the React Query cache to remove all server state
|
||||
getQueryClient().clear()
|
||||
|
||||
set({
|
||||
activeWorkflowId: null,
|
||||
error: null,
|
||||
hydration: initialHydration,
|
||||
clipboard: null,
|
||||
})
|
||||
|
||||
logger.info('Logout complete - all workflow data cleared')
|
||||
},
|
||||
|
||||
copyBlocks: (blockIds: string[]) => {
|
||||
if (blockIds.length === 0) return
|
||||
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const activeWorkflowId = get().activeWorkflowId
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const copiedBlocks: Record<string, BlockState> = {}
|
||||
const copiedSubBlockValues: Record<string, Record<string, unknown>> = {}
|
||||
const blockIdSet = new Set(blockIds)
|
||||
|
||||
blockIds.forEach((blockId) => {
|
||||
const loop = workflowStore.loops[blockId]
|
||||
if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n))
|
||||
const parallel = workflowStore.parallels[blockId]
|
||||
if (parallel?.nodes) parallel.nodes.forEach((n) => blockIdSet.add(n))
|
||||
})
|
||||
|
||||
blockIdSet.forEach((blockId) => {
|
||||
const block = workflowStore.blocks[blockId]
|
||||
if (block) {
|
||||
copiedBlocks[blockId] = structuredClone(block)
|
||||
if (activeWorkflowId) {
|
||||
const blockValues = subBlockStore.workflowValues[activeWorkflowId]?.[blockId]
|
||||
if (blockValues) {
|
||||
copiedSubBlockValues[blockId] = structuredClone(blockValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const copiedEdges = workflowStore.edges.filter(
|
||||
(edge) => blockIdSet.has(edge.source) && blockIdSet.has(edge.target)
|
||||
)
|
||||
|
||||
const copiedLoops: Record<string, Loop> = {}
|
||||
Object.entries(workflowStore.loops).forEach(([loopId, loop]) => {
|
||||
if (blockIdSet.has(loopId)) {
|
||||
copiedLoops[loopId] = structuredClone(loop)
|
||||
}
|
||||
})
|
||||
|
||||
const copiedParallels: Record<string, Parallel> = {}
|
||||
Object.entries(workflowStore.parallels).forEach(([parallelId, parallel]) => {
|
||||
if (blockIdSet.has(parallelId)) {
|
||||
copiedParallels[parallelId] = structuredClone(parallel)
|
||||
}
|
||||
})
|
||||
|
||||
set({
|
||||
clipboard: {
|
||||
blocks: copiedBlocks,
|
||||
edges: copiedEdges,
|
||||
subBlockValues: copiedSubBlockValues,
|
||||
loops: copiedLoops,
|
||||
parallels: copiedParallels,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Copied blocks to clipboard', { count: Object.keys(copiedBlocks).length })
|
||||
},
|
||||
|
||||
preparePasteData: (positionOffset = DEFAULT_DUPLICATE_OFFSET) => {
|
||||
const { clipboard, activeWorkflowId } = get()
|
||||
if (!clipboard || Object.keys(clipboard.blocks).length === 0) return null
|
||||
if (!activeWorkflowId) return null
|
||||
|
||||
const workflowStore = useWorkflowStore.getState()
|
||||
const { blocks, edges, loops, parallels, subBlockValues } = regenerateBlockIds(
|
||||
clipboard.blocks,
|
||||
clipboard.edges,
|
||||
clipboard.loops,
|
||||
clipboard.parallels,
|
||||
clipboard.subBlockValues,
|
||||
positionOffset,
|
||||
workflowStore.blocks,
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
return { blocks, edges, loops, parallels, subBlockValues }
|
||||
},
|
||||
|
||||
hasClipboard: () => {
|
||||
const { clipboard } = get()
|
||||
return clipboard !== null && Object.keys(clipboard.blocks).length > 0
|
||||
},
|
||||
|
||||
clearClipboard: () => {
|
||||
set({ clipboard: null })
|
||||
},
|
||||
|
||||
setPendingSelection: (blockIds: string[]) => {
|
||||
set((state) => ({
|
||||
pendingSelection: [...(state.pendingSelection ?? []), ...blockIds],
|
||||
}))
|
||||
},
|
||||
|
||||
clearPendingSelection: () => {
|
||||
set({ pendingSelection: null })
|
||||
},
|
||||
}),
|
||||
{ name: 'workflow-registry' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface ClipboardData {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface WorkflowMetadata {
|
||||
id: string
|
||||
name: string
|
||||
lastModified: Date
|
||||
createdAt: Date
|
||||
description?: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
archivedAt?: Date | null
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export type HydrationPhase = 'idle' | 'creating' | 'state-loading' | 'ready' | 'error'
|
||||
|
||||
export interface HydrationState {
|
||||
phase: HydrationPhase
|
||||
workspaceId: string | null
|
||||
workflowId: string | null
|
||||
requestId: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface WorkflowRegistryState {
|
||||
activeWorkflowId: string | null
|
||||
error: string | null
|
||||
hydration: HydrationState
|
||||
clipboard: ClipboardData | null
|
||||
pendingSelection: string[] | null
|
||||
}
|
||||
|
||||
interface WorkflowRegistryActions {
|
||||
setActiveWorkflow: (id: string) => Promise<void>
|
||||
loadWorkflowState: (workflowId: string) => Promise<void>
|
||||
switchToWorkspace: (id: string) => void
|
||||
markWorkflowCreating: (workflowId: string) => void
|
||||
markWorkflowCreated: (workflowId: string | null) => void
|
||||
copyBlocks: (blockIds: string[]) => void
|
||||
preparePasteData: (positionOffset?: { x: number; y: number }) => {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
} | null
|
||||
hasClipboard: () => boolean
|
||||
clearClipboard: () => void
|
||||
setPendingSelection: (blockIds: string[]) => void
|
||||
clearPendingSelection: () => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export type WorkflowRegistry = WorkflowRegistryState & WorkflowRegistryActions
|
||||
@@ -0,0 +1,420 @@
|
||||
import { randomItem } from '@sim/utils/random'
|
||||
|
||||
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
|
||||
const ADJECTIVES = [
|
||||
// Light & Luminosity
|
||||
'Radiant',
|
||||
'Luminous',
|
||||
'Blazing',
|
||||
'Glowing',
|
||||
'Bright',
|
||||
'Gleaming',
|
||||
'Shining',
|
||||
'Lustrous',
|
||||
'Flaring',
|
||||
'Vivid',
|
||||
'Dazzling',
|
||||
'Beaming',
|
||||
'Brilliant',
|
||||
'Lit',
|
||||
'Ablaze',
|
||||
// Celestial Descriptors
|
||||
'Stellar',
|
||||
'Cosmic',
|
||||
'Astral',
|
||||
'Galactic',
|
||||
'Nebular',
|
||||
'Orbital',
|
||||
'Lunar',
|
||||
'Solar',
|
||||
'Starlit',
|
||||
'Heavenly',
|
||||
'Celestial',
|
||||
'Sidereal',
|
||||
'Planetary',
|
||||
'Starry',
|
||||
'Spacial',
|
||||
// Scale & Magnitude
|
||||
'Infinite',
|
||||
'Vast',
|
||||
'Boundless',
|
||||
'Immense',
|
||||
'Colossal',
|
||||
'Titanic',
|
||||
'Massive',
|
||||
'Grand',
|
||||
'Supreme',
|
||||
'Ultimate',
|
||||
'Epic',
|
||||
'Enormous',
|
||||
'Gigantic',
|
||||
'Limitless',
|
||||
'Total',
|
||||
// Temporal
|
||||
'Eternal',
|
||||
'Ancient',
|
||||
'Timeless',
|
||||
'Enduring',
|
||||
'Ageless',
|
||||
'Immortal',
|
||||
'Primal',
|
||||
'Nascent',
|
||||
'First',
|
||||
'Elder',
|
||||
'Lasting',
|
||||
'Undying',
|
||||
'Perpetual',
|
||||
'Final',
|
||||
'Prime',
|
||||
// Movement & Energy
|
||||
'Sidbuck',
|
||||
'Swift',
|
||||
'Drifting',
|
||||
'Spinning',
|
||||
'Surging',
|
||||
'Pulsing',
|
||||
'Soaring',
|
||||
'Racing',
|
||||
'Falling',
|
||||
'Rising',
|
||||
'Circling',
|
||||
'Streaking',
|
||||
'Hurtling',
|
||||
'Floating',
|
||||
'Orbiting',
|
||||
'Spiraling',
|
||||
// Colors of Space
|
||||
'Crimson',
|
||||
'Azure',
|
||||
'Violet',
|
||||
'Indigo',
|
||||
'Amber',
|
||||
'Sapphire',
|
||||
'Obsidian',
|
||||
'Silver',
|
||||
'Golden',
|
||||
'Scarlet',
|
||||
'Cobalt',
|
||||
'Emerald',
|
||||
'Ruby',
|
||||
'Onyx',
|
||||
'Ivory',
|
||||
// Physical Properties
|
||||
'Magnetic',
|
||||
'Quantum',
|
||||
'Thermal',
|
||||
'Photonic',
|
||||
'Ionic',
|
||||
'Plasma',
|
||||
'Spectral',
|
||||
'Charged',
|
||||
'Polar',
|
||||
'Dense',
|
||||
'Atomic',
|
||||
'Nuclear',
|
||||
'Electric',
|
||||
'Kinetic',
|
||||
'Static',
|
||||
// Atmosphere & Mystery
|
||||
'Ethereal',
|
||||
'Mystic',
|
||||
'Phantom',
|
||||
'Shadow',
|
||||
'Silent',
|
||||
'Distant',
|
||||
'Hidden',
|
||||
'Veiled',
|
||||
'Fading',
|
||||
'Arcane',
|
||||
'Cryptic',
|
||||
'Obscure',
|
||||
'Dim',
|
||||
'Dusky',
|
||||
'Shrouded',
|
||||
// Temperature & State
|
||||
'Frozen',
|
||||
'Burning',
|
||||
'Molten',
|
||||
'Volatile',
|
||||
'Icy',
|
||||
'Fiery',
|
||||
'Cool',
|
||||
'Warm',
|
||||
'Cold',
|
||||
'Hot',
|
||||
'Searing',
|
||||
'Frigid',
|
||||
'Scalding',
|
||||
'Chilled',
|
||||
'Heated',
|
||||
// Power & Force
|
||||
'Mighty',
|
||||
'Fierce',
|
||||
'Raging',
|
||||
'Wild',
|
||||
'Serene',
|
||||
'Tranquil',
|
||||
'Harmonic',
|
||||
'Resonant',
|
||||
'Steady',
|
||||
'Bold',
|
||||
'Potent',
|
||||
'Violent',
|
||||
'Calm',
|
||||
'Furious',
|
||||
'Forceful',
|
||||
// Texture & Form
|
||||
'Smooth',
|
||||
'Jagged',
|
||||
'Fractured',
|
||||
'Solid',
|
||||
'Hollow',
|
||||
'Curved',
|
||||
'Sharp',
|
||||
'Fluid',
|
||||
'Rigid',
|
||||
'Warped',
|
||||
// Rare & Precious
|
||||
'Noble',
|
||||
'Pure',
|
||||
'Rare',
|
||||
'Pristine',
|
||||
'Flawless',
|
||||
'Unique',
|
||||
'Exotic',
|
||||
'Sacred',
|
||||
'Divine',
|
||||
'Hallowed',
|
||||
]
|
||||
|
||||
const NOUNS = [
|
||||
// Stars & Stellar Objects
|
||||
'Star',
|
||||
'Sun',
|
||||
'Pulsar',
|
||||
'Quasar',
|
||||
'Magnetar',
|
||||
'Nova',
|
||||
'Supernova',
|
||||
'Hypernova',
|
||||
'Neutron',
|
||||
'Dwarf',
|
||||
'Giant',
|
||||
'Protostar',
|
||||
'Blazar',
|
||||
'Cepheid',
|
||||
'Binary',
|
||||
// Galaxies & Clusters
|
||||
'Galaxy',
|
||||
'Nebula',
|
||||
'Cluster',
|
||||
'Void',
|
||||
'Filament',
|
||||
'Halo',
|
||||
'Bulge',
|
||||
'Spiral',
|
||||
'Ellipse',
|
||||
'Arm',
|
||||
'Disk',
|
||||
'Shell',
|
||||
'Remnant',
|
||||
'Cloud',
|
||||
'Dust',
|
||||
// Planets & Moons
|
||||
'Planet',
|
||||
'Moon',
|
||||
'World',
|
||||
'Exoplanet',
|
||||
'Jovian',
|
||||
'Titan',
|
||||
'Europa',
|
||||
'Io',
|
||||
'Callisto',
|
||||
'Ganymede',
|
||||
'Triton',
|
||||
'Phobos',
|
||||
'Deimos',
|
||||
'Enceladus',
|
||||
'Charon',
|
||||
// Small Bodies
|
||||
'Comet',
|
||||
'Meteor',
|
||||
'Asteroid',
|
||||
'Meteorite',
|
||||
'Bolide',
|
||||
'Fireball',
|
||||
'Iceball',
|
||||
'Plutino',
|
||||
'Centaur',
|
||||
'Trojan',
|
||||
'Shard',
|
||||
'Fragment',
|
||||
'Debris',
|
||||
'Rock',
|
||||
'Ice',
|
||||
// Constellations & Myths
|
||||
'Orion',
|
||||
'Andromeda',
|
||||
'Perseus',
|
||||
'Pegasus',
|
||||
'Phoenix',
|
||||
'Draco',
|
||||
'Cygnus',
|
||||
'Aquila',
|
||||
'Lyra',
|
||||
'Vega',
|
||||
'Centaurus',
|
||||
'Hydra',
|
||||
'Sirius',
|
||||
'Polaris',
|
||||
'Altair',
|
||||
// Celestial Phenomena
|
||||
'Eclipse',
|
||||
'Aurora',
|
||||
'Corona',
|
||||
'Flare',
|
||||
'Storm',
|
||||
'Vortex',
|
||||
'Jet',
|
||||
'Burst',
|
||||
'Pulse',
|
||||
'Wave',
|
||||
'Ripple',
|
||||
'Shimmer',
|
||||
'Glow',
|
||||
'Flash',
|
||||
'Spark',
|
||||
// Cosmic Structures
|
||||
'Horizon',
|
||||
'Zenith',
|
||||
'Nadir',
|
||||
'Apex',
|
||||
'Meridian',
|
||||
'Equinox',
|
||||
'Solstice',
|
||||
'Transit',
|
||||
'Aphelion',
|
||||
'Orbit',
|
||||
'Axis',
|
||||
'Pole',
|
||||
'Equator',
|
||||
'Limb',
|
||||
'Arc',
|
||||
// Space & Dimensions
|
||||
'Cosmos',
|
||||
'Universe',
|
||||
'Dimension',
|
||||
'Realm',
|
||||
'Expanse',
|
||||
'Infinity',
|
||||
'Continuum',
|
||||
'Manifold',
|
||||
'Abyss',
|
||||
'Ether',
|
||||
'Vacuum',
|
||||
'Space',
|
||||
'Fabric',
|
||||
'Plane',
|
||||
'Domain',
|
||||
// Energy & Particles
|
||||
'Photon',
|
||||
'Neutrino',
|
||||
'Proton',
|
||||
'Electron',
|
||||
'Positron',
|
||||
'Quark',
|
||||
'Boson',
|
||||
'Fermion',
|
||||
'Tachyon',
|
||||
'Graviton',
|
||||
'Meson',
|
||||
'Gluon',
|
||||
'Lepton',
|
||||
'Muon',
|
||||
'Pion',
|
||||
// Regions & Zones
|
||||
'Sector',
|
||||
'Quadrant',
|
||||
'Zone',
|
||||
'Belt',
|
||||
'Ring',
|
||||
'Field',
|
||||
'Stream',
|
||||
'Current',
|
||||
'Wake',
|
||||
'Region',
|
||||
'Frontier',
|
||||
'Border',
|
||||
'Edge',
|
||||
'Margin',
|
||||
'Rim',
|
||||
// Navigation & Discovery
|
||||
'Beacon',
|
||||
'Signal',
|
||||
'Probe',
|
||||
'Voyager',
|
||||
'Pioneer',
|
||||
'Seeker',
|
||||
'Wanderer',
|
||||
'Nomad',
|
||||
'Drifter',
|
||||
'Scout',
|
||||
'Explorer',
|
||||
'Ranger',
|
||||
'Surveyor',
|
||||
'Sentinel',
|
||||
'Watcher',
|
||||
// Portals & Passages
|
||||
'Gateway',
|
||||
'Portal',
|
||||
'Nexus',
|
||||
'Bridge',
|
||||
'Conduit',
|
||||
'Channel',
|
||||
'Passage',
|
||||
'Rift',
|
||||
'Warp',
|
||||
'Fold',
|
||||
'Tunnel',
|
||||
'Crossing',
|
||||
'Link',
|
||||
'Path',
|
||||
'Route',
|
||||
// Core & Systems
|
||||
'Core',
|
||||
'Matrix',
|
||||
'Lattice',
|
||||
'Network',
|
||||
'Circuit',
|
||||
'Array',
|
||||
'Reactor',
|
||||
'Engine',
|
||||
'Forge',
|
||||
'Crucible',
|
||||
'Hub',
|
||||
'Node',
|
||||
'Kernel',
|
||||
'Center',
|
||||
'Heart',
|
||||
// Cosmic Objects
|
||||
'Crater',
|
||||
'Rift',
|
||||
'Chasm',
|
||||
'Canyon',
|
||||
'Peak',
|
||||
'Ridge',
|
||||
'Basin',
|
||||
'Plateau',
|
||||
'Valley',
|
||||
'Trench',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generates a creative workflow name using random adjectives and nouns
|
||||
* @returns A creative workflow name like "blazing-phoenix" or "crystal-dragon"
|
||||
*/
|
||||
export function generateCreativeWorkflowName(): string {
|
||||
const adjective = randomItem(ADJECTIVES)
|
||||
const noun = randomItem(NOUNS)
|
||||
return `${adjective.toLowerCase()}-${noun.toLowerCase()}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user