@open-mercato/core 0.6.5-develop.4516.1.88e6ab71a9 → 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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/step_instance/index.js +2 -0
- package/dist/generated/entities/step_instance/index.js.map +2 -2
- package/dist/generated/entities/user_task/index.js +2 -0
- package/dist/generated/entities/user_task/index.js.map +2 -2
- package/dist/generated/entities/workflow_branch_instance/index.js +39 -0
- package/dist/generated/entities/workflow_branch_instance/index.js.map +7 -0
- package/dist/generated/entities/workflow_event/index.js +2 -0
- package/dist/generated/entities/workflow_event/index.js.map +2 -2
- package/dist/generated/entities/workflow_instance/index.js +2 -0
- package/dist/generated/entities/workflow_instance/index.js.map +2 -2
- package/dist/generated/entities.ids.generated.js +1 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +24 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/progress/api/jobs/[id]/route.js +7 -1
- package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
- package/dist/modules/shipping_carriers/api/cancel/route.js +2 -2
- package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/status-sync.js +8 -1
- package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +3 -1
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js +4 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
- package/dist/modules/workflows/components/nodes/ParallelForkNode.js +49 -0
- package/dist/modules/workflows/components/nodes/ParallelForkNode.js.map +7 -0
- package/dist/modules/workflows/components/nodes/ParallelJoinNode.js +49 -0
- package/dist/modules/workflows/components/nodes/ParallelJoinNode.js.map +7 -0
- package/dist/modules/workflows/components/nodes/index.js +4 -0
- package/dist/modules/workflows/components/nodes/index.js.map +2 -2
- package/dist/modules/workflows/data/entities.js +81 -0
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/data/validators.js +146 -1
- package/dist/modules/workflows/data/validators.js.map +2 -2
- package/dist/modules/workflows/events.js +7 -1
- package/dist/modules/workflows/events.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +4 -2
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
- package/dist/modules/workflows/lib/event-logger.js +2 -0
- package/dist/modules/workflows/lib/event-logger.js.map +2 -2
- package/dist/modules/workflows/lib/execution-token.js +98 -0
- package/dist/modules/workflows/lib/execution-token.js.map +7 -0
- package/dist/modules/workflows/lib/node-type-icons.js +14 -5
- package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
- package/dist/modules/workflows/lib/parallel-handler.js +364 -0
- package/dist/modules/workflows/lib/parallel-handler.js.map +7 -0
- package/dist/modules/workflows/lib/signal-handler.js +63 -1
- package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +74 -30
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-handler.js +26 -0
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/timer-handler.js +26 -1
- package/dist/modules/workflows/lib/timer-handler.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +33 -21
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +39 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/migrations/Migration20260602120000.js +24 -0
- package/dist/modules/workflows/migrations/Migration20260602120000.js.map +7 -0
- package/dist/modules/workflows/workers/workflow-activities.worker.js +8 -4
- package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
- package/generated/entities/step_instance/index.ts +1 -0
- package/generated/entities/user_task/index.ts +1 -0
- package/generated/entities/workflow_branch_instance/index.ts +18 -0
- package/generated/entities/workflow_event/index.ts +1 -0
- package/generated/entities/workflow_instance/index.ts +1 -0
- package/generated/entities.ids.generated.ts +1 -0
- package/generated/entity-fields-registry.ts +24 -0
- package/package.json +7 -7
- package/src/modules/progress/api/jobs/[id]/route.ts +7 -0
- package/src/modules/shipping_carriers/api/cancel/route.ts +2 -2
- package/src/modules/shipping_carriers/lib/status-sync.ts +19 -0
- package/src/modules/workflows/components/NodeEditDialog.tsx +2 -0
- package/src/modules/workflows/components/WorkflowGraphImpl.tsx +3 -1
- package/src/modules/workflows/components/nodes/ParallelForkNode.tsx +66 -0
- package/src/modules/workflows/components/nodes/ParallelJoinNode.tsx +66 -0
- package/src/modules/workflows/components/nodes/index.ts +6 -0
- package/src/modules/workflows/data/entities.ts +109 -0
- package/src/modules/workflows/data/validators.ts +223 -0
- package/src/modules/workflows/events.ts +7 -0
- package/src/modules/workflows/i18n/de.json +12 -0
- package/src/modules/workflows/i18n/en.json +12 -0
- package/src/modules/workflows/i18n/es.json +12 -0
- package/src/modules/workflows/i18n/pl.json +12 -0
- package/src/modules/workflows/lib/activity-executor.ts +8 -2
- package/src/modules/workflows/lib/activity-queue-types.ts +3 -0
- package/src/modules/workflows/lib/event-logger.ts +3 -0
- package/src/modules/workflows/lib/execution-token.ts +166 -0
- package/src/modules/workflows/lib/node-type-icons.ts +11 -2
- package/src/modules/workflows/lib/parallel-handler.ts +575 -0
- package/src/modules/workflows/lib/signal-handler.ts +72 -1
- package/src/modules/workflows/lib/step-handler.ts +94 -34
- package/src/modules/workflows/lib/task-handler.ts +32 -0
- package/src/modules/workflows/lib/timer-handler.ts +30 -1
- package/src/modules/workflows/lib/transition-handler.ts +56 -24
- package/src/modules/workflows/lib/workflow-executor.ts +53 -1
- package/src/modules/workflows/migrations/.snapshot-open-mercato.json +263 -0
- package/src/modules/workflows/migrations/Migration20260602120000.ts +25 -0
- 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(
|