@open-mercato/core 0.6.5-develop.4498.1.55dc06a57c → 0.6.5-develop.4534.1.b459babe6d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/step_instance/index.js +2 -0
  3. package/dist/generated/entities/step_instance/index.js.map +2 -2
  4. package/dist/generated/entities/user_task/index.js +2 -0
  5. package/dist/generated/entities/user_task/index.js.map +2 -2
  6. package/dist/generated/entities/workflow_branch_instance/index.js +39 -0
  7. package/dist/generated/entities/workflow_branch_instance/index.js.map +7 -0
  8. package/dist/generated/entities/workflow_event/index.js +2 -0
  9. package/dist/generated/entities/workflow_event/index.js.map +2 -2
  10. package/dist/generated/entities/workflow_instance/index.js +2 -0
  11. package/dist/generated/entities/workflow_instance/index.js.map +2 -2
  12. package/dist/generated/entities.ids.generated.js +1 -0
  13. package/dist/generated/entities.ids.generated.js.map +2 -2
  14. package/dist/generated/entity-fields-registry.js +24 -0
  15. package/dist/generated/entity-fields-registry.js.map +2 -2
  16. package/dist/helpers/integration/currenciesFixtures.js +51 -1
  17. package/dist/helpers/integration/currenciesFixtures.js.map +2 -2
  18. package/dist/modules/progress/api/jobs/[id]/route.js +7 -1
  19. package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
  20. package/dist/modules/shipping_carriers/api/cancel/route.js +2 -2
  21. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  22. package/dist/modules/shipping_carriers/lib/status-sync.js +8 -1
  23. package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
  24. package/dist/modules/workflows/components/NodeEditDialog.js +3 -1
  25. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  26. package/dist/modules/workflows/components/WorkflowGraphImpl.js +4 -2
  27. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  28. package/dist/modules/workflows/components/nodes/ParallelForkNode.js +49 -0
  29. package/dist/modules/workflows/components/nodes/ParallelForkNode.js.map +7 -0
  30. package/dist/modules/workflows/components/nodes/ParallelJoinNode.js +49 -0
  31. package/dist/modules/workflows/components/nodes/ParallelJoinNode.js.map +7 -0
  32. package/dist/modules/workflows/components/nodes/index.js +4 -0
  33. package/dist/modules/workflows/components/nodes/index.js.map +2 -2
  34. package/dist/modules/workflows/data/entities.js +81 -0
  35. package/dist/modules/workflows/data/entities.js.map +2 -2
  36. package/dist/modules/workflows/data/validators.js +146 -1
  37. package/dist/modules/workflows/data/validators.js.map +2 -2
  38. package/dist/modules/workflows/events.js +7 -1
  39. package/dist/modules/workflows/events.js.map +2 -2
  40. package/dist/modules/workflows/lib/activity-executor.js +4 -2
  41. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  42. package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
  43. package/dist/modules/workflows/lib/event-logger.js +2 -0
  44. package/dist/modules/workflows/lib/event-logger.js.map +2 -2
  45. package/dist/modules/workflows/lib/execution-token.js +98 -0
  46. package/dist/modules/workflows/lib/execution-token.js.map +7 -0
  47. package/dist/modules/workflows/lib/node-type-icons.js +14 -5
  48. package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
  49. package/dist/modules/workflows/lib/parallel-handler.js +364 -0
  50. package/dist/modules/workflows/lib/parallel-handler.js.map +7 -0
  51. package/dist/modules/workflows/lib/signal-handler.js +63 -1
  52. package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
  53. package/dist/modules/workflows/lib/step-handler.js +74 -30
  54. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  55. package/dist/modules/workflows/lib/task-handler.js +26 -0
  56. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  57. package/dist/modules/workflows/lib/timer-handler.js +26 -1
  58. package/dist/modules/workflows/lib/timer-handler.js.map +2 -2
  59. package/dist/modules/workflows/lib/transition-handler.js +33 -21
  60. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  61. package/dist/modules/workflows/lib/workflow-executor.js +39 -1
  62. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  63. package/dist/modules/workflows/migrations/Migration20260602120000.js +24 -0
  64. package/dist/modules/workflows/migrations/Migration20260602120000.js.map +7 -0
  65. package/dist/modules/workflows/workers/workflow-activities.worker.js +8 -4
  66. package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
  67. package/generated/entities/step_instance/index.ts +1 -0
  68. package/generated/entities/user_task/index.ts +1 -0
  69. package/generated/entities/workflow_branch_instance/index.ts +18 -0
  70. package/generated/entities/workflow_event/index.ts +1 -0
  71. package/generated/entities/workflow_instance/index.ts +1 -0
  72. package/generated/entities.ids.generated.ts +1 -0
  73. package/generated/entity-fields-registry.ts +24 -0
  74. package/package.json +7 -7
  75. package/src/helpers/integration/currenciesFixtures.ts +59 -0
  76. package/src/modules/progress/api/jobs/[id]/route.ts +7 -0
  77. package/src/modules/shipping_carriers/api/cancel/route.ts +2 -2
  78. package/src/modules/shipping_carriers/lib/status-sync.ts +19 -0
  79. package/src/modules/workflows/components/NodeEditDialog.tsx +2 -0
  80. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +3 -1
  81. package/src/modules/workflows/components/nodes/ParallelForkNode.tsx +66 -0
  82. package/src/modules/workflows/components/nodes/ParallelJoinNode.tsx +66 -0
  83. package/src/modules/workflows/components/nodes/index.ts +6 -0
  84. package/src/modules/workflows/data/entities.ts +109 -0
  85. package/src/modules/workflows/data/validators.ts +223 -0
  86. package/src/modules/workflows/events.ts +7 -0
  87. package/src/modules/workflows/i18n/de.json +12 -0
  88. package/src/modules/workflows/i18n/en.json +12 -0
  89. package/src/modules/workflows/i18n/es.json +12 -0
  90. package/src/modules/workflows/i18n/pl.json +12 -0
  91. package/src/modules/workflows/lib/activity-executor.ts +8 -2
  92. package/src/modules/workflows/lib/activity-queue-types.ts +3 -0
  93. package/src/modules/workflows/lib/event-logger.ts +3 -0
  94. package/src/modules/workflows/lib/execution-token.ts +166 -0
  95. package/src/modules/workflows/lib/node-type-icons.ts +11 -2
  96. package/src/modules/workflows/lib/parallel-handler.ts +575 -0
  97. package/src/modules/workflows/lib/signal-handler.ts +72 -1
  98. package/src/modules/workflows/lib/step-handler.ts +94 -34
  99. package/src/modules/workflows/lib/task-handler.ts +32 -0
  100. package/src/modules/workflows/lib/timer-handler.ts +30 -1
  101. package/src/modules/workflows/lib/transition-handler.ts +56 -24
  102. package/src/modules/workflows/lib/workflow-executor.ts +53 -1
  103. package/src/modules/workflows/migrations/.snapshot-open-mercato.json +263 -0
  104. package/src/modules/workflows/migrations/Migration20260602120000.ts +25 -0
  105. package/src/modules/workflows/workers/workflow-activities.worker.ts +9 -4
