@@ -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,50 @@ 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+ function storedGraphFingerprint ( graph : Record < string , unknown > ) : string {
231+ const edges = [ ...( asGraph ( graph ) . edges ?? [ ] ) ] . sort ( ( a , b ) => compareStrings ( a . id , b . id ) )
232+ return JSON . stringify ( sortObjectKeysDeep ( { ...graph , edges } ) )
233+ }
234+
235+ interface BaseGraph {
236+ graph : Record < string , unknown >
237+ /** Set when the base was read from storage, so the write can prove it is still current. */
238+ storedFingerprint ?: string
239+ }
240+
219241async function resolveBaseGraph (
220242 principal : Principal ,
221243 input : ApplyWorkflowOperationsInput ,
222244 context : ActiveWorkflowApplicationContext
223- ) : Promise < Record < string , unknown > > {
224- if ( input . baseGraph && principal . kind === 'delegated' ) return input . baseGraph
225- return loadStoredGraph ( context . workflowId )
245+ ) : Promise < BaseGraph > {
246+ if ( input . baseGraph && principal . kind === 'delegated' ) return { graph : input . baseGraph }
247+ const graph = await loadStoredGraph ( context . workflowId )
248+ return { graph, storedFingerprint : storedGraphFingerprint ( graph ) }
249+ }
250+
251+ /**
252+ * Refuses the write when the stored graph is no longer the one the batch was
253+ * applied to. Runs under the workflow row lock, so a concurrent edit that
254+ * committed after the base read surfaces as a conflict instead of being
255+ * silently overwritten by a graph that never saw it.
256+ */
257+ async function assertStoredGraphUnchanged (
258+ workflowId : string ,
259+ storedFingerprint : string ,
260+ tx : DbOrTx
261+ ) : Promise < void > {
262+ const current = await loadStoredGraph ( workflowId , tx )
263+ if ( storedGraphFingerprint ( current ) !== storedFingerprint ) {
264+ throw new OrchestrationError (
265+ 'conflict' ,
266+ 'The workflow changed while these operations were being applied. Re-read it and retry.'
267+ )
268+ }
226269}
227270
228271/**
@@ -233,6 +276,8 @@ async function resolveBaseGraph(
233276 * skipped items, and exactly one write of the fully-resolved graph happens at the
234277 * end, through the shared persistence primitive. A caller that needs all-or-nothing
235278 * sets `atomic`, which decides between the in-memory apply and that single write.
279+ * When the batch was applied to the stored graph, that write is refused as a
280+ * `conflict` if another edit committed in between, rather than overwriting it.
236281 *
237282 * Copilot's `edit_workflow` tool and `POST /api/v2/workflows/{workflowId}/operations` are
238283 * both adapters over this; the tool is the only caller allowed to supply
@@ -261,7 +306,11 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
261306 const subjectUserId = attribution . attributedUserId
262307
263308 input . checkAborted ?.( )
264- const baseGraph = await resolveBaseGraph ( principal , input , context )
309+ const { graph : baseGraph , storedFingerprint } = await resolveBaseGraph (
310+ principal ,
311+ input ,
312+ context
313+ )
265314
266315 const [ permissionConfig , blockVisibility ] = await Promise . all ( [
267316 resolvePermissionGroupConfig (
@@ -432,6 +481,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
432481 }
433482 }
434483
484+ const nextState = { blocks : graph . blocks , edges : graph . edges }
435485 const persisted = await replaceWorkflowNormalizedState ( {
436486 workflowId : context . workflowId ,
437487 workspaceId : context . workspaceId ,
@@ -442,7 +492,18 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({
442492 * governed subject are the same human and cannot diverge here.
443493 */
444494 subjectUserId,
445- state : { blocks : graph . blocks , edges : graph . edges } ,
495+ /**
496+ * Applying the batch reaches credential lookups, MCP server discovery,
497+ * and lint queries, so it ran outside the lock; only the staleness check
498+ * runs under it.
499+ */
500+ state :
501+ storedFingerprint === undefined
502+ ? nextState
503+ : async ( tx ) => {
504+ await assertStoredGraphUnchanged ( context . workflowId , storedFingerprint , tx )
505+ return nextState
506+ } ,
446507 } )
447508
448509 logger . info ( 'Applied workflow operations' , {
0 commit comments