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

192 lines
6.6 KiB
TypeScript

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)
}
}