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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user