Workflow Draft Synchronization#
Workflow draft sync is the mechanism by which the canvas editor persists in-progress workflow state. It spans four layers: frontend debouncing, backend HTTP validation, agent binding reconciliation, and (optionally) real-time multi-user collaboration via CRDT. Understanding each layer is critical for debugging save hangs, "stuck in queue" preview failures, and silent publish errors.
Data Flow#
Database Model#
The workflows table stores draft and published rows with the same schema β draft rows carry version = "draft" while published rows carry a timestamp string as the version . Only one draft row exists per app; published rows are immutable snapshots. Both are fetched by app_id + tenant_id + version, and the draft is selected with .with_for_update() before every write to prevent concurrent overwrites .
Key columns:
graph(LongText) β JSON of all nodes and edgesfeatures(LongText) β app-level feature config_environment_variables(LongText) β secret-encrypted JSON keyed by variable nameunique_hashβ SHA-1 of{"graph": graph_dict}used as an optimistic lock token
Frontend: Debounce, Payload, and Guards#
Debounce β workflow-draft-slice.ts creates a 5-second debounced sync function via es-toolkit. Rapid edits coalesce; flushPendingSync() forces immediate dispatch .
Payload construction β use-nodes-sync-draft.ts (doSyncWorkflowDraft) :
- Strips internal
_-prefixed fields andselectedflags from nodes and edges. - Filters out
StartPlaceholderand unlinked temporary nodes. - Includes
syncWorkflowDraftHash; adraft_workflow_not_syncserver error triggers a full draft refresh. - In collaboration mode, non-leader sessions delegate to
collaborationManager.requestWorkflowSync()instead of calling the API directly.
Page-close guard β syncWorkflowDraftWhenPageClose fires a fetch keepalive on visibilitychange hidden / beforeunload. In collaboration mode this is gated by canFlushGraphOnPageClose() or canUseLocalDraftFallback() to prevent a stale snapshot from overwriting other collaborators' work .
App-deletion guard β shouldSkipDraftSync checks isAppDeletingOrDeleted(appId) and skips the HTTP call if the app is mid-deletion, suppressing 404 toasts while still calling the onSettled cleanup callback .
Backend: REST Handler and Validation Pipeline#
Entry point β DraftWorkflowApi.post parses a SyncDraftWorkflowPayload and delegates to WorkflowService.sync_draft_workflow .
Key SyncDraftWorkflowPayload fields :
| Field | Purpose |
|---|---|
graph | Workflow graph (nodes + edges) |
features | App feature config |
hash | Optimistic lock token |
environment_variable_patch | Per-ID upsert/delete (enables collaborative env var edits) |
preserve_environment_variables | Preserve env vars when no patch is sent |
_is_collaborative | Graph-only save; skips feature validation |
sync_draft_workflow execution order :
- Row lock β
with_for_update()before the hash check. - Hash check β raises
WorkflowHashNotEqualErrorβ HTTPdraft_workflow_not_syncifworkflow.unique_hash != unique_hash. - Feature/graph validation β
validate_features_structure()andvalidate_graph_structure()(skipped whengraph_only=True). - Upsert β creates or updates the
Workflowrow withversion="draft". - Env var merge β if
environment_variable_upsertsis provided, variables are merged by ID; otherwise legacy full-replace applies. - Agent binding sync β
sync_agent_bindings_for_draft()(see below). - Draft validation β
validate_agent_nodes_for_draft_sync()(lenient: missing bindings silently skipped). - Commit + event β
session.commit()thenapp_draft_workflow_was_synced.send(...).
Response: { result: "success", hash, updated_at } β the frontend stores hash as the new syncWorkflowDraftHash .
Agent V2 Binding Reconciliation#
Every draft save calls sync_agent_bindings_for_draft which iterates all Agent V2 nodes, deletes bindings for removed nodes, and upserts bindings for present nodes .
Validation strictness differs between draft and publish :
| Phase | Missing binding | Missing model |
|---|---|---|
validate_agent_nodes_for_draft_sync | Silently skipped | Allowed |
validate_agent_nodes_for_publish | Hard error (WorkflowAgentNodeValidationError) | Hard error |
This asymmetry causes the most common upgrade regression: workflows with Agent nodes created before the V2 binding requirement draft-save successfully but block at publish time . The error "Workflow Agent node {id} requires a binding before publishing" is raised at publish validation .
Workaround: open the node in the editor, re-select the agent binding, save the draft, then publish. If rebinding fails, delete and recreate the Agent node and reconnect its edges .
Collaborative Sync#
Gated by the enable_collaboration_mode system flag. The collaboration layer lives under web/app/components/workflow/collaboration/ .
CRDT model β CollaborationManager maintains a LoroDoc with nodesMap and edgesMap. Private _-prefixed keys and selected flags are excluded from CRDT sync to avoid dirtying the document with UI-only state .
Leader election β the server assigns one leader per session, preferring visible tabs (graph_active=True). When a tab hides, its requestAnimationFrame loop pauses and CRDT updates queue without applying. The frontend emits graph_view_state on every visibilitychange; the backend atomically demotes a hidden leader under a Redis lock. This was the root cause fixed by PR #38997 ("prevent hidden-tab collaboration leader from saving stale drafts") .
Draft persistence β only the elected leader persists to the DB. Follower save requests are routed to the leader via socketio.call() with a 5-second timeout .
Local fallback β self-hosted deployments without a WebSocket service activate localDraftFallbackActive when connection to the default socket URL fails, falling back to HTTP-only draft saves .
Known Failure Modes#
Celery Worker Backpressure (Editor Stuck in "Queued")#
Since v1.13, workflow execution is offloaded to Celery. When CELERY_WORKER_CONCURRENCY is too low (default: 1), the workflow_based_app_execution queue backs up and preview/test runs never emit SSE events . The draft sync API call itself returns 200; the hang is in execution, not persistence.
Fix: set CELERY_WORKER_CONCURRENCY=10 (or CELERY_AUTO_SCALE=true) and restart the worker.
Plugin Daemon Version Mismatch#
The plugin daemon must be versioned in lockstep with the Dify API . A version mismatch causes all plugin-related calls to fail; the daemon logs "failed to find the version of the plugin sdk". Syncing workflows that reference plugins will silently fail or hang.
Fix: ensure the daemon image tag matches the Dify release (e.g., daemon 0.6.10-local for Dify 1.15.0), then run docker compose exec api flask backfill-plugin-auto-upgrade .
Hash Mismatch / Stale Draft (draft_workflow_not_sync)#
If the client's syncWorkflowDraftHash doesn't match the server's stored hash (e.g., after a concurrent save from another tab), the API returns draft_workflow_not_sync. The client automatically fetches a fresh draft copy and resets its local hash .
Agent V2 Binding Error at Publish (Upgrade Regression)#
Workflows upgraded from pre-1.15.0 that contain Agent nodes without a WorkflowAgentNodeBinding record will save drafts successfully but fail at publish with WorkflowAgentNodeValidationError . See the Agent V2 Binding Reconciliation section above for the workaround.
Key Source Files#
| File | Role |
|---|---|
web/β¦/workflow-draft-slice.ts | Zustand slice: 5-second debounce, hash management, flushPendingSync |
web/β¦/use-nodes-sync-draft.ts | Hook: payload construction, HTTP call, collaboration routing, page-close guard |
api/controllers/console/app/workflow.py | DraftWorkflowApi.post β REST handler, SyncDraftWorkflowPayload schema |
api/services/workflow_service.py | sync_draft_workflow β core save + validation pipeline |
api/services/agent/workflow_publish_service.py | Agent binding reconciliation and publish validation |
api/models/workflow.py | Workflow ORM model: schema, unique_hash, env var encryption |
web/β¦/collaboration/core/collaboration-manager.ts | Loro CRDT orchestrator, leader election, undo/redo |
web/β¦/collaboration/core/crdt-provider.ts | CRDT β WebSocket bridge |