chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { useWorkflowDiffStore } from './store'
+228
View File
@@ -0,0 +1,228 @@
/**
* @vitest-environment node
*
* Tests for the workflow-diff store's status modeling.
*
* Focus: the {@link WorkflowDiffStatus} enum is the single source of truth and
* the legacy `hasActiveDiff` / `isShowingDiff` / `isDiffReady` booleans are
* derived from it, so contradictory combinations are unrepresentable. We assert
* the exhaustive status → boolean mapping and the status transitions driven by
* the tractable actions (`toggleDiffView`, `clearDiff`, `_batchedStateUpdate`).
*
* @remarks
* The store transitively imports the diff engine, serializer, socket
* operations, and the workflow/registry stores, all of which drag in the block
* registry and emcn icon CSS. Every such dependency is mocked so the suite
* loads under the node environment and exercises only the store + its types.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { applyWorkflowStateToStores } = vi.hoisted(() => ({
applyWorkflowStateToStores: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('@/lib/workflows/diff', () => ({
WorkflowDiffEngine: class {
clearDiff = vi.fn()
createDiffFromWorkflowState = vi.fn()
},
stripWorkflowDiffMarkers: vi.fn((s) => s),
}))
vi.mock('@/lib/workflows/operations/socket-operations', () => ({
enqueueReplaceWorkflowState: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/workflows/sanitization/validation', () => ({
validateWorkflowState: vi.fn(() => ({ valid: true, errors: [], sanitizedState: null })),
}))
vi.mock('@/serializer', () => ({
Serializer: class {
serializeWorkflow = vi.fn()
deserializeWorkflow = vi.fn()
},
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: { getState: vi.fn(() => ({ activeWorkflowId: null })) },
}))
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn((blocks) => blocks),
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: vi.fn(() => ({
getWorkflowState: vi.fn(() => ({ blocks: {}, edges: [], loops: {}, parallels: {} })),
blocks: {},
lastSaved: 0,
})),
setState: vi.fn(),
},
}))
vi.mock('@/stores/workflow-diff/utils', () => ({
applyWorkflowStateToStores,
captureBaselineSnapshot: vi.fn(),
cloneWorkflowState: vi.fn((s) => s),
createBatchedUpdater:
(set: (u: Record<string, unknown>) => void) => (updates: Record<string, unknown>) =>
set(updates),
getLatestUserMessageId: vi.fn().mockResolvedValue(null),
persistWorkflowStateToServer: vi.fn().mockResolvedValue(true),
WORKFLOW_DIFF_SETTLED_EVENT: 'workflow-diff-settled',
}))
import { RESET_DIFF_STATE, useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import {
deriveDiffFlags,
type WorkflowDiffState,
type WorkflowDiffStatus,
} from '@/stores/workflow-diff/types'
function seedStatus(status: WorkflowDiffStatus): void {
useWorkflowDiffStore.setState(deriveDiffFlags(status))
}
describe('useWorkflowDiffStore status modeling', () => {
beforeEach(() => {
vi.clearAllMocks()
useWorkflowDiffStore.setState({
...RESET_DIFF_STATE,
pendingExternalUpdates: {},
remoteUpdateVersions: {},
reconcilingWorkflows: {},
reconciliationErrors: {},
} as Partial<WorkflowDiffState>)
})
describe('deriveDiffFlags', () => {
it('maps every status to the documented legacy booleans', () => {
expect(deriveDiffFlags('none')).toEqual({
status: 'none',
hasActiveDiff: false,
isShowingDiff: false,
isDiffReady: false,
})
expect(deriveDiffFlags('staged')).toEqual({
status: 'staged',
hasActiveDiff: true,
isShowingDiff: false,
isDiffReady: true,
})
expect(deriveDiffFlags('showing')).toEqual({
status: 'showing',
hasActiveDiff: true,
isShowingDiff: true,
isDiffReady: true,
})
})
it('keeps hasActiveDiff and isDiffReady in lockstep (legacy invariant)', () => {
for (const status of ['none', 'staged', 'showing'] as const) {
const flags = deriveDiffFlags(status)
expect(flags.hasActiveDiff).toBe(flags.isDiffReady)
}
})
})
describe('initial / reset state', () => {
it('starts in the none-derived state', () => {
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('none')
expect(state.hasActiveDiff).toBe(false)
expect(state.isShowingDiff).toBe(false)
expect(state.isDiffReady).toBe(false)
})
it('RESET_DIFF_STATE carries the none-derived flags and clears diff payload', () => {
expect(RESET_DIFF_STATE.status).toBe('none')
expect(RESET_DIFF_STATE.hasActiveDiff).toBe(false)
expect(RESET_DIFF_STATE.isShowingDiff).toBe(false)
expect(RESET_DIFF_STATE.isDiffReady).toBe(false)
expect(RESET_DIFF_STATE.baselineWorkflow).toBeNull()
expect(RESET_DIFF_STATE.diffAnalysis).toBeNull()
})
})
describe('toggleDiffView', () => {
it('is a guarded no-op when there is no active diff', () => {
seedStatus('none')
useWorkflowDiffStore.getState().toggleDiffView()
expect(useWorkflowDiffStore.getState().status).toBe('none')
})
it('toggles showing → staged (hides the proposed changes)', () => {
seedStatus('showing')
useWorkflowDiffStore.getState().toggleDiffView()
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('staged')
expect(state.hasActiveDiff).toBe(true)
expect(state.isDiffReady).toBe(true)
expect(state.isShowingDiff).toBe(false)
})
it('toggles staged → showing (reveals the proposed changes)', () => {
seedStatus('staged')
useWorkflowDiffStore.getState().toggleDiffView()
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('showing')
expect(state.isShowingDiff).toBe(true)
})
})
describe('clearDiff', () => {
it('returns the store to the none status', () => {
seedStatus('showing')
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('none')
expect(state.hasActiveDiff).toBe(false)
expect(state.isShowingDiff).toBe(false)
expect(state.isDiffReady).toBe(false)
})
})
describe('_batchedStateUpdate (undo/redo writer)', () => {
it('restores the showing status via deriveDiffFlags', () => {
seedStatus('none')
useWorkflowDiffStore.getState()._batchedStateUpdate({
...deriveDiffFlags('showing'),
baselineWorkflow: null,
baselineWorkflowId: 'wf-1',
})
const state = useWorkflowDiffStore.getState()
expect(state.status).toBe('showing')
expect(state.hasActiveDiff).toBe(true)
expect(state.isShowingDiff).toBe(true)
expect(state.isDiffReady).toBe(true)
})
it('the derived booleans always agree with the stored status', () => {
for (const status of ['none', 'staged', 'showing', 'none'] as const) {
seedStatus(status)
const state = useWorkflowDiffStore.getState()
expect({
hasActiveDiff: state.hasActiveDiff,
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
}).toEqual({
hasActiveDiff: deriveDiffFlags(status).hasActiveDiff,
isShowingDiff: deriveDiffFlags(status).isShowingDiff,
isDiffReady: deriveDiffFlags(status).isDiffReady,
})
}
})
})
})
+570
View File
@@ -0,0 +1,570 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { stripWorkflowDiffMarkers, WorkflowDiffEngine } from '@/lib/workflows/diff'
import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { Serializer } from '@/serializer'
import { useWorkflowRegistry } from '../workflows/registry/store'
import { mergeSubblockState } from '../workflows/utils'
import { useWorkflowStore } from '../workflows/workflow/store'
import { deriveDiffFlags, type WorkflowDiffActions, type WorkflowDiffState } from './types'
import {
applyWorkflowStateToStores,
captureBaselineSnapshot,
cloneWorkflowState,
createBatchedUpdater,
getLatestUserMessageId,
persistWorkflowStateToServer,
WORKFLOW_DIFF_SETTLED_EVENT,
} from './utils'
const logger = createLogger('WorkflowDiffStore')
const diffEngine = new WorkflowDiffEngine()
/**
* Canonical state patch that clears the diff overlay back to `none`: the
* none-derived flags plus a wipe of all diff payload fields.
*/
export const RESET_DIFF_STATE = {
...deriveDiffFlags('none'),
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
_triggerMessageId: null,
} as const
/**
* Detects when a diff contains no meaningful changes.
*/
function isEmptyDiffAnalysis(
diffAnalysis?: {
new_blocks?: string[]
edited_blocks?: string[]
deleted_blocks?: string[]
field_diffs?: Record<string, { changed_fields: string[] }>
edge_diff?: { new_edges?: string[]; deleted_edges?: string[] }
} | null
): boolean {
if (!diffAnalysis) return false
const hasBlockChanges =
(diffAnalysis.new_blocks?.length || 0) > 0 ||
(diffAnalysis.edited_blocks?.length || 0) > 0 ||
(diffAnalysis.deleted_blocks?.length || 0) > 0
const hasEdgeChanges =
(diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 ||
(diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0
const hasFieldChanges = Object.values(diffAnalysis.field_diffs || {}).some(
(diff) => (diff?.changed_fields?.length || 0) > 0
)
return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges
}
function notifyDiffSettled(workflowId: string | null | undefined): void {
if (!workflowId || typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(WORKFLOW_DIFF_SETTLED_EVENT, { detail: { workflowId } }))
}
export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActions>()(
devtools(
(set, get) => {
const batchedUpdate = createBatchedUpdater(set)
return {
...deriveDiffFlags('none'),
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
pendingExternalUpdates: {},
remoteUpdateVersions: {},
reconcilingWorkflows: {},
reconciliationErrors: {},
_triggerMessageId: null,
_batchedStateUpdate: batchedUpdate,
setProposedChanges: async (proposedState, diffAnalysis, options) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
logger.error('Cannot apply diff without an active workflow')
throw new Error('No active workflow found')
}
// Capture baseline if needed (synchronous, fast)
let baselineWorkflow = get().baselineWorkflow
let baselineWorkflowId = get().baselineWorkflowId
let capturedBaseline = false
if (
options?.baselineWorkflow &&
(!baselineWorkflow || baselineWorkflowId !== activeWorkflowId)
) {
baselineWorkflow = cloneWorkflowState(options.baselineWorkflow)
baselineWorkflowId = activeWorkflowId
capturedBaseline = true
logger.info('Using provided baseline snapshot for diff workflow', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks || {}).length,
})
} else if (!baselineWorkflow || baselineWorkflowId !== activeWorkflowId) {
baselineWorkflow = captureBaselineSnapshot(activeWorkflowId)
baselineWorkflowId = activeWorkflowId
capturedBaseline = true
logger.info('Captured baseline snapshot for diff workflow', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks || {}).length,
})
}
// Create diff (this is fast, just computes the diff)
const diffResult = await diffEngine.createDiffFromWorkflowState(
proposedState,
diffAnalysis,
baselineWorkflow ?? undefined
)
if (!diffResult.success || !diffResult.diff) {
const errorMessage = diffResult.errors?.join(', ') || 'Failed to create diff'
logger.error(errorMessage)
throw new Error(errorMessage)
}
const diffAnalysisResult = diffResult.diff.diffAnalysis || null
if (isEmptyDiffAnalysis(diffAnalysisResult)) {
logger.info('No workflow diff detected; skipping diff view')
diffEngine.clearDiff()
batchedUpdate(RESET_DIFF_STATE)
return
}
const candidateState = diffResult.diff.proposedState
logger.info('[WorkflowDiff] Applying proposed state', {
blockCount: Object.keys(candidateState.blocks || {}).length,
edgeCount: candidateState.edges?.length ?? 0,
hasLoops: !!candidateState.loops,
hasParallels: !!candidateState.parallels,
})
// Validate proposed workflow using serializer round-trip
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow(
candidateState.blocks,
candidateState.edges,
candidateState.loops,
candidateState.parallels,
false
)
serializer.deserializeWorkflow(serialized)
// Set diff status BEFORE applying state so that the very first
// React render after the store update already sees diff mode active.
// This prevents a flash frame where blocks render without diff colors.
set({
...deriveDiffFlags('showing'),
baselineWorkflow: baselineWorkflow,
baselineWorkflowId,
diffAnalysis: diffAnalysisResult,
diffMetadata: diffResult.diff.metadata,
diffError: null,
_triggerMessageId: get()._triggerMessageId ?? null,
})
// Now apply the proposed state to stores — React will batch this
// with the diff flag update above into a single render.
applyWorkflowStateToStores(activeWorkflowId, candidateState)
logger.info('[WorkflowDiff] Applied state to stores with diff flags already active')
// Resolve trigger message ID in the background (non-blocking)
if (capturedBaseline && !get()._triggerMessageId) {
getLatestUserMessageId().then((msgId) => {
if (msgId) set({ _triggerMessageId: msgId })
})
}
logger.info('Workflow diff applied optimistically', {
workflowId: activeWorkflowId,
blocks: Object.keys(candidateState.blocks || {}).length,
edges: candidateState.edges?.length || 0,
})
// When skipPersist is set, the server tool (edit_workflow) already
// saved to DB. Both the Socket.IO broadcast and HTTP persist would
// race with subsequent edit_workflow calls and overwrite newer state,
// causing block IDs to thrash.
if (!options?.skipPersist) {
const cleanState = stripWorkflowDiffMarkers(cloneWorkflowState(candidateState))
enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: cleanState,
}).catch((error) => {
logger.warn('Failed to broadcast workflow state (non-blocking)', { error })
})
persistWorkflowStateToServer(activeWorkflowId, candidateState)
.then((persisted) => {
if (!persisted) {
logger.warn('Failed to persist copilot edits (state already applied locally)')
} else {
logger.info('Workflow diff persisted to database', {
workflowId: activeWorkflowId,
})
}
})
.catch((error) => {
logger.warn('Failed to persist workflow state (non-blocking)', { error })
})
}
// Emit event for undo/redo recording
if (!options?.skipRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
detail: {
type: 'apply-diff',
baselineSnapshot: baselineWorkflow,
proposedState: candidateState,
diffAnalysis: diffResult.diff.diffAnalysis,
},
})
)
}
},
clearDiff: ({ restoreBaseline = true } = {}) => {
const { baselineWorkflow, baselineWorkflowId } = get()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (
restoreBaseline &&
baselineWorkflow &&
baselineWorkflowId &&
baselineWorkflowId === activeWorkflowId
) {
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
}
diffEngine.clearDiff()
set(RESET_DIFF_STATE)
notifyDiffSettled(baselineWorkflowId ?? activeWorkflowId)
},
toggleDiffView: () => {
const { status } = get()
if (status === 'none') {
logger.warn('Cannot toggle diff view without an active, ready diff')
return
}
batchedUpdate(deriveDiffFlags(status === 'showing' ? 'staged' : 'showing'))
},
acceptChanges: async (options) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
logger.error('No active workflow ID found when accepting diff')
throw new Error('No active workflow found')
}
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(
currentState.blocks,
activeWorkflowId ?? undefined
)
const mergedState = {
...currentState,
blocks: mergedBlocks,
}
const cleanState = stripWorkflowDiffMarkers(cloneWorkflowState(mergedState))
const validation = validateWorkflowState(cleanState, { sanitize: true })
if (!validation.valid) {
const errorMessage = `Cannot apply changes: ${validation.errors.join('; ')}`
logger.error(errorMessage)
batchedUpdate({ diffError: errorMessage })
throw new Error(errorMessage)
}
const stateToApply = {
...(validation.sanitizedState || cleanState),
lastSaved: useWorkflowStore.getState().lastSaved,
}
// Capture state before accept for undo
const beforeAccept = cloneWorkflowState(mergedState)
const afterAccept = cloneWorkflowState(stateToApply)
const diffAnalysisForUndo = get().diffAnalysis
const baselineForUndo = get().baselineWorkflow
// Clear diff state FIRST to prevent flash of colors
// This must happen synchronously before applying the cleaned state
set(RESET_DIFF_STATE)
// Clear the diff engine
diffEngine.clearDiff()
// Now apply the cleaned state
applyWorkflowStateToStores(activeWorkflowId, stateToApply)
// Emit event for undo/redo recording (unless we're in an undo/redo operation)
if (!options?.skipRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
detail: {
type: 'accept-diff',
beforeAccept,
afterAccept,
diffAnalysis: diffAnalysisForUndo,
baselineSnapshot: baselineForUndo,
},
})
)
}
notifyDiffSettled(activeWorkflowId)
},
rejectChanges: async (options) => {
const { baselineWorkflow, baselineWorkflowId, diffAnalysis } = get()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!baselineWorkflow || !baselineWorkflowId) {
logger.warn('Reject called without baseline workflow')
get().clearDiff({ restoreBaseline: false })
return
}
if (!activeWorkflowId || activeWorkflowId !== baselineWorkflowId) {
logger.warn('Reject called while viewing a different workflow', {
activeWorkflowId,
baselineWorkflowId,
})
get().clearDiff({ restoreBaseline: false })
return
}
// Capture current state (with markers) before rejecting
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(
currentState.blocks,
activeWorkflowId ?? undefined
)
const beforeReject = cloneWorkflowState({
...currentState,
blocks: mergedBlocks,
})
get().setWorkflowReconciliationInProgress(baselineWorkflowId, true)
// Clear diff state FIRST for instant UI feedback
set(RESET_DIFF_STATE)
// Clear the diff engine
diffEngine.clearDiff()
// Apply baseline state locally
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
// Emit event for undo/redo recording synchronously
if (!options?.skipRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
detail: {
type: 'reject-diff',
beforeReject,
afterReject: baselineWorkflow,
diffAnalysis,
baselineSnapshot: baselineWorkflow,
},
})
)
}
logger.info('Broadcasting reject to other users', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks).length,
})
enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: baselineWorkflow,
}).catch((error) => {
logger.error('Failed to broadcast reject to other users:', error)
})
const pendingGenerationBeforePersist =
get().pendingExternalUpdates[baselineWorkflowId] ?? 0
const persisted = await persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow)
if (!persisted) {
logger.error('Failed to persist baseline workflow state')
if (
(get().pendingExternalUpdates[baselineWorkflowId] ?? 0) <=
pendingGenerationBeforePersist
) {
get().clearExternalUpdatePending(baselineWorkflowId)
}
get().setWorkflowReconciliationInProgress(baselineWorkflowId, false)
get().setWorkflowReconciliationError(
baselineWorkflowId,
'Failed to save rejected copilot changes. Refresh and try again.'
)
if (
(get().pendingExternalUpdates[baselineWorkflowId] ?? 0) >
pendingGenerationBeforePersist
) {
notifyDiffSettled(baselineWorkflowId)
}
return
}
if (
(get().pendingExternalUpdates[baselineWorkflowId] ?? 0) <=
pendingGenerationBeforePersist
) {
get().clearExternalUpdatePending(baselineWorkflowId)
}
get().setWorkflowReconciliationError(baselineWorkflowId, null)
get().setWorkflowReconciliationInProgress(baselineWorkflowId, false)
notifyDiffSettled(baselineWorkflowId)
},
reapplyDiffMarkers: () => {
const { hasActiveDiff, isDiffReady, diffAnalysis } = get()
if (!hasActiveDiff || !isDiffReady || !diffAnalysis) {
return
}
const workflowStore = useWorkflowStore.getState()
const currentBlocks = workflowStore.blocks
// Check if any blocks need markers applied (checking the actual property, not just existence)
const needsUpdate =
diffAnalysis.new_blocks?.some((blockId) => {
const block = currentBlocks[blockId]
const blockDiffState = (block as { is_diff?: string } | undefined)?.is_diff
return block && blockDiffState !== 'new'
}) ||
diffAnalysis.edited_blocks?.some((blockId) => {
const block = currentBlocks[blockId]
const blockDiffState = (block as { is_diff?: string } | undefined)?.is_diff
return block && blockDiffState !== 'edited'
})
if (!needsUpdate) {
return
}
const updatedBlocks: Record<string, any> = {}
let hasChanges = false
// Only clone blocks that need diff markers
Object.entries(currentBlocks).forEach(([blockId, block]) => {
const isNewBlock = diffAnalysis.new_blocks?.includes(blockId)
const isEditedBlock = diffAnalysis.edited_blocks?.includes(blockId)
const blockDiffState = (block as { is_diff?: string } | undefined)?.is_diff
if (isNewBlock && blockDiffState !== 'new') {
updatedBlocks[blockId] = { ...block, is_diff: 'new' }
hasChanges = true
} else if (isEditedBlock && blockDiffState !== 'edited') {
updatedBlocks[blockId] = { ...block, is_diff: 'edited' }
// Re-apply field_diffs if available
if (diffAnalysis.field_diffs?.[blockId]) {
updatedBlocks[blockId].field_diffs = diffAnalysis.field_diffs[blockId]
// Clone subblocks and apply markers
const fieldDiff = diffAnalysis.field_diffs[blockId]
updatedBlocks[blockId].subBlocks = { ...block.subBlocks }
fieldDiff.changed_fields.forEach((field) => {
if (updatedBlocks[blockId].subBlocks?.[field]) {
updatedBlocks[blockId].subBlocks[field] = {
...updatedBlocks[blockId].subBlocks[field],
is_diff: 'changed',
}
}
})
}
hasChanges = true
} else {
updatedBlocks[blockId] = block
}
})
// Only update if we actually made changes
if (hasChanges) {
useWorkflowStore.setState({ blocks: updatedBlocks })
logger.info('Re-applied diff markers to workflow blocks')
}
},
markRemoteUpdateSeen: (workflowId) => {
set((state) => ({
remoteUpdateVersions: {
...state.remoteUpdateVersions,
[workflowId]: (state.remoteUpdateVersions[workflowId] ?? 0) + 1,
},
reconciliationErrors: Object.fromEntries(
Object.entries(state.reconciliationErrors).filter(([id]) => id !== workflowId)
),
}))
},
markExternalUpdatePending: (workflowId) => {
const current = get()
set({
pendingExternalUpdates: {
...current.pendingExternalUpdates,
[workflowId]: (current.pendingExternalUpdates[workflowId] ?? 0) + 1,
},
remoteUpdateVersions: {
...current.remoteUpdateVersions,
[workflowId]: (current.remoteUpdateVersions[workflowId] ?? 0) + 1,
},
reconciliationErrors: Object.fromEntries(
Object.entries(current.reconciliationErrors).filter(([id]) => id !== workflowId)
),
})
},
clearExternalUpdatePending: (workflowId) => {
set((state) => {
const { [workflowId]: _removed, ...pendingExternalUpdates } =
state.pendingExternalUpdates
return { pendingExternalUpdates }
})
},
setWorkflowReconciliationInProgress: (workflowId, isReconciling) => {
set((state) => {
const { [workflowId]: _removed, ...reconcilingWorkflows } = state.reconcilingWorkflows
return {
reconcilingWorkflows: isReconciling
? { ...reconcilingWorkflows, [workflowId]: true }
: reconcilingWorkflows,
}
})
},
setWorkflowReconciliationError: (workflowId, error) => {
set((state) => {
const { [workflowId]: _removed, ...reconciliationErrors } = state.reconciliationErrors
return {
reconciliationErrors: error
? { ...reconciliationErrors, [workflowId]: error }
: reconciliationErrors,
}
})
},
}
},
{ name: 'workflow-diff-store' }
)
)
+100
View File
@@ -0,0 +1,100 @@
import type { DiffAnalysis, WorkflowDiff } from '@/lib/workflows/diff'
import type { WorkflowState } from '../workflows/workflow/types'
/**
* The lifecycle stage of the workflow diff overlay.
*
* @remarks
* This is the single source of truth for the diff overlay. The legacy
* `hasActiveDiff` / `isShowingDiff` / `isDiffReady` booleans are derived from
* it via {@link deriveDiffFlags}, which makes contradictory combinations —
* such as "showing a diff that has no active diff" — unrepresentable.
*
* - `none` — no diff staged; the canvas shows the live workflow.
* - `staged` — a diff is staged and ready, but the canvas is showing the
* baseline (proposed changes hidden).
* - `showing` — a diff is staged and ready, and the canvas is showing the
* proposed changes with diff markers.
*/
export type WorkflowDiffStatus = 'none' | 'staged' | 'showing'
export interface WorkflowDiffState {
/** Lifecycle stage of the diff overlay; the source of truth for diff flags */
status: WorkflowDiffStatus
/** Derived from {@link status}: a diff is staged (`staged` or `showing`) */
hasActiveDiff: boolean
/** Derived from {@link status}: the canvas is rendering the proposed changes */
isShowingDiff: boolean
/** Derived from {@link status}: a staged diff is ready to view/toggle */
isDiffReady: boolean
baselineWorkflow: WorkflowState | null
baselineWorkflowId: string | null
diffAnalysis: DiffAnalysis | null
diffMetadata: WorkflowDiff['metadata'] | null
diffError?: string | null
pendingExternalUpdates: Record<string, number>
remoteUpdateVersions: Record<string, number>
reconcilingWorkflows: Record<string, boolean>
reconciliationErrors: Record<string, string>
_triggerMessageId?: string | null
}
interface DiffActionOptions {
/** Skip recording this operation for undo/redo. Used during undo/redo replay. */
skipRecording?: boolean
/** Skip persisting to DB. Use when the server tool already saved (e.g. edit_workflow). */
skipPersist?: boolean
/**
* Explicit baseline snapshot to diff against.
* Use this when the proposed state is fetched asynchronously and the live
* workflow store may have already been updated to that same state.
*/
baselineWorkflow?: WorkflowState
}
export interface WorkflowDiffActions {
setProposedChanges: (
workflowState: WorkflowState,
diffAnalysis?: DiffAnalysis,
options?: DiffActionOptions
) => Promise<void>
clearDiff: (options?: { restoreBaseline?: boolean }) => void
toggleDiffView: () => void
acceptChanges: (options?: DiffActionOptions) => Promise<void>
rejectChanges: (options?: DiffActionOptions) => Promise<void>
reapplyDiffMarkers: () => void
markRemoteUpdateSeen: (workflowId: string) => void
markExternalUpdatePending: (workflowId: string) => void
clearExternalUpdatePending: (workflowId: string) => void
setWorkflowReconciliationInProgress: (workflowId: string, isReconciling: boolean) => void
setWorkflowReconciliationError: (workflowId: string, error: string | null) => void
_batchedStateUpdate: (updates: Partial<WorkflowDiffState>) => void
}
/**
* The {@link WorkflowDiffStatus} fields shared by `status` and its derived
* booleans. Spread this into a state patch so the source of truth and the
* legacy flags never drift apart.
*/
export type DiffStatusFlags = Pick<
WorkflowDiffState,
'status' | 'hasActiveDiff' | 'isShowingDiff' | 'isDiffReady'
>
/**
* Computes the legacy `hasActiveDiff` / `isShowingDiff` / `isDiffReady`
* booleans (plus the `status` itself) from a {@link WorkflowDiffStatus}.
*
* @remarks
* Keeping the derived booleans on the stored state lets existing consumers
* keep reading `state.hasActiveDiff` etc. unchanged while
* {@link WorkflowDiffStatus} remains the single source of truth.
*/
export function deriveDiffFlags(status: WorkflowDiffStatus): DiffStatusFlags {
return {
status,
hasActiveDiff: status !== 'none',
isShowingDiff: status === 'showing',
isDiffReady: status !== 'none',
}
}
@@ -0,0 +1,77 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { useVariablesStore } from '@/stores/variables/store'
import { applyWorkflowVariablesToStore } from '@/stores/workflow-diff/utils'
describe('applyWorkflowVariablesToStore', () => {
beforeEach(() => {
useVariablesStore.setState({
variables: {},
isLoading: false,
error: null,
isEditing: null,
})
})
it('hydrates variables for the target workflow and preserves other workflows', () => {
useVariablesStore.setState({
variables: {
old: {
id: 'old',
workflowId: 'workflow-a',
name: 'oldValue',
type: 'plain',
value: 'stale',
},
other: {
id: 'other',
workflowId: 'workflow-b',
name: 'otherValue',
type: 'plain',
value: 'kept',
},
},
})
applyWorkflowVariablesToStore('workflow-a', {
next: {
id: 'next',
name: 'nextValue',
type: 'number',
value: 42,
},
})
expect(useVariablesStore.getState().variables).toEqual({
other: {
id: 'other',
workflowId: 'workflow-b',
name: 'otherValue',
type: 'plain',
value: 'kept',
},
next: {
id: 'next',
workflowId: 'workflow-a',
name: 'nextValue',
type: 'number',
value: 42,
},
})
})
it('preserves null variable values from persisted workflow state', () => {
applyWorkflowVariablesToStore('workflow-a', {
next: {
id: 'next',
name: 'nullableValue',
type: 'object',
value: null,
},
})
expect(useVariablesStore.getState().variables.next.value).toBeNull()
})
})
+191
View File
@@ -0,0 +1,191 @@
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
putWorkflowNormalizedStateContract,
type WorkflowStateContractInput,
} from '@/lib/api/contracts/workflows'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '../workflows/registry/store'
import { useSubBlockStore } from '../workflows/subblock/store'
import { mergeSubblockState } from '../workflows/utils'
import { useWorkflowStore } from '../workflows/workflow/store'
import type { WorkflowState } from '../workflows/workflow/types'
import type { WorkflowDiffState } from './types'
const logger = createLogger('WorkflowDiffStore')
export const WORKFLOW_DIFF_SETTLED_EVENT = 'workflow-diff-settled'
export function cloneWorkflowState(state: WorkflowState): WorkflowState {
return {
...state,
blocks: structuredClone(state.blocks || {}),
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
}
}
export function extractSubBlockValues(
workflowState: WorkflowState
): Record<string, Record<string, any>> {
const values: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
values[blockId][subBlockId] = subBlock?.value ?? null
})
})
return values
}
export function applyWorkflowStateToStores(
workflowId: string,
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) {
logger.debug('[applyWorkflowStateToStores] Applying state', {
workflowId,
blockCount: Object.keys(workflowState.blocks || {}).length,
edgeCount: workflowState.edges?.length ?? 0,
edgePreview: workflowState.edges?.slice(0, 3).map((e) => `${e.source} -> ${e.target}`),
})
const workflowStore = useWorkflowStore.getState()
const cloned = cloneWorkflowState(workflowState)
logger.debug('[applyWorkflowStateToStores] Cloned state edges', {
clonedEdgeCount: cloned.edges?.length ?? 0,
})
workflowStore.replaceWorkflowState(cloned, options)
const subBlockValues = extractSubBlockValues(workflowState)
useSubBlockStore.getState().setWorkflowValues(workflowId, subBlockValues)
if (Object.hasOwn(workflowState, 'variables')) {
applyWorkflowVariablesToStore(workflowId, workflowState.variables)
}
// Verify what's in the store after apply
const afterState = workflowStore.getWorkflowState()
logger.info('[applyWorkflowStateToStores] Applied workflow state to stores', {
workflowId,
afterEdgeCount: afterState.edges?.length ?? 0,
})
}
export function applyWorkflowVariablesToStore(
workflowId: string,
variables?: WorkflowState['variables'] | null
) {
const stampedVariables: Record<string, Variable> = {}
Object.entries(variables || {}).forEach(([id, variable]) => {
if (!variable?.name) return
stampedVariables[id] = {
id: variable.id || id,
workflowId,
name: variable.name,
type: variable.type || 'plain',
value: Object.hasOwn(variable, 'value') ? variable.value : '',
}
})
useVariablesStore.setState((state) => ({
variables: {
...Object.fromEntries(
Object.entries(state.variables).filter(([, variable]) => variable.workflowId !== workflowId)
),
...stampedVariables,
},
}))
}
export function captureBaselineSnapshot(workflowId: string): WorkflowState {
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(currentState.blocks, workflowId)
return {
...cloneWorkflowState(currentState),
blocks: structuredClone(mergedBlocks),
}
}
export async function persistWorkflowStateToServer(
workflowId: string,
workflowState: WorkflowState
): Promise<boolean> {
try {
const cleanState = stripWorkflowDiffMarkers(cloneWorkflowState(workflowState))
const { dragStartPosition: _dropDragStart, ...stateToSave } = cleanState
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
// Mirror auto-layout-utils sanitization: schema rejects nullable
// sourceHandle/targetHandle (input type is `string | undefined`), but the
// store's Edge type carries `string | null | undefined`. Drop nulls before
// sending so the contract input parses cleanly.
const sanitizedEdges: ContractEdgeInput[] = (stateToSave.edges || []).map((edge: Edge) => {
const { sourceHandle, targetHandle, ...rest } = edge
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
sanitized.sourceHandle = sourceHandle
}
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
sanitized.targetHandle = targetHandle
}
return sanitized
})
const cleanedWorkflowState: WorkflowStateContractInput = {
...stateToSave,
loops: stateToSave.loops || {},
parallels: stateToSave.parallels || {},
edges: sanitizedEdges,
lastSaved: Date.now(),
}
await requestJson(putWorkflowNormalizedStateContract, {
params: { id: workflowId },
body: cleanedWorkflowState,
})
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId === workflowId) {
useWorkflowStore.setState({ lastSaved: Date.now() })
}
return true
} catch (error) {
if (error instanceof ApiClientError) {
logger.error('Failed to persist workflow state after copilot edit', {
status: error.status,
message: error.message,
})
} else {
logger.error('Failed to persist workflow state after copilot edit', error)
}
return false
}
}
export async function getLatestUserMessageId(): Promise<string | null> {
return null
}
export function createBatchedUpdater(set: (updates: Partial<WorkflowDiffState>) => void) {
let updateTimer: NodeJS.Timeout | null = null
const UPDATE_DEBOUNCE_MS = 16
let pendingUpdates: Partial<WorkflowDiffState> = {}
return (updates: Partial<WorkflowDiffState>) => {
Object.assign(pendingUpdates, updates)
if (updateTimer) {
clearTimeout(updateTimer)
}
updateTimer = setTimeout(() => {
set(pendingUpdates)
pendingUpdates = {}
updateTimer = null
}, UPDATE_DEBOUNCE_MS)
}
}