improvement(variables): update workflows to use deployed variables, not local ones to align with the rest of the canvas components (#2577)

* improvement(variables): update workflows to use deployed variables, not local ones to align with the rest of the canvas components

* update change detection to ignore trigger id since it is runtime metadata and not actually required to be redeployed
This commit is contained in:
Waleed
2025-12-24 17:40:23 -08:00
committed by GitHub
parent da7eca9590
commit 40a6bf5c8c
9 changed files with 500 additions and 32 deletions

View File

@@ -60,11 +60,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (normalizedData) {
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
}
const { hasWorkflowChanged } = await import('@/lib/workflows/comparison')
needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any)

View File

@@ -318,6 +318,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
loops: Record<string, any>
parallels: Record<string, any>
deploymentVersionId?: string
variables?: Record<string, any>
} | null = null
let processedInput = input
@@ -327,6 +328,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
: await loadDeployedWorkflowState(workflowId)
if (workflowData) {
const deployedVariables =
!shouldUseDraftState && 'variables' in workflowData
? (workflowData as any).variables
: undefined
cachedWorkflowData = {
blocks: workflowData.blocks,
edges: workflowData.edges,
@@ -336,6 +342,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
!shouldUseDraftState && 'deploymentVersionId' in workflowData
? (workflowData.deploymentVersionId as string)
: undefined,
variables: deployedVariables,
}
const serializedWorkflow = new Serializer().serializeWorkflow(
@@ -405,11 +412,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowStateOverride: effectiveWorkflowStateOverride,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
processedInput,
workflow.variables || {},
executionVariables,
selectedOutputs
)
@@ -471,6 +480,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
selectedOutputs,
cachedWorkflowData?.blocks || {}
)
const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables
const stream = await createStreamingResponse({
requestId,
workflow: {
@@ -478,7 +489,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
userId: actorUserId,
workspaceId,
isDeployed: workflow.isDeployed,
variables: (workflow as any).variables,
variables: streamVariables,
},
input: processedInput,
executingUserId: actorUserId,
@@ -675,11 +686,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowStateOverride: effectiveWorkflowStateOverride,
}
const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
processedInput,
workflow.variables || {},
sseExecutionVariables,
selectedOutputs
)

View File

@@ -1,4 +1,4 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -22,17 +22,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Check if the workflow has meaningful changes that would require redeployment
let needsRedeployment = false
if (validation.workflow.isDeployed) {
// Get current state from normalized tables (same logic as deployment API)
// Load current state from normalized tables using centralized helper
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
// Workflow exists but has no blocks in normalized tables (empty workflow or not migrated)
// This is valid state - return success with no redeployment needed
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,
@@ -41,11 +36,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || {},
lastSaved: Date.now(),
}

View File

