@@ -3,6 +3,8 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal
33import { db } from '@sim/db'
44import { createLogger } from '@sim/logger'
55import { toError } from '@sim/utils/errors'
6+ import { sortObjectKeysDeep } from '@sim/utils/object'
7+ import { compareStrings } from '@sim/utils/string'
68import {
79 generateLoopBlocks ,
810 generateParallelBlocks ,
@@ -12,6 +14,7 @@ import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
1214import { ForbiddenOperationError , principalAuditSource } from '@/lib/core/application'
1315import { getBlockVisibility } from '@/lib/core/config/block-visibility'
1416import { OrchestrationError } from '@/lib/core/orchestration/types'
17+ import type { DbOrTx } from '@/lib/db/types'
1518import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement'
1619import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server'
1720import { notifyWorkflowUpdated } from '@/lib/realtime/notify'
@@ -147,8 +150,11 @@ function asGraph(value: Record<string, unknown>): Pick<WorkflowState, 'blocks' |
147150 * graph. A null result therefore remains a hard failure rather than becoming a
148151 * fallback graph that a write could persist after a failed read.
149152 */
150- async function loadStoredGraph ( workflowId : string ) : Promise < Record < string , unknown > > {
151- const normalized = await loadWorkflowFromNormalizedTables ( workflowId )
153+ async function loadStoredGraph (
154+ workflowId : string ,
155+ executor ?: DbOrTx
156+ ) : Promise < Record < string , unknown > > {
157+ const normalized = await loadWorkflowFromNormalizedTables ( workflowId , executor )
152158 if ( ! normalized ) {
153159 throw new OrchestrationError ( 'validation' , `Workflow ${ workflowId } has no normalized state` )
154160 }
@@ -216,13 +222,54 @@ function applyBlockEnabledChanges(
216222 return { blocks : current , applied }
217223}
218224
225+ /**
226+ * An order-insensitive identity for a stored graph, so two reads of the same
227+ * rows compare equal even though the loader neither orders its rows nor fixes
228+ * the key order of the records it builds.
229+ *
230+ * Only blocks and edges are compared: loops and parallels are rebuilt from the
231+ * blocks on every load, with child `nodes` in row order.
232+ */
233+ function storedGraphFingerprint ( graph : Record < string , unknown > ) : string {
234+ const { blocks, edges } = asGraph ( graph )
235+ const sortedEdges = [ ...( edges ?? [ ] ) ] . sort ( ( a , b ) => compareStrings ( a . id , b . id ) )
236+ return JSON . stringify ( sortObjectKeysDeep ( { blocks, edges : sortedEdges } ) )
237+ }
238+
239+ interface BaseGraph {
240+ graph : Record < string , unknown >
241+ /** Set when the base was read from storage, so the write can prove it is still current. */
242+ storedFingerprint ?: string
243+ }
244+
219245async function resolveBaseGraph (
220246 principal : Principal ,
221247 input : ApplyWorkflowOperationsInput ,
222248 context : ActiveWorkflowApplicationContext
223- ) : Promise < Record < string , unknown > > {
224- if ( input . baseGraph && principal . kind === 'delegated' ) return input . baseGraph
225- return loadStoredGraph ( context . workflowId )
249+ ) : Promise < BaseGraph > {
250+ if ( input . baseGraph && principal . kind === 'delegated' ) return { graph : input . baseGraph }
251+ const graph = await loadStoredGraph ( context . workflowId )
252+ return { graph, storedFingerprint : storedGraphFingerprint ( graph ) }
253+ }
254+
255+ /**
256+ * Refuses the write when the stored graph is no longer the one the batch was
257+ * applied to. Runs under the workflow row lock, so a concurrent edit that
258+ * committed after the base read surfaces as a conflict instead of being
259+ * silently overwritten by a graph that never saw it.
260+ */
261+ async function assertStoredGraphUnchanged (
262+ workflowId : string ,
263+ storedFingerprint : string ,
264+ tx : DbOrTx
265+ ) : Promise < void > {
266+ const current = await loadStoredGraph ( workflowId , tx )
267+ if ( storedGraphFingerprint ( current ) !== storedFingerprint ) {
268+ throw new OrchestrationError (
269+ 'conflict' ,
270+ 'The workflow changed while these operations were being applied. Re-read it and retry.'
271+ )
272+ }
226273}
227274
228275/**
@@ -233,6 +280,8 @@ async function resolveBaseGraph(
233280 * skipped items, and exactly one write of the fully-resolved graph happens at the
234281 * end, through the shared persistence primitive. A caller that needs all-or-nothing
235282 * sets `atomic`, which decides between the in-memory apply and that single write.
283+ * When the batch was applied to the stored graph, that write is refused as a
284+ * `conflict` if another edit committed in between, rather than overwriting it.
236285 *
237286 * Copilot's `edit_workflow` tool and `POST /api/v2/workflows/{workflowId}/operations` are
238287 * both adapters over this; the tool is the only caller allowed to supply
@@ -261,7 +310,11 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
261310 const subjectUserId = attribution . attributedUserId
262311
263312 input . checkAborted ?.( )
264- const baseGraph = await resolveBaseGraph ( principal , input , context )
313+ const { graph : baseGraph , storedFingerprint } = await resolveBaseGraph (
314+ principal ,
315+ input ,
316+ context
317+ )
265318
266319 const [ permissionConfig , blockVisibility ] = await Promise . all ( [
267320 resolvePermissionGroupConfig (
@@ -432,6 +485,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
432485 }
433486 }
434487
488+ const nextState = { blocks : graph . blocks , edges : graph . edges }
435489 const persisted = await replaceWorkflowNormalizedState ( {
436490 workflowId : context . workflowId ,
437491 workspaceId : context . workspaceId ,
@@ -442,7 +496,18 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
442496 * governed subject are the same human and cannot diverge here.
443497 */
444498 subjectUserId,
445- state : { blocks : graph . blocks , edges : graph . edges } ,
499+ /**
500+ * Applying the batch reaches credential lookups, MCP server discovery,
501+ * and lint queries, so it ran outside the lock; only the staleness check
502+ * runs under it.
503+ */
504+ state :
505+ storedFingerprint === undefined
506+ ? nextState
507+ : async ( tx ) => {
508+ await assertStoredGraphUnchanged ( context . workflowId , storedFingerprint , tx )
509+ return nextState
510+ } ,
446511 } )
447512
448513 logger . info ( 'Applied workflow operations' , {
0 commit comments