@@ -0,0 +1,575 @@
1
+ /**
2
+ * Workflows Module - Parallel Fork / Join Handler
3
+ *
4
+ * Implements PARALLEL_FORK / PARALLEL_JOIN execution with a multi-token model:
5
+ *
6
+ * - `openFork` turns a PARALLEL_FORK step into N persistent branch tokens
7
+ * (`WorkflowBranchInstance`), one per outgoing `auto` transition, and puts the
8
+ * instance into the dormant `FORKED` state.
9
+ * - `advanceBranches` runs the interleaved loop: while the instance is FORKED it
10
+ * advances each ACTIVE branch one step at a time (BPMN semantics — single lock,
11
+ * no thread-level concurrency). A branch pauses independently (USER_TASK /
12
+ * signal / timer / async activity) without blocking siblings.
13
+ * - When every branch has reached its JOIN (wait-all), `fireJoin` merges the
14
+ * branch namespaces back into `instance.context`, applies optional
15
+ * `outputMapping`, and resumes the root token at the step after the JOIN.
16
+ * - A failed branch cancels its siblings and fails the whole instance.
17
+ *
18
+ * All work happens under the executor's pessimistic instance lock + transaction,
19
+ * so wait-all counting and JOIN firing are race-free.
20
+ */
21
+
22
+ import { EntityManager, LockMode } from '@mikro-orm/core'
23
+ import type { AwilixContainer } from 'awilix'
24
+ import {
25
+ WorkflowInstance,
26
+ WorkflowBranchInstance,
27
+ WorkflowDefinition,
28
+ StepInstance,
29
+ UserTask,
30
+ WorkflowEvent,
31
+ } from '../data/entities'
32
+ import { logWorkflowEvent } from './event-logger'
33
+ import * as stepHandler from './step-handler'
34
+ import { branchToken } from './execution-token'
35
+
36
+ export interface AdvanceBranchesResult {
37
+ outcome: 'joined' | 'waiting' | 'failed'
38
+ error?: string
39
+ }
40
+
41
+ export interface ResumeBranchOptions {
42
+ instanceId: string
43
+ branchInstanceId: string
44
+ tenantId: string
45
+ organizationId: string
46
+ /** Values merged into the branch's private namespace before resuming (task form data, signal payload, …). */
47
+ contextMerge?: Record<string, any>
48
+ /** Step instance to exit (the paused step's instance) before the branch advances past it. */
49
+ exitStepInstanceId?: string | null
50
+ exitOutput?: any
51
+ }
52
+
53
+ /**
54
+ * Resume a single paused/waiting branch: optionally merge data into its
55
+ * namespace, exit its paused step instance, and mark it ACTIVE. The caller then
56
+ * re-enters `executeWorkflow` (FORKED mode) so the interleaved loop advances the
57
+ * branch and, if it is the last to arrive, fires the JOIN.
58
+ *
59
+ * Idempotent: a re-delivered signal/timer/task on an already-ACTIVE or terminal
60
+ * branch is a no-op. Returns false when there is nothing to resume.
61
+ */
62
+ export async function resumeBranch(
63
+ em: EntityManager,
64
+ options: ResumeBranchOptions,
65
+ ): Promise<boolean> {
66
+ const branch = await em.findOne(WorkflowBranchInstance, {
67
+ id: options.branchInstanceId,
68
+ workflowInstanceId: options.instanceId,
69
+ tenantId: options.tenantId,
70
+ organizationId: options.organizationId,
71
+ })
72
+
73
+ if (!branch) return false
74
+ if (branch.status !== 'PAUSED' && branch.status !== 'WAITING_FOR_ACTIVITIES') {
75
+ // Already active or terminal — re-delivery; nothing to do.
76
+ return false
77
+ }
78
+
79
+ const now = new Date()
80
+ if (options.contextMerge) {
81
+ branch.contextNamespace = { ...(branch.contextNamespace || {}), ...options.contextMerge }
82
+ }
83
+
84
+ if (options.exitStepInstanceId) {
85
+ const stepInstance = await em.findOne(StepInstance, {
86
+ id: options.exitStepInstanceId,
87
+ workflowInstanceId: options.instanceId,
88
+ status: 'ACTIVE',
89
+ })
90
+ if (stepInstance) {
91
+ await stepHandler.exitStep(em, stepInstance, options.exitOutput)
92
+ }
93
+ }
94
+
95
+ branch.status = 'ACTIVE'
96
+ branch.pendingTransition = null
97
+ branch.updatedAt = now
98
+ await em.flush()
99
+
100
+ return true
101
+ }
102
+
103
+ interface ParallelContext {
104
+ userId?: string
105
+ }
106
+
107
+ function getNestedValue(obj: any, path: string): any {
108
+ return path.split('.').reduce((current, key) => (current == null ? undefined : current[key]), obj)
109
+ }
110
+
111
+ /**
112
+ * Open a PARALLEL_FORK: create one ACTIVE branch per outgoing `auto` transition
113
+ * and mark the instance FORKED. Branch tokens start positioned ON the fork step
114
+ * so the interleaved loop runs each branch's fork transition (and its
115
+ * activities) in that branch's own context.
116
+ */
117
+ export async function openFork(
118
+ em: EntityManager,
119
+ instance: WorkflowInstance,
120
+ definition: WorkflowDefinition,
121
+ forkStepDef: any,
122
+ ): Promise<void> {
123
+ const forkStepId: string = forkStepDef.stepId
124
+ const joinStepId: string | undefined = forkStepDef.config?.joinStepId
125
+ if (!joinStepId) {
126
+ throw new Error(`[internal] PARALLEL_FORK "${forkStepId}" missing config.joinStepId`)
127
+ }
128
+
129
+ const outgoing = (definition.definition.transitions || []).filter(
130
+ (transition: any) => transition.fromStepId === forkStepId && transition.trigger === 'auto',
131
+ )
132
+
133
+ const now = new Date()
134
+ const branchKeys: string[] = []
135
+ for (const transition of outgoing) {
136
+ branchKeys.push(transition.transitionId)
137
+ const branch = em.create(WorkflowBranchInstance, {
138
+ workflowInstanceId: instance.id,
139
+ forkStepId,
140
+ joinStepId,
141
+ branchKey: transition.transitionId,
142
+ parentBranchId: null,
143
+ // Start ON the fork; advanceBranches runs the branch_key transition first.
144
+ currentStepId: forkStepId,
145
+ status: 'ACTIVE',
146
+ contextNamespace: {},
147
+ tenantId: instance.tenantId,
148
+ organizationId: instance.organizationId,
149
+ startedAt: now,
150
+ createdAt: now,
151
+ updatedAt: now,
152
+ })
153
+ em.persist(branch)
154
+ }
155
+
156
+ instance.status = 'FORKED'
157
+ instance.activeForkStepId = forkStepId
158
+ instance.updatedAt = now
159
+ await em.flush()
160
+
161
+ await logWorkflowEvent(em, {
162
+ workflowInstanceId: instance.id,
163
+ eventType: 'PARALLEL_FORK_OPENED',
164
+ eventData: { forkStepId, joinStepId, branchKeys },
165
+ tenantId: instance.tenantId,
166
+ organizationId: instance.organizationId,
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Resume a branch that was WAITING_FOR_ACTIVITIES after its async activities
172
+ * finished. Mirrors the instance-level resume but scoped to the branch:
173
+ * merges completed-activity outputs into the branch namespace, advances the
174
+ * branch's pending transition target, and marks it ACTIVE (or FAILED if any
175
+ * async activity failed). The caller re-enters the interleaved loop.
176
+ */
177
+ export async function resumeBranchAfterActivities(
178
+ em: EntityManager,
179
+ container: AwilixContainer,
180
+ instanceId: string,
181
+ branchInstanceId: string,
182
+ ): Promise<{ continueExecution: boolean }> {
183
+ const branch = await em.findOne(
184
+ WorkflowBranchInstance,
185
+ { id: branchInstanceId, workflowInstanceId: instanceId },
186
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
187
+ )
188
+ if (!branch) {
189
+ throw new Error('[internal] Branch not found during async resume')
190
+ }
191
+ if (branch.status !== 'WAITING_FOR_ACTIVITIES') {
192
+ // Re-delivery or already advanced — nothing to do.
193
+ return { continueExecution: false }
194
+ }
195
+
196
+ const namespace = branch.contextNamespace || {}
197
+ const pendingJobIds = (namespace._pendingAsyncActivities as any[]) || []
198
+
199
+ const completedEvents = await em.find(WorkflowEvent, {
200
+ workflowInstanceId: instanceId,
201
+ branchInstanceId,
202
+ eventType: 'ACTIVITY_COMPLETED',
203
+ eventData: { async: true },
204
+ })
205
+ const failedCount = await em.count(WorkflowEvent, {
206
+ workflowInstanceId: instanceId,
207
+ branchInstanceId,
208
+ eventType: 'ACTIVITY_FAILED',
209
+ eventData: { async: true },
210
+ })
211
+
212
+ if (completedEvents.length + failedCount < pendingJobIds.length) {
213
+ // Still waiting on other branch activities.
214
+ return { continueExecution: false }
215
+ }
216
+
217
+ const now = new Date()
218
+ if (failedCount > 0) {
219
+ branch.status = 'FAILED'
220
+ branch.errorMessage = `${failedCount} async activities failed in branch "${branch.branchKey}"`
221
+ branch.updatedAt = now
222
+ await em.flush()
223
+ // The interleaved loop will observe the FAILED branch and cancel siblings.
224
+ return { continueExecution: true }
225
+ }
226
+
227
+ const mergedNamespace: Record<string, any> = { ...namespace }
228
+ for (const event of completedEvents) {
229
+ if (event.eventData?.output) {
230
+ mergedNamespace[`${event.eventData.activityId}_result`] = event.eventData.output
231
+ }
232
+ }
233
+ delete mergedNamespace._pendingAsyncActivities
234
+ branch.contextNamespace = mergedNamespace
235
+
236
+ const pending = branch.pendingTransition
237
+ branch.pendingTransition = null
238
+ branch.status = 'ACTIVE'
239
+ branch.updatedAt = now
240
+ await em.flush()
241
+
242
+ // Execute the destination step in branch context (mirrors the instance-level
243
+ // resume) so the branch cursor lands on an executed step. The step may pause
244
+ // the branch again (e.g. a following USER_TASK); that is handled by the loop.
245
+ if (pending) {
246
+ const instance = await em.findOne(WorkflowInstance, { id: instanceId })
247
+ if (instance) {
248
+ branch.currentStepId = pending.toStepId
249
+ await em.flush()
250
+ await stepHandler.executeStep(
251
+ em,
252
+ instance,
253
+ pending.toStepId,
254
+ { workflowContext: { ...(instance.context || {}), ...(branch.contextNamespace || {}) } },
255
+ container,
256
+ branch,
257
+ )
258
+ }
259
+ }
260
+
261
+ return { continueExecution: true }
262
+ }
263
+
264
+ /**
265
+ * Interleaved branch execution loop. Advances ACTIVE branches one step at a
266
+ * time until either all branches reach the JOIN (→ fireJoin → 'joined'), a
267
+ * branch fails (→ cancel siblings + 'failed'), or no branch is ACTIVE because
268
+ * they are all waiting on external resume (→ 'waiting').
269
+ */
270
+ export async function advanceBranches(
271
+ em: EntityManager,
272
+ container: AwilixContainer,
273
+ instance: WorkflowInstance,
274
+ definition: WorkflowDefinition,
275
+ context: ParallelContext,
276
+ ): Promise<AdvanceBranchesResult> {
277
+ const forkStepId = instance.activeForkStepId
278
+ if (!forkStepId) {
279
+ throw new Error('[internal] advanceBranches called but instance has no active fork')
280
+ }
281
+
282
+ // Branch-aware iteration budget: each branch gets the same per-token budget as
283
+ // the single-token loop, so fan-out alone never trips the guard.
284
+ const allBranchesForBudget = await em.find(WorkflowBranchInstance, {
285
+ workflowInstanceId: instance.id,
286
+ forkStepId,
287
+ })
288
+ const maxIterations = Math.max(1, allBranchesForBudget.length) * 100
289
+ let iterations = 0
290
+
291
+ while (iterations < maxIterations) {
292
+ iterations++
293
+
294
+ const branches = await em.find(WorkflowBranchInstance, {
295
+ workflowInstanceId: instance.id,
296
+ forkStepId,
297
+ })
298
+
299
+ const active = branches.filter((b) => b.status === 'ACTIVE')
300
+
301
+ if (active.length === 0) {
302
+ const failed = branches.find((b) => b.status === 'FAILED')
303
+ if (failed) {
304
+ // A branch failed out-of-band (e.g. async-activity resume marked it
305
+ // FAILED); cancel any non-terminal siblings before failing the instance.
306
+ await cancelSiblings(em, instance, failed)
307
+ return { outcome: 'failed', error: failed.errorMessage || 'Branch failed' }
308
+ }
309
+ if (branches.every((b) => b.status === 'COMPLETED')) {
310
+ await fireJoin(em, instance, definition, branches, forkStepId)
311
+ return { outcome: 'joined' }
312
+ }
313
+ // Some branches paused/waiting for external resume — instance idles.
314
+ return { outcome: 'waiting' }
315
+ }
316
+
317
+ for (const branch of active) {
318
+ const result = await advanceOneBranch(em, container, instance, definition, branch, context)
319
+ if (result === 'failed') {
320
+ await cancelSiblings(em, instance, branch)
321
+ return { outcome: 'failed', error: branch.errorMessage || 'Branch failed' }
322
+ }
323
+ }
324
+ }
325
+
326
+ throw new Error('[internal] Maximum branch execution iterations reached - possible infinite loop')
327
+ }
328
+
329
+ /**
330
+ * Advance a single ACTIVE branch by one step. Returns 'failed' if the branch
331
+ * failed; otherwise mutates branch state (advanced / completed@join / paused).
332
+ */
333
+ async function advanceOneBranch(
334
+ em: EntityManager,
335
+ container: AwilixContainer,
336
+ instance: WorkflowInstance,
337
+ definition: WorkflowDefinition,
338
+ branch: WorkflowBranchInstance,
339
+ context: ParallelContext,
340
+ ): Promise<'advanced' | 'failed'> {
341
+ // Already at the join → synchronize (mark completed, do not execute the join).
342
+ if (branch.currentStepId === branch.joinStepId) {
343
+ await completeBranchAtJoin(em, instance, branch)
344
+ return 'advanced'
345
+ }
346
+
347
+ const transitionHandler = await import('./transition-handler')
348
+
349
+ const token = branchToken(instance, branch)
350
+ // Token read context = instance snapshot overlaid with branch namespace.
351
+ const readContext = { ...(instance.context || {}), ...(branch.contextNamespace || {}) }
352
+ const evalContext = { workflowContext: readContext, userId: context.userId }
353
+
354
+ // Select the transition to take. Branches positioned on the fork follow their
355
+ // own branch_key transition; otherwise pick the highest-priority valid auto one.
356
+ let selected: any | null = null
357
+ if (branch.currentStepId === branch.forkStepId) {
358
+ selected = (definition.definition.transitions || []).find(
359
+ (transition: any) => transition.transitionId === branch.branchKey,
360
+ ) || null
361
+ } else {
362
+ const valid = await transitionHandler.findValidTransitions(
363
+ em,
364
+ instance,
365
+ branch.currentStepId,
366
+ evalContext,
367
+ )
368
+ const validAuto = valid.filter((vt) => vt.isValid && vt.transition?.trigger === 'auto')
369
+ selected = validAuto.length > 0 ? validAuto[0].transition : null
370
+ }
371
+
372
+ if (!selected) {
373
+ // No outgoing transition and not at the join → the branch is stuck.
374
+ await failBranch(em, instance, branch, `Branch "${branch.branchKey}" has no valid transition from "${branch.currentStepId}"`)
375
+ return 'failed'
376
+ }
377
+
378
+ try {
379
+ const transitionResult = await transitionHandler.executeTransitionForToken(
380
+ em,
381
+ container,
382
+ token,
383
+ selected.fromStepId,
384
+ selected.toStepId,
385
+ evalContext,
386
+ )
387
+
388
+ if (!transitionResult.success) {
389
+ await failBranch(em, instance, branch, transitionResult.error || 'Branch transition failed')
390
+ return 'failed'
391
+ }
392
+
393
+ // executeTransitionForToken set branch.currentStepId = toStepId. If that is
394
+ // the join, synchronize now (the join step itself is a no-op).
395
+ if (branch.currentStepId === branch.joinStepId) {
396
+ await completeBranchAtJoin(em, instance, branch)
397
+ }
398
+ return 'advanced'
399
+ } catch (error) {
400
+ const message = error instanceof Error ? error.message : String(error)
401
+ await failBranch(em, instance, branch, message)
402
+ return 'failed'
403
+ }
404
+ }
405
+
406
+ async function completeBranchAtJoin(
407
+ em: EntityManager,
408
+ instance: WorkflowInstance,
409
+ branch: WorkflowBranchInstance,
410
+ ): Promise<void> {
411
+ const now = new Date()
412
+ branch.status = 'COMPLETED'
413
+ branch.completedAt = now
414
+ branch.updatedAt = now
415
+ await em.flush()
416
+
417
+ await logWorkflowEvent(em, {
418
+ workflowInstanceId: instance.id,
419
+ branchInstanceId: branch.id,
420
+ eventType: 'PARALLEL_BRANCH_COMPLETED',
421
+ eventData: { branchKey: branch.branchKey, joinStepId: branch.joinStepId },
422
+ tenantId: instance.tenantId,
423
+ organizationId: instance.organizationId,
424
+ })
425
+ }
426
+
427
+ async function failBranch(
428
+ em: EntityManager,
429
+ instance: WorkflowInstance,
430
+ branch: WorkflowBranchInstance,
431
+ message: string,
432
+ ): Promise<void> {
433
+ const now = new Date()
434
+ branch.status = 'FAILED'
435
+ branch.errorMessage = message
436
+ branch.updatedAt = now
437
+ await em.flush()
438
+
439
+ await logWorkflowEvent(em, {
440
+ workflowInstanceId: instance.id,
441
+ branchInstanceId: branch.id,
442
+ eventType: 'PARALLEL_BRANCH_FAILED',
443
+ eventData: { branchKey: branch.branchKey, error: message },
444
+ tenantId: instance.tenantId,
445
+ organizationId: instance.organizationId,
446
+ })
447
+ }
448
+
449
+ /**
450
+ * Cancel sibling branches of a failed branch (best-effort), cancel their open
451
+ * user tasks, and log a cancellation event per branch.
452
+ */
453
+ async function cancelSiblings(
454
+ em: EntityManager,
455
+ instance: WorkflowInstance,
456
+ failedBranch: WorkflowBranchInstance,
457
+ ): Promise<void> {
458
+ const siblings = await em.find(WorkflowBranchInstance, {
459
+ workflowInstanceId: instance.id,
460
+ forkStepId: failedBranch.forkStepId,
461
+ })
462
+
463
+ const now = new Date()
464
+ for (const sibling of siblings) {
465
+ if (sibling.id === failedBranch.id) continue
466
+ if (sibling.status === 'ACTIVE' || sibling.status === 'PAUSED' || sibling.status === 'WAITING_FOR_ACTIVITIES') {
467
+ sibling.status = 'CANCELLED'
468
+ sibling.updatedAt = now
469
+
470
+ // Best-effort: cancel the branch's open user tasks.
471
+ const openTasks = await em.find(UserTask, {
472
+ workflowInstanceId: instance.id,
473
+ branchInstanceId: sibling.id,
474
+ status: 'PENDING',
475
+ })
476
+ for (const task of openTasks) {
477
+ task.status = 'CANCELLED'
478
+ task.updatedAt = now
479
+ }
480
+
481
+ await em.flush()
482
+
483
+ await logWorkflowEvent(em, {
484
+ workflowInstanceId: instance.id,
485
+ branchInstanceId: sibling.id,
486
+ eventType: 'PARALLEL_BRANCH_CANCELLED',
487
+ eventData: { branchKey: sibling.branchKey, reason: 'sibling-failed' },
488
+ tenantId: instance.tenantId,
489
+ organizationId: instance.organizationId,
490
+ })
491
+ }
492
+ }
493
+
494
+ await logWorkflowEvent(em, {
495
+ workflowInstanceId: instance.id,
496
+ eventType: 'PARALLEL_FORK_FAILED',
497
+ eventData: { forkStepId: failedBranch.forkStepId, failedBranchKey: failedBranch.branchKey },
498
+ tenantId: instance.tenantId,
499
+ organizationId: instance.organizationId,
500
+ })
501
+ }
502
+
503
+ /**
504
+ * Fire the JOIN once all branches are COMPLETED: merge each branch namespace
505
+ * under `instance.context.branches[branchKey]`, apply optional outputMapping to
506
+ * lift selected values to the top level, then resume the root token at the step
507
+ * after the JOIN.
508
+ */
509
+ async function fireJoin(
510
+ em: EntityManager,
511
+ instance: WorkflowInstance,
512
+ definition: WorkflowDefinition,
513
+ branches: WorkflowBranchInstance[],
514
+ forkStepId: string,
515
+ ): Promise<void> {
516
+ const joinStepId = branches[0]?.joinStepId
517
+ if (!joinStepId) {
518
+ throw new Error('[internal] fireJoin called without a join step id')
519
+ }
520
+
521
+ const joinStep = definition.definition.steps.find((s: any) => s.stepId === joinStepId)
522
+
523
+ // Deterministic merge — no silent collisions: each branch keeps its own slot.
524
+ const branchesContext: Record<string, any> = { ...(instance.context?.branches || {}) }
525
+ const mergedBranchKeys: string[] = []
526
+ for (const branch of branches) {
527
+ branchesContext[branch.branchKey] = branch.contextNamespace || {}
528
+ mergedBranchKeys.push(branch.branchKey)
529
+ }
530
+
531
+ let nextContext: Record<string, any> = {
532
+ ...(instance.context || {}),
533
+ branches: branchesContext,
534
+ }
535
+
536
+ // Optional outputMapping: topLevelKey -> 'branches.<branchKey>.<path>'. The
537
+ // reserved `branches` slot map is never overwritten — guard it explicitly so a
538
+ // mapping cannot clobber the per-branch namespaces.
539
+ const outputMapping: Record<string, string> | undefined = joinStep?.config?.outputMapping
540
+ if (outputMapping) {
541
+ for (const [topKey, sourcePath] of Object.entries(outputMapping)) {
542
+ if (topKey === 'branches') continue
543
+ const value = getNestedValue(nextContext, sourcePath)
544
+ if (value !== undefined) nextContext[topKey] = value
545
+ }
546
+ }
547
+
548
+ // Park the root token ON the JOIN step (not its successor) and let the
549
+ // single-token executor loop run the JOIN's outgoing transition. Jumping
550
+ // straight to the post-join step would bypass stepHandler.executeStep() for
551
+ // that step — so a following USER_TASK / WAIT_FOR_TIMER / WAIT_FOR_SIGNAL would
552
+ // never have its task created / timer enqueued / signal wait registered (the
553
+ // instance would hang), and any activities on the JOIN's outgoing transition
554
+ // would be skipped. The JOIN step itself is a no-op, so the loop simply runs
555
+ // its outgoing transition through the normal step-entry path.
556
+ const afterJoinStepId = (definition.definition.transitions || []).find(
557
+ (transition: any) => transition.fromStepId === joinStepId,
558
+ )?.toStepId ?? null
559
+
560
+ const now = new Date()
561
+ instance.context = nextContext
562
+ instance.status = 'RUNNING'
563
+ instance.activeForkStepId = null
564
+ instance.currentStepId = joinStepId
565
+ instance.updatedAt = now
566
+ await em.flush()
567
+
568
+ await logWorkflowEvent(em, {
569
+ workflowInstanceId: instance.id,
570
+ eventType: 'PARALLEL_JOIN_COMPLETED',
571
+ eventData: { forkStepId, joinStepId, mergedBranchKeys, afterJoinStepId },
572
+ tenantId: instance.tenantId,
573
+ organizationId: instance.organizationId,
574
+ })
575
+ }
@@ -8,7 +8,7 @@ import { EntityManager } from '@mikro-orm/core'
8
8
  import type { EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'
9
9
  import type { AwilixContainer } from 'awilix'
10
10
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
- import { WorkflowInstance, WorkflowDefinition, StepInstance } from '../data/entities'
11
+ import { WorkflowInstance, WorkflowBranchInstance, WorkflowDefinition, StepInstance } from '../data/entities'
12
12
  import type * as eventLoggerModule from './event-logger'
13
13
  import type * as stepHandlerModule from './step-handler'
14
14
  import type * as transitionHandlerModule from './transition-handler'
@@ -89,6 +89,77 @@ export async function sendSignal(
89
89
  )
90
90
  }
91
91
 
92
+ // Branch-scoped signal: a FORKED instance routes the signal to the branch
93
+ // paused at a matching WAIT_FOR_SIGNAL step.
94
+ if (instance.status === 'FORKED') {
95
+ const branchDefinition = await findOneWithDecryption(
96
+ em as PostgreSqlEntityManager,
97
+ WorkflowDefinition,
98
+ { id: instance.definitionId, tenantId: instance.tenantId, organizationId: instance.organizationId, deletedAt: null },
99
+ undefined,
100
+ { tenantId: instance.tenantId, organizationId: instance.organizationId },
101
+ )
102
+ if (!branchDefinition) {
103
+ throw new SignalError('Workflow definition not found', 'DEFINITION_NOT_FOUND', { definitionId: instance.definitionId })
104
+ }
105
+
106
+ const pausedBranches = await em.find(WorkflowBranchInstance, {
107
+ workflowInstanceId: instanceId,
108
+ status: 'PAUSED',
109
+ tenantId,
110
+ organizationId,
111
+ })
112
+
113
+ let targetBranch: WorkflowBranchInstance | null = null
114
+ for (const candidate of pausedBranches) {
115
+ const step = branchDefinition.definition.steps.find((s: any) => s.stepId === candidate.currentStepId)
116
+ if (step?.stepType === 'WAIT_FOR_SIGNAL') {
117
+ const candidateSignal = step.signalConfig?.signalName || step.stepId
118
+ if (candidateSignal === signalName) {
119
+ targetBranch = candidate
120
+ break
121
+ }
122
+ }
123
+ }
124
+
125
+ if (!targetBranch) {
126
+ throw new SignalError('No parallel branch awaiting this signal', 'NO_BRANCH_AWAITING_SIGNAL', { instanceId, signalName })
127
+ }
128
+
129
+ const branchStepInstance = await em.findOne(StepInstance, {
130
+ workflowInstanceId: instanceId,
131
+ branchInstanceId: targetBranch.id,
132
+ stepId: targetBranch.currentStepId,
133
+ status: 'ACTIVE',
134
+ })
135
+
136
+ await eventLogger.logWorkflowEvent(em, {
137
+ workflowInstanceId: instanceId,
138
+ stepInstanceId: branchStepInstance?.id,
139
+ branchInstanceId: targetBranch.id,
140
+ eventType: 'SIGNAL_RECEIVED',
141
+ eventData: { signalName, branch: true },
142
+ userId,
143
+ tenantId,
144
+ organizationId,
145
+ })
146
+
147
+ const { resumeBranch } = await import('./parallel-handler')
148
+ const resumed = await resumeBranch(em, {
149
+ instanceId,
150
+ branchInstanceId: targetBranch.id,
151
+ tenantId,
152
+ organizationId,
153
+ contextMerge: payload,
154
+ exitStepInstanceId: branchStepInstance?.id ?? null,
155
+ exitOutput: { signalName, payload },
156
+ })
157
+ if (resumed) {
158
+ await workflowExecutor.executeWorkflow(em, container, instanceId, { userId })
159
+ }
160
+ return
161
+ }
162
+
92
163
  // Verify workflow is paused
93
164
  if (instance.status !== 'PAUSED') {
94
165
  throw new SignalError(