@@ -35,7 +35,6 @@ interface DeployModalProps {
workflowId: string | null
isDeployed: boolean
needsRedeployment: boolean
setNeedsRedeployment: (value: boolean) => void
deployedState: WorkflowState
isLoadingDeployedState: boolean
refetchDeployedState: () => Promise<void>
@@ -58,7 +57,6 @@ export function DeployModal({
workflowId,
isDeployed: isDeployedProp,
needsRedeployment,
setNeedsRedeployment,
deployedState,
isLoadingDeployedState,
refetchDeployedState,
@@ -229,7 +227,6 @@ export function DeployModal({
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
setNeedsRedeployment(false)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
@@ -453,7 +450,6 @@ export function DeployModal({
getApiKeyLabel(apiKey)
)
setNeedsRedeployment(false)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}

View File

@@ -45,8 +45,7 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
isRegistryLoading,
})
// Detect changes between current and deployed state
const { changeDetected, setChangeDetected } = useChangeDetection({
const { changeDetected } = useChangeDetection({
workflowId: activeWorkflowId,
deployedState,
isLoadingDeployedState,
@@ -136,7 +135,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
workflowId={activeWorkflowId}
isDeployed={isDeployed}
needsRedeployment={changeDetected}
setNeedsRedeployment={setChangeDetected}
deployedState={deployedState!}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchWithErrorHandling}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { useDebounce } from '@/hooks/use-debounce'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -27,8 +28,18 @@ export function useChangeDetection({
const subBlockValues = useSubBlockStore((state) =>
workflowId ? state.workflowValues[workflowId] : null
)
const allVariables = useVariablesStore((state) => state.variables)
const workflowVariables = useMemo(() => {
if (!workflowId) return {}
const vars: Record<string, any> = {}
for (const [id, variable] of Object.entries(allVariables)) {
if (variable.workflowId === workflowId) {
vars[id] = variable
}
}
return vars
}, [workflowId, allVariables])
// Build current state with subblock values merged into blocks
const currentState = useMemo((): WorkflowState | null => {
if (!workflowId) return null
@@ -37,12 +48,10 @@ export function useChangeDetection({
const blockSubValues = subBlockValues?.[blockId] || {}
const subBlocks: Record<string, any> = {}
// Merge subblock values into the block's subBlocks structure
for (const [subId, value] of Object.entries(blockSubValues)) {
subBlocks[subId] = { value }
}
// Also include existing subBlocks from the block itself
if (block.subBlocks) {
for (const [subId, subBlock] of Object.entries(block.subBlocks)) {
if (!subBlocks[subId]) {
@@ -64,10 +73,10 @@ export function useChangeDetection({
edges,
loops,
parallels,
}
}, [workflowId, blocks, edges, loops, parallels, subBlockValues])
variables: workflowVariables,
} as WorkflowState & { variables: Record<string, any> }
}, [workflowId, blocks, edges, loops, parallels, subBlockValues, workflowVariables])
// Compute change detection with debouncing for performance
const rawChangeDetected = useMemo(() => {
if (!currentState || !deployedState || isLoadingDeployedState) {
return false
@@ -75,13 +84,7 @@ export function useChangeDetection({
return hasWorkflowChanged(currentState, deployedState)
}, [currentState, deployedState, isLoadingDeployedState])
// Debounce to avoid UI flicker during rapid edits
const changeDetected = useDebounce(rawChangeDetected, 300)
const setChangeDetected = () => {
// No-op: change detection is now computed, not stateful
// Kept for API compatibility
}
return { changeDetected, setChangeDetected }
return { changeDetected }
}

View File

@@ -2075,4 +2075,437 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
})
describe('Variable Changes', () => {
it.concurrent('should detect added variables', () => {
const deployedState = {
...createWorkflowState({}),
variables: {},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should detect removed variables', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
},
}
const currentState = {
...createWorkflowState({}),
variables: {},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should detect variable value changes', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'world' },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should detect variable type changes', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: '123' },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'number', value: 123 },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should detect variable name changes', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'oldName', type: 'string', value: 'hello' },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'newName', type: 'string', value: 'hello' },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should not detect change for identical variables', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false)
})
it.concurrent('should not detect change for empty variables on both sides', () => {
const deployedState = {
...createWorkflowState({}),
variables: {},
}
const currentState = {
...createWorkflowState({}),
variables: {},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false)
})
it.concurrent('should not detect change for undefined vs empty object variables', () => {
const deployedState = {
...createWorkflowState({}),
variables: undefined,
}
const currentState = {
...createWorkflowState({}),
variables: {},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false)
})
it.concurrent('should handle complex variable values (objects)', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'config', type: 'object', value: { key: 'value1' } },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'config', type: 'object', value: { key: 'value2' } },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should handle complex variable values (arrays)', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'items', type: 'array', value: [1, 2, 3] },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'items', type: 'array', value: [1, 2, 4] },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
})
it.concurrent('should not detect change when variable key order differs', () => {
const deployedState = {
...createWorkflowState({}),
variables: {
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
},
}
const currentState = {
...createWorkflowState({}),
variables: {
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
},
}
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false)
})
})
describe('Trigger Runtime Metadata (Should Not Trigger Change)', () => {
it.concurrent('should not detect change when webhookId differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when triggerPath differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
triggerPath: { value: '' },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
triggerPath: { value: '/api/webhooks/abc123' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when testUrl differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrl: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrl: { value: 'https://test.example.com/webhook' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when testUrlExpiresAt differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrlExpiresAt: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
testUrlExpiresAt: { value: '2025-12-31T23:59:59Z' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent('should not detect change when all runtime metadata differs', () => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
triggerPath: { value: '' },
testUrl: { value: null },
testUrlExpiresAt: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
testUrl: { value: 'https://test.example.com/webhook' },
testUrlExpiresAt: { value: '2025-12-31T23:59:59Z' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
})
it.concurrent(
'should detect change when triggerConfig differs but runtime metadata also differs',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'pull_request' } },
webhookId: { value: 'wh_123456' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
}
)
it.concurrent(
'should not detect change when runtime metadata is added to current state',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent(
'should not detect change when runtime metadata is removed from current state',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_old123' },
triggerPath: { value: '/api/webhooks/old' },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
})
})

View File

@@ -1,4 +1,5 @@
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
import {
normalizedStringify,
normalizeEdge,
@@ -100,10 +101,12 @@ export function hasWorkflowChanged(
subBlocks: undefined,
}
// Get all subBlock IDs from both states
// Get all subBlock IDs from both states, excluding runtime metadata
const allSubBlockIds = [
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(deployedSubBlocks)]),
].sort()
]
.filter((id) => !TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id))
.sort()
// Normalize and compare each subBlock
for (const subBlockId of allSubBlockIds) {
@@ -224,5 +227,16 @@ export function hasWorkflowChanged(
}
}
// 6. Compare variables
const currentVariables = (currentState as any).variables || {}
const deployedVariables = (deployedState as any).variables || {}
const normalizedCurrentVars = normalizeValue(currentVariables)
const normalizedDeployedVars = normalizeValue(deployedVariables)
if (normalizedStringify(normalizedCurrentVars) !== normalizedStringify(normalizedDeployedVars)) {
return true
}
return false
}

View File

@@ -42,6 +42,7 @@ export interface NormalizedWorkflowData {
export interface DeployedWorkflowData extends NormalizedWorkflowData {
deploymentVersionId: string
variables?: Record<string, any>
}
export async function blockExistsInDeployment(
@@ -94,13 +95,14 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise<Dep
throw new Error(`Workflow ${workflowId} has no active deployment`)
}
const state = active.state as WorkflowState
const state = active.state as WorkflowState & { variables?: Record<string, any> }
return {
blocks: state.blocks || {},
edges: state.edges || [],
loops: state.loops || {},
parallels: state.parallels || {},
variables: state.variables || {},
isFromNormalizedTables: false,
deploymentVersionId: active.id,
}