@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.
Files changed (102) 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/modules/progress/api/jobs/[id]/route.js +7 -1
  17. package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
  18. package/dist/modules/shipping_carriers/api/cancel/route.js +2 -2
  19. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  20. package/dist/modules/shipping_carriers/lib/status-sync.js +8 -1
  21. package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
  22. package/dist/modules/workflows/components/NodeEditDialog.js +3 -1
  23. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  24. package/dist/modules/workflows/components/WorkflowGraphImpl.js +4 -2
  25. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  26. package/dist/modules/workflows/components/nodes/ParallelForkNode.js +49 -0
  27. package/dist/modules/workflows/components/nodes/ParallelForkNode.js.map +7 -0
  28. package/dist/modules/workflows/components/nodes/ParallelJoinNode.js +49 -0
  29. package/dist/modules/workflows/components/nodes/ParallelJoinNode.js.map +7 -0
  30. package/dist/modules/workflows/components/nodes/index.js +4 -0
  31. package/dist/modules/workflows/components/nodes/index.js.map +2 -2
  32. package/dist/modules/workflows/data/entities.js +81 -0
  33. package/dist/modules/workflows/data/entities.js.map +2 -2
  34. package/dist/modules/workflows/data/validators.js +146 -1
  35. package/dist/modules/workflows/data/validators.js.map +2 -2
  36. package/dist/modules/workflows/events.js +7 -1
  37. package/dist/modules/workflows/events.js.map +2 -2
  38. package/dist/modules/workflows/lib/activity-executor.js +4 -2
  39. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  40. package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
  41. package/dist/modules/workflows/lib/event-logger.js +2 -0
  42. package/dist/modules/workflows/lib/event-logger.js.map +2 -2
  43. package/dist/modules/workflows/lib/execution-token.js +98 -0
  44. package/dist/modules/workflows/lib/execution-token.js.map +7 -0
  45. package/dist/modules/workflows/lib/node-type-icons.js +14 -5
  46. package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
  47. package/dist/modules/workflows/lib/parallel-handler.js +364 -0
  48. package/dist/modules/workflows/lib/parallel-handler.js.map +7 -0
  49. package/dist/modules/workflows/lib/signal-handler.js +63 -1
  50. package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
  51. package/dist/modules/workflows/lib/step-handler.js +74 -30
  52. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  53. package/dist/modules/workflows/lib/task-handler.js +26 -0
  54. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  55. package/dist/modules/workflows/lib/timer-handler.js +26 -1
  56. package/dist/modules/workflows/lib/timer-handler.js.map +2 -2
  57. package/dist/modules/workflows/lib/transition-handler.js +33 -21
  58. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  59. package/dist/modules/workflows/lib/workflow-executor.js +39 -1
  60. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  61. package/dist/modules/workflows/migrations/Migration20260602120000.js +24 -0
  62. package/dist/modules/workflows/migrations/Migration20260602120000.js.map +7 -0
  63. package/dist/modules/workflows/workers/workflow-activities.worker.js +8 -4
  64. package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
  65. package/generated/entities/step_instance/index.ts +1 -0
  66. package/generated/entities/user_task/index.ts +1 -0
  67. package/generated/entities/workflow_branch_instance/index.ts +18 -0
  68. package/generated/entities/workflow_event/index.ts +1 -0
  69. package/generated/entities/workflow_instance/index.ts +1 -0
  70. package/generated/entities.ids.generated.ts +1 -0
  71. package/generated/entity-fields-registry.ts +24 -0
  72. package/package.json +7 -7
  73. package/src/modules/progress/api/jobs/[id]/route.ts +7 -0
  74. package/src/modules/shipping_carriers/api/cancel/route.ts +2 -2
  75. package/src/modules/shipping_carriers/lib/status-sync.ts +19 -0
  76. package/src/modules/workflows/components/NodeEditDialog.tsx +2 -0
  77. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +3 -1
  78. package/src/modules/workflows/components/nodes/ParallelForkNode.tsx +66 -0
  79. package/src/modules/workflows/components/nodes/ParallelJoinNode.tsx +66 -0
  80. package/src/modules/workflows/components/nodes/index.ts +6 -0
  81. package/src/modules/workflows/data/entities.ts +109 -0
  82. package/src/modules/workflows/data/validators.ts +223 -0
  83. package/src/modules/workflows/events.ts +7 -0
  84. package/src/modules/workflows/i18n/de.json +12 -0
  85. package/src/modules/workflows/i18n/en.json +12 -0
  86. package/src/modules/workflows/i18n/es.json +12 -0
  87. package/src/modules/workflows/i18n/pl.json +12 -0
  88. package/src/modules/workflows/lib/activity-executor.ts +8 -2
  89. package/src/modules/workflows/lib/activity-queue-types.ts +3 -0
  90. package/src/modules/workflows/lib/event-logger.ts +3 -0
  91. package/src/modules/workflows/lib/execution-token.ts +166 -0
  92. package/src/modules/workflows/lib/node-type-icons.ts +11 -2
  93. package/src/modules/workflows/lib/parallel-handler.ts +575 -0
  94. package/src/modules/workflows/lib/signal-handler.ts +72 -1
  95. package/src/modules/workflows/lib/step-handler.ts +94 -34
  96. package/src/modules/workflows/lib/task-handler.ts +32 -0
  97. package/src/modules/workflows/lib/timer-handler.ts +30 -1
  98. package/src/modules/workflows/lib/transition-handler.ts +56 -24
  99. package/src/modules/workflows/lib/workflow-executor.ts +53 -1
  100. package/src/modules/workflows/migrations/.snapshot-open-mercato.json +263 -0
  101. package/src/modules/workflows/migrations/Migration20260602120000.ts +25 -0
  102. package/src/modules/workflows/workers/workflow-activities.worker.ts +9 -4
@@ -2,13 +2,32 @@ import type { UnifiedShipmentStatus } from './adapter'
2
2
  import type { CarrierShipment } from '../data/entities'
3
3
  import type { ShippingEventId } from '../events'
4
4
 
5
+ // Use Symbol.for so the marker survives module duplication across bundle
6
+ // boundaries — production builds can split this class into separate chunks,
7
+ // which breaks `instanceof` (see isCrudHttpError in
8
+ // @open-mercato/shared/lib/crud/errors for the same pattern).
9
+ const SHIPMENT_CANCEL_NOT_ALLOWED_MARKER = Symbol.for('@open-mercato/shipping_carriers/ShipmentCancelNotAllowedError')
10
+
5
11
  export class ShipmentCancelNotAllowedError extends Error {
12
+ readonly [SHIPMENT_CANCEL_NOT_ALLOWED_MARKER] = true
13
+
6
14
  constructor(status: string) {
7
15
  super(`Shipment cannot be cancelled in its current status: ${status}`)
8
16
  this.name = 'ShipmentCancelNotAllowedError'
9
17
  }
10
18
  }
11
19
 
20
+ /**
21
+ * Type-safe check that works across module/bundle boundaries. Prefer this over
22
+ * `instanceof ShipmentCancelNotAllowedError` in route handlers, where the thrown
23
+ * error may originate from a different bundle than the one performing the check.
24
+ */
25
+ export function isShipmentCancelNotAllowedError(error: unknown): error is ShipmentCancelNotAllowedError {
26
+ return !!error
27
+ && typeof error === 'object'
28
+ && (error as Record<symbol, unknown>)[SHIPMENT_CANCEL_NOT_ALLOWED_MARKER] === true
29
+ }
30
+
12
31
  const VALID_SHIPPING_TRANSITIONS: Record<string, UnifiedShipmentStatus[]> = {
13
32
  label_created: ['picked_up', 'in_transit', 'cancelled'],
14
33
  picked_up: ['in_transit', 'cancelled'],
@@ -499,6 +499,8 @@ export function NodeEditDialog({ node, isOpen, onClose, onSave, onDelete }: Node
499
499
  waitForSignal: t('workflows.nodeTypes.waitForSignal'),
500
500
  waitForTimer: t('workflows.nodeTypes.waitForTimer'),
501
501
  subWorkflow: t('workflows.nodeTypes.subWorkflow'),
502
+ parallelFork: t('workflows.nodeTypes.parallelFork'),
503
+ parallelJoin: t('workflows.nodeTypes.parallelJoin'),
502
504
  }[node.type || 'automated']
503
505
 
504
506
  // START nodes are partially editable (pre-conditions only), END nodes are not editable
@@ -19,7 +19,7 @@ import {
19
19
  ConnectionMode,
20
20
  MarkerType,
21
21
  } from '@xyflow/react'
22
- import {StartNode, EndNode, UserTaskNode, AutomatedNode, SubWorkflowNode, WaitForSignalNode, WaitForTimerNode} from './nodes'
22
+ import {StartNode, EndNode, UserTaskNode, AutomatedNode, SubWorkflowNode, WaitForSignalNode, WaitForTimerNode, ParallelForkNode, ParallelJoinNode} from './nodes'
23
23
  import { WorkflowTransitionEdge } from './WorkflowTransitionEdge'
24
24
  import { STATUS_COLORS } from '../lib/status-colors'
25
25
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
@@ -133,6 +133,8 @@ export default function WorkflowGraphImpl({
133
133
  subWorkflow: SubWorkflowNode,
134
134
  waitForSignal: WaitForSignalNode,
135
135
  waitForTimer: WaitForTimerNode,
136
+ parallelFork: ParallelForkNode,
137
+ parallelJoin: ParallelJoinNode,
136
138
  }),
137
139
  []
138
140
  )
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { Handle, Position, NodeProps } from '@xyflow/react'
4
+ import { WorkflowNodeCard } from '../WorkflowNodeCard'
5
+ import { WorkflowStatus } from '../../lib/status-colors'
6
+
7
+ /**
8
+ * ParallelForkNode display data.
9
+ *
10
+ * A PARALLEL_FORK splits execution into N branches (one per outgoing `auto`
11
+ * transition) that run concurrently and converge at the paired PARALLEL_JOIN
12
+ * referenced by `joinStepId`.
13
+ */
14
+ export interface ParallelForkNodeData {
15
+ label: string
16
+ description?: string
17
+ joinStepId?: string
18
+ status?: 'pending' | 'running' | 'completed' | 'error' | 'not_started' | 'in_progress'
19
+ stepNumber?: number
20
+ badge?: string
21
+ tooltip?: string
22
+ executionStatus?: 'completed' | 'active' | 'pending' | 'failed' | 'skipped'
23
+ }
24
+
25
+ function mapStatus(status?: string): WorkflowStatus {
26
+ if (!status || status === 'pending') return 'not_started'
27
+ if (status === 'running' || status === 'in_progress') return 'in_progress'
28
+ if (status === 'completed') return 'completed'
29
+ return 'not_started'
30
+ }
31
+
32
+ /**
33
+ * ParallelForkNode - splits the workflow into concurrent branches.
34
+ * One target handle (in); one source handle (out) that fans out to each branch.
35
+ */
36
+ export function ParallelForkNode({ data, isConnectable, selected }: NodeProps) {
37
+ const nodeData = data as unknown as ParallelForkNodeData
38
+
39
+ return (
40
+ <div className="parallel-fork-node" title={nodeData.tooltip}>
41
+ <Handle
42
+ type="target"
43
+ position={Position.Top}
44
+ id="target"
45
+ isConnectable={isConnectable}
46
+ className="!w-3 !h-3 !bg-primary !border-2 !border-background"
47
+ />
48
+
49
+ <WorkflowNodeCard
50
+ title={nodeData.label}
51
+ description={nodeData.description}
52
+ status={mapStatus(nodeData.status)}
53
+ nodeType="parallelFork"
54
+ selected={selected}
55
+ />
56
+
57
+ <Handle
58
+ type="source"
59
+ position={Position.Bottom}
60
+ id="source"
61
+ isConnectable={isConnectable}
62
+ className="!w-3 !h-3 !bg-primary !border-2 !border-background"
63
+ />
64
+ </div>
65
+ )
66
+ }
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { Handle, Position, NodeProps } from '@xyflow/react'
4
+ import { WorkflowNodeCard } from '../WorkflowNodeCard'
5
+ import { WorkflowStatus } from '../../lib/status-colors'
6
+
7
+ /**
8
+ * ParallelJoinNode display data.
9
+ *
10
+ * A PARALLEL_JOIN synchronizes the branches created by its paired
11
+ * PARALLEL_FORK (`forkStepId`) using wait-all semantics, then continues the
12
+ * single outgoing transition once every branch has completed.
13
+ */
14
+ export interface ParallelJoinNodeData {
15
+ label: string
16
+ description?: string
17
+ forkStepId?: string
18
+ status?: 'pending' | 'running' | 'completed' | 'error' | 'not_started' | 'in_progress'
19
+ stepNumber?: number
20
+ badge?: string
21
+ tooltip?: string
22
+ executionStatus?: 'completed' | 'active' | 'pending' | 'failed' | 'skipped'
23
+ }
24
+
25
+ function mapStatus(status?: string): WorkflowStatus {
26
+ if (!status || status === 'pending') return 'not_started'
27
+ if (status === 'running' || status === 'in_progress') return 'in_progress'
28
+ if (status === 'completed') return 'completed'
29
+ return 'not_started'
30
+ }
31
+
32
+ /**
33
+ * ParallelJoinNode - synchronizes concurrent branches (wait-all).
34
+ * One target handle (in) collecting all branches; one source handle (out).
35
+ */
36
+ export function ParallelJoinNode({ data, isConnectable, selected }: NodeProps) {
37
+ const nodeData = data as unknown as ParallelJoinNodeData
38
+
39
+ return (
40
+ <div className="parallel-join-node" title={nodeData.tooltip}>
41
+ <Handle
42
+ type="target"
43
+ position={Position.Top}
44
+ id="target"
45
+ isConnectable={isConnectable}
46
+ className="!w-3 !h-3 !bg-primary !border-2 !border-background"
47
+ />
48
+
49
+ <WorkflowNodeCard
50
+ title={nodeData.label}
51
+ description={nodeData.description}
52
+ status={mapStatus(nodeData.status)}
53
+ nodeType="parallelJoin"
54
+ selected={selected}
55
+ />
56
+
57
+ <Handle
58
+ type="source"
59
+ position={Position.Bottom}
60
+ id="source"
61
+ isConnectable={isConnectable}
62
+ className="!w-3 !h-3 !bg-primary !border-2 !border-background"
63
+ />
64
+ </div>
65
+ )
66
+ }
@@ -18,3 +18,9 @@ export type { WaitForSignalNodeData } from './WaitForSignalNode'
18
18
 
19
19
  export { WaitForTimerNode } from './WaitForTimerNode'
20
20
  export type { WaitForTimerNodeData } from './WaitForTimerNode'
21
+
22
+ export { ParallelForkNode } from './ParallelForkNode'
23
+ export type { ParallelForkNodeData } from './ParallelForkNode'
24
+
25
+ export { ParallelJoinNode } from './ParallelJoinNode'
26
+ export type { ParallelJoinNodeData } from './ParallelJoinNode'
@@ -31,6 +31,15 @@ export type WorkflowInstanceStatus =
31
31
  | 'COMPENSATING'
32
32
  | 'COMPENSATED'
33
33
  | 'WAITING_FOR_ACTIVITIES'
34
+ | 'FORKED'
35
+
36
+ export type WorkflowBranchInstanceStatus =
37
+ | 'ACTIVE'
38
+ | 'PAUSED'
39
+ | 'WAITING_FOR_ACTIVITIES'
40
+ | 'COMPLETED'
41
+ | 'FAILED'
42
+ | 'CANCELLED'
34
43
 
35
44
  export type StepInstanceStatus =
36
45
  | 'PENDING'
@@ -275,6 +284,11 @@ export class WorkflowInstance {
275
284
  timestamp: Date
276
285
  } | null
277
286
 
287
+ // When the instance is FORKED, points at the open PARALLEL_FORK step whose
288
+ // branches are currently executing. Null for single-token instances.
289
+ @Property({ name: 'active_fork_step_id', type: 'varchar', length: 100, nullable: true })
290
+ activeForkStepId?: string | null
291
+
278
292
  @Property({ name: 'retry_count', type: 'integer', default: 0 })
279
293
  retryCount: number = 0
280
294
 
@@ -294,6 +308,88 @@ export class WorkflowInstance {
294
308
  deletedAt?: Date | null
295
309
  }
296
310
 
311
+ // ============================================================================
312
+ // Entity: WorkflowBranchInstance
313
+ // ============================================================================
314
+
315
+ /**
316
+ * WorkflowBranchInstance entity
317
+ *
318
+ * A single parallel branch token created by a PARALLEL_FORK step. Each branch
319
+ * advances independently (interleaved under the instance lock) with its own
320
+ * private context namespace, and converges to the paired PARALLEL_JOIN step.
321
+ * Branches are tenant/org scoped and never cross-tenant.
322
+ */
323
+ @Entity({ tableName: 'workflow_branch_instances' })
324
+ @Index({ name: 'workflow_branch_instances_instance_status_idx', properties: ['workflowInstanceId', 'status'] })
325
+ @Index({ name: 'workflow_branch_instances_instance_fork_idx', properties: ['workflowInstanceId', 'forkStepId'] })
326
+ @Index({ name: 'workflow_branch_instances_tenant_org_idx', properties: ['tenantId', 'organizationId'] })
327
+ export class WorkflowBranchInstance {
328
+ [OptionalProps]?: 'createdAt' | 'updatedAt'
329
+
330
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
331
+ id!: string
332
+
333
+ @Property({ name: 'workflow_instance_id', type: 'uuid' })
334
+ workflowInstanceId!: string
335
+
336
+ @Property({ name: 'fork_step_id', type: 'varchar', length: 100 })
337
+ forkStepId!: string
338
+
339
+ @Property({ name: 'join_step_id', type: 'varchar', length: 100 })
340
+ joinStepId!: string
341
+
342
+ // The transitionId of the FORK's outgoing transition that created this branch.
343
+ @Property({ name: 'branch_key', type: 'varchar', length: 100 })
344
+ branchKey!: string
345
+
346
+ // Reserved for nested-fork support (always null this iteration; validator blocks nesting).
347
+ @Property({ name: 'parent_branch_id', type: 'uuid', nullable: true })
348
+ parentBranchId?: string | null
349
+
350
+ @Property({ name: 'current_step_id', type: 'varchar', length: 100 })
351
+ currentStepId!: string
352
+
353
+ @Property({ name: 'status', type: 'varchar', length: 30 })
354
+ status!: WorkflowBranchInstanceStatus
355
+
356
+ // The branch's private write scope; merged back into instance.context at JOIN.
357
+ @Property({ name: 'context_namespace', type: 'jsonb' })
358
+ contextNamespace!: Record<string, any>
359
+
360
+ // Per-branch equivalent of WorkflowInstance.pendingTransition (async activities).
361
+ @Property({ name: 'pending_transition', type: 'jsonb', nullable: true })
362
+ pendingTransition?: {
363
+ toStepId: string
364
+ activityResults: any[]
365
+ timestamp: Date
366
+ } | null
367
+
368
+ @Property({ name: 'error_message', type: 'text', nullable: true })
369
+ errorMessage?: string | null
370
+
371
+ @Property({ name: 'error_details', type: 'jsonb', nullable: true })
372
+ errorDetails?: any | null
373
+
374
+ @Property({ name: 'started_at', type: Date, nullable: true })
375
+ startedAt?: Date | null
376
+
377
+ @Property({ name: 'completed_at', type: Date, nullable: true })
378
+ completedAt?: Date | null
379
+
380
+ @Property({ name: 'tenant_id', type: 'uuid' })
381
+ tenantId!: string
382
+
383
+ @Property({ name: 'organization_id', type: 'uuid' })
384
+ organizationId!: string
385
+
386
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
387
+ createdAt: Date = new Date()
388
+
389
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
390
+ updatedAt: Date = new Date()
391
+ }
392
+
297
393
  // ============================================================================
298
394
  // Entity: StepInstance
299
395
  // ============================================================================
@@ -317,6 +413,10 @@ export class StepInstance {
317
413
  @Property({ name: 'workflow_instance_id', type: 'uuid' })
318
414
  workflowInstanceId!: string
319
415
 
416
+ // Set when this step executes inside a parallel branch; null for single-token.
417
+ @Property({ name: 'branch_instance_id', type: 'uuid', nullable: true })
418
+ branchInstanceId?: string | null
419
+
320
420
  @Property({ name: 'step_id', type: 'varchar', length: 100 })
321
421
  stepId!: string
322
422
 
@@ -390,6 +490,10 @@ export class UserTask {
390
490
  @Property({ name: 'step_instance_id', type: 'uuid' })
391
491
  stepInstanceId!: string
392
492
 
493
+ // Set when this task belongs to a parallel branch; null for single-token instances.
494
+ @Property({ name: 'branch_instance_id', type: 'uuid', nullable: true })
495
+ branchInstanceId?: string | null
496
+
393
497
  @Property({ name: 'task_name', type: 'varchar', length: 255 })
394
498
  taskName!: string
395
499
 
@@ -472,6 +576,10 @@ export class WorkflowEvent {
472
576
  @Property({ name: 'step_instance_id', type: 'uuid', nullable: true })
473
577
  stepInstanceId?: string | null
474
578
 
579
+ // Set when the event was logged within a parallel branch; null otherwise.
580
+ @Property({ name: 'branch_instance_id', type: 'uuid', nullable: true })
581
+ branchInstanceId?: string | null
582
+
475
583
  @Property({ name: 'event_type', type: 'varchar', length: 50 })
476
584
  eventType!: string
477
585
 
@@ -560,6 +668,7 @@ export class WorkflowEventTrigger {
560
668
  export default [
561
669
  WorkflowDefinition,
562
670
  WorkflowInstance,
671
+ WorkflowBranchInstance,
563
672
  StepInstance,
564
673
  UserTask,
565
674
  WorkflowEvent,
@@ -69,9 +69,20 @@ export const workflowInstanceStatusSchema = z.enum([
69
69
  'COMPENSATING',
70
70
  'COMPENSATED',
71
71
  'WAITING_FOR_ACTIVITIES',
72
+ 'FORKED',
72
73
  ])
73
74
  export type WorkflowInstanceStatus = z.infer<typeof workflowInstanceStatusSchema>
74
75
 
76
+ export const workflowBranchInstanceStatusSchema = z.enum([
77
+ 'ACTIVE',
78
+ 'PAUSED',
79
+ 'WAITING_FOR_ACTIVITIES',
80
+ 'COMPLETED',
81
+ 'FAILED',
82
+ 'CANCELLED',
83
+ ])
84
+ export type WorkflowBranchInstanceStatus = z.infer<typeof workflowBranchInstanceStatusSchema>
85
+
75
86
  export const stepInstanceStatusSchema = z.enum([
76
87
  'PENDING',
77
88
  'ACTIVE',
@@ -376,6 +387,210 @@ export const workflowDefinitionTriggerSchema = z.object({
376
387
  })
377
388
  export type WorkflowDefinitionTrigger = z.infer<typeof workflowDefinitionTriggerSchema>
378
389
 
390
+ // ============================================================================
391
+ // PARALLEL_FORK / PARALLEL_JOIN definition validation
392
+ // ============================================================================
393
+
394
+ // Error codes surfaced by FORK/JOIN definition validation. Stable identifiers
395
+ // so the visual editor and tests can match on them.
396
+ export type ForkJoinValidationCode =
397
+ | 'MISSING_JOIN_STEP_ID'
398
+ | 'JOIN_STEP_NOT_FOUND'
399
+ | 'JOIN_STEP_WRONG_TYPE'
400
+ | 'MISSING_FORK_STEP_ID'
401
+ | 'FORK_JOIN_MISMATCH'
402
+ | 'FORK_TOO_FEW_BRANCHES'
403
+ | 'JOIN_TOO_FEW_INCOMING'
404
+ | 'DUPLICATE_BRANCH_KEY'
405
+ | 'NESTED_FORK_NOT_SUPPORTED'
406
+ | 'NO_CONVERGENCE_TO_JOIN'
407
+ | 'FORK_JOIN_CYCLE'
408
+ | 'UNPAIRED_JOIN'
409
+
410
+ export interface ForkJoinValidationIssue {
411
+ code: ForkJoinValidationCode
412
+ message: string
413
+ stepId?: string
414
+ }
415
+
416
+ interface ForkJoinStepLike {
417
+ stepId: string
418
+ stepType: string
419
+ config?: Record<string, unknown> | null
420
+ }
421
+
422
+ interface ForkJoinTransitionLike {
423
+ transitionId: string
424
+ fromStepId: string
425
+ toStepId: string
426
+ trigger: string
427
+ }
428
+
429
+ interface ForkJoinDefinitionLike {
430
+ steps: ForkJoinStepLike[]
431
+ transitions: ForkJoinTransitionLike[]
432
+ }
433
+
434
+ /**
435
+ * Validates PARALLEL_FORK / PARALLEL_JOIN structure of a workflow definition.
436
+ * Pure and side-effect-free so it can be unit tested and reused by the editor.
437
+ *
438
+ * Rules (this iteration — wait-all, no nesting):
439
+ * 1. Every FORK declares config.joinStepId pointing at an existing PARALLEL_JOIN.
440
+ * 2. The paired JOIN back-references the fork via config.forkStepId.
441
+ * 3. A FORK has >= 2 outgoing `auto` transitions (branch keys unique); a JOIN has >= 2 incoming.
442
+ * 4. Every path from a FORK converges to its JOIN — no END inside a branch, no dead ends,
443
+ * no path bypassing the JOIN, no path to a different JOIN.
444
+ * 5. No nesting: no FORK appears on a path between a FORK and its JOIN.
445
+ * 6. No cycles back to the FORK within its branch region.
446
+ * 7. Every PARALLEL_JOIN is paired with exactly one FORK.
447
+ */
448
+ export function validateParallelForkJoin(definition: ForkJoinDefinitionLike): ForkJoinValidationIssue[] {
449
+ const issues: ForkJoinValidationIssue[] = []
450
+ const steps = definition.steps ?? []
451
+ const transitions = definition.transitions ?? []
452
+
453
+ const stepById = new Map<string, ForkJoinStepLike>()
454
+ for (const step of steps) stepById.set(step.stepId, step)
455
+
456
+ const outgoingByStep = new Map<string, ForkJoinTransitionLike[]>()
457
+ const incomingCountByStep = new Map<string, number>()
458
+ for (const transition of transitions) {
459
+ const list = outgoingByStep.get(transition.fromStepId) ?? []
460
+ list.push(transition)
461
+ outgoingByStep.set(transition.fromStepId, list)
462
+ incomingCountByStep.set(transition.toStepId, (incomingCountByStep.get(transition.toStepId) ?? 0) + 1)
463
+ }
464
+
465
+ const forkSteps = steps.filter((step) => step.stepType === 'PARALLEL_FORK')
466
+ const joinSteps = steps.filter((step) => step.stepType === 'PARALLEL_JOIN')
467
+
468
+ // Track which JOIN steps are paired with a FORK so we can flag orphan joins.
469
+ const pairedJoinIds = new Set<string>()
470
+
471
+ for (const fork of forkSteps) {
472
+ const joinStepId = (fork.config?.joinStepId as string | undefined) ?? undefined
473
+ if (!joinStepId) {
474
+ issues.push({ code: 'MISSING_JOIN_STEP_ID', stepId: fork.stepId, message: `PARALLEL_FORK "${fork.stepId}" must declare config.joinStepId` })
475
+ continue
476
+ }
477
+ const joinStep = stepById.get(joinStepId)
478
+ if (!joinStep) {
479
+ issues.push({ code: 'JOIN_STEP_NOT_FOUND', stepId: fork.stepId, message: `PARALLEL_FORK "${fork.stepId}" references missing join step "${joinStepId}"` })
480
+ continue
481
+ }
482
+ if (joinStep.stepType !== 'PARALLEL_JOIN') {
483
+ issues.push({ code: 'JOIN_STEP_WRONG_TYPE', stepId: fork.stepId, message: `Step "${joinStepId}" referenced by fork "${fork.stepId}" is not a PARALLEL_JOIN` })
484
+ continue
485
+ }
486
+
487
+ pairedJoinIds.add(joinStepId)
488
+
489
+ const backForkStepId = (joinStep.config?.forkStepId as string | undefined) ?? undefined
490
+ if (!backForkStepId) {
491
+ issues.push({ code: 'MISSING_FORK_STEP_ID', stepId: joinStepId, message: `PARALLEL_JOIN "${joinStepId}" must declare config.forkStepId` })
492
+ } else if (backForkStepId !== fork.stepId) {
493
+ issues.push({ code: 'FORK_JOIN_MISMATCH', stepId: joinStepId, message: `PARALLEL_JOIN "${joinStepId}" back-reference forkStepId "${backForkStepId}" does not match fork "${fork.stepId}"` })
494
+ }
495
+
496
+ const autoBranches = (outgoingByStep.get(fork.stepId) ?? []).filter((transition) => transition.trigger === 'auto')
497
+ if (autoBranches.length < 2) {
498
+ issues.push({ code: 'FORK_TOO_FEW_BRANCHES', stepId: fork.stepId, message: `PARALLEL_FORK "${fork.stepId}" must have at least 2 outgoing auto transitions (found ${autoBranches.length})` })
499
+ }
500
+ const branchKeys = new Set<string>()
501
+ for (const branch of autoBranches) {
502
+ if (branchKeys.has(branch.transitionId)) {
503
+ issues.push({ code: 'DUPLICATE_BRANCH_KEY', stepId: fork.stepId, message: `PARALLEL_FORK "${fork.stepId}" has duplicate branch key "${branch.transitionId}"` })
504
+ }
505
+ branchKeys.add(branch.transitionId)
506
+ }
507
+
508
+ if ((incomingCountByStep.get(joinStepId) ?? 0) < 2) {
509
+ issues.push({ code: 'JOIN_TOO_FEW_INCOMING', stepId: joinStepId, message: `PARALLEL_JOIN "${joinStepId}" must have at least 2 incoming transitions` })
510
+ }
511
+
512
+ // Convergence + no-nesting + no-cycle traversal over the branch region.
513
+ const fullyExplored = new Set<string>()
514
+ const onStack = new Set<string>()
515
+ let reportedNesting = false
516
+ let reportedNoConvergence = false
517
+ let reportedCycle = false
518
+
519
+ const visit = (stepId: string): void => {
520
+ if (stepId === joinStepId) return // converged
521
+ if (stepId === fork.stepId) {
522
+ if (!reportedCycle) {
523
+ issues.push({ code: 'FORK_JOIN_CYCLE', stepId: fork.stepId, message: `A branch of fork "${fork.stepId}" loops back to the fork before reaching join "${joinStepId}"` })
524
+ reportedCycle = true
525
+ }
526
+ return
527
+ }
528
+ const step = stepById.get(stepId)
529
+ if (!step) {
530
+ if (!reportedNoConvergence) {
531
+ issues.push({ code: 'NO_CONVERGENCE_TO_JOIN', stepId: fork.stepId, message: `A branch of fork "${fork.stepId}" reaches missing step "${stepId}" instead of join "${joinStepId}"` })
532
+ reportedNoConvergence = true
533
+ }
534
+ return
535
+ }
536
+ if (step.stepType === 'END') {
537
+ if (!reportedNoConvergence) {
538
+ issues.push({ code: 'NO_CONVERGENCE_TO_JOIN', stepId: fork.stepId, message: `A branch of fork "${fork.stepId}" reaches an END step before join "${joinStepId}"` })
539
+ reportedNoConvergence = true
540
+ }
541
+ return
542
+ }
543
+ if (step.stepType === 'PARALLEL_FORK') {
544
+ if (!reportedNesting) {
545
+ issues.push({ code: 'NESTED_FORK_NOT_SUPPORTED', stepId: fork.stepId, message: `Nested PARALLEL_FORK "${stepId}" inside fork "${fork.stepId}" is not supported` })
546
+ reportedNesting = true
547
+ }
548
+ return
549
+ }
550
+ if (step.stepType === 'PARALLEL_JOIN') {
551
+ // Reached a join that is not this fork's join → it does not converge correctly.
552
+ if (!reportedNoConvergence) {
553
+ issues.push({ code: 'NO_CONVERGENCE_TO_JOIN', stepId: fork.stepId, message: `A branch of fork "${fork.stepId}" reaches join "${stepId}" instead of its own join "${joinStepId}"` })
554
+ reportedNoConvergence = true
555
+ }
556
+ return
557
+ }
558
+ if (onStack.has(stepId)) {
559
+ if (!reportedCycle) {
560
+ issues.push({ code: 'FORK_JOIN_CYCLE', stepId: fork.stepId, message: `A branch of fork "${fork.stepId}" contains a cycle at step "${stepId}"` })
561
+ reportedCycle = true
562
+ }
563
+ return
564
+ }
565
+ if (fullyExplored.has(stepId)) return
566
+
567
+ const outgoing = outgoingByStep.get(stepId) ?? []
568
+ if (outgoing.length === 0) {
569
+ if (!reportedNoConvergence) {
570
+ issues.push({ code: 'NO_CONVERGENCE_TO_JOIN', stepId: fork.stepId, message: `A branch of fork "${fork.stepId}" dead-ends at step "${stepId}" without reaching join "${joinStepId}"` })
571
+ reportedNoConvergence = true
572
+ }
573
+ return
574
+ }
575
+ onStack.add(stepId)
576
+ for (const transition of outgoing) visit(transition.toStepId)
577
+ onStack.delete(stepId)
578
+ fullyExplored.add(stepId)
579
+ }
580
+
581
+ for (const branch of autoBranches) visit(branch.toStepId)
582
+ }
583
+
584
+ // Any PARALLEL_JOIN not paired with a fork is an orphan.
585
+ for (const join of joinSteps) {
586
+ if (!pairedJoinIds.has(join.stepId)) {
587
+ issues.push({ code: 'UNPAIRED_JOIN', stepId: join.stepId, message: `PARALLEL_JOIN "${join.stepId}" is not paired with any PARALLEL_FORK` })
588
+ }
589
+ }
590
+
591
+ return issues
592
+ }
593
+
379
594
  // Workflow definition data (JSONB structure)
380
595
  export const workflowDefinitionDataSchema = z.object({
381
596
  steps: z.array(workflowStepSchema).min(2, 'Workflow must have at least START and END steps'),
@@ -384,6 +599,14 @@ export const workflowDefinitionDataSchema = z.object({
384
599
  queries: z.array(z.any()).optional(), // For Phase 7
385
600
  signals: z.array(z.any()).optional(), // For Phase 9
386
601
  timers: z.array(z.any()).optional(), // For Phase 9
602
+ }).superRefine((definition, ctx) => {
603
+ for (const issue of validateParallelForkJoin(definition as ForkJoinDefinitionLike)) {
604
+ ctx.addIssue({
605
+ code: 'custom',
606
+ path: ['steps'],
607
+ message: `[${issue.code}] ${issue.message}`,
608
+ })
609
+ }
387
610
  })
388
611
 
389
612
  // Workflow metadata
@@ -35,6 +35,13 @@ const events = [
35
35
  { id: 'workflows.trigger.created', label: 'Trigger Created', entity: 'trigger', category: 'crud' },
36
36
  { id: 'workflows.trigger.updated', label: 'Trigger Updated', entity: 'trigger', category: 'crud' },
37
37
  { id: 'workflows.trigger.deleted', label: 'Trigger Deleted', entity: 'trigger', category: 'crud' },
38
+
39
+ // Parallel Fork / Join (branch lifecycle)
40
+ { id: 'workflows.branch.opened', label: 'Parallel Branch Opened', entity: 'branch', category: 'lifecycle' },
41
+ { id: 'workflows.branch.completed', label: 'Parallel Branch Completed', entity: 'branch', category: 'lifecycle' },
42
+ { id: 'workflows.branch.cancelled', label: 'Parallel Branch Cancelled', entity: 'branch', category: 'lifecycle' },
43
+ { id: 'workflows.branch.failed', label: 'Parallel Branch Failed', entity: 'branch', category: 'lifecycle' },
44
+ { id: 'workflows.join.completed', label: 'Parallel Join Completed', entity: 'branch', category: 'lifecycle' },
38
45
  ] as const
39
46
 
40
47
  export const eventsConfig = createModuleEvents({
@@ -913,6 +913,8 @@
913
913
  "workflows.nodeTypes.automated": "AUTOMATISIERT",
914
914
  "workflows.nodeTypes.decision": "ENTSCHEIDUNG",
915
915
  "workflows.nodeTypes.end": "ENDE",
916
+ "workflows.nodeTypes.parallelFork": "Parallele Verzweigung",
917
+ "workflows.nodeTypes.parallelJoin": "Parallele Zusammenführung",
916
918
  "workflows.nodeTypes.start": "START",
917
919
  "workflows.nodeTypes.subWorkflow": "SUB-WORKFLOW",
918
920
  "workflows.nodeTypes.userTask": "BENUTZERAUFGABE",
@@ -937,6 +939,16 @@
937
939
  "workflows.orderApproval.requestApproval": "Genehmigung anfordern",
938
940
  "workflows.orderApproval.startError": "Genehmigungsworkflow konnte nicht gestartet werden.",
939
941
  "workflows.orderApproval.submitDecision": "Entscheidung einreichen",
942
+ "workflows.parallel.branch.label": "Zweig",
943
+ "workflows.parallel.branch.status.ACTIVE": "Aktiv",
944
+ "workflows.parallel.branch.status.CANCELLED": "Abgebrochen",
945
+ "workflows.parallel.branch.status.COMPLETED": "Abgeschlossen",
946
+ "workflows.parallel.branch.status.FAILED": "Fehlgeschlagen",
947
+ "workflows.parallel.branch.status.PAUSED": "Pausiert",
948
+ "workflows.parallel.branch.status.WAITING_FOR_ACTIVITIES": "Warten auf Aktivitäten",
949
+ "workflows.parallel.fork.label": "Parallele Verzweigung",
950
+ "workflows.parallel.join.label": "Parallele Zusammenführung",
951
+ "workflows.parallel.validation.title": "Validierung der parallelen Verzweigung/Zusammenführung fehlgeschlagen",
940
952
  "workflows.signals.awaiting": "Wartet auf Signal",
941
953
  "workflows.signals.correlationKey": "Korrelationsschlüssel",
942
954
  "workflows.signals.payload": "Signal-Nutzlast",
@@ -913,6 +913,8 @@
913
913
  "workflows.nodeTypes.automated": "AUTOMATED",
914
914
  "workflows.nodeTypes.decision": "DECISION",
915
915
  "workflows.nodeTypes.end": "END",
916
+ "workflows.nodeTypes.parallelFork": "Parallel Fork",
917
+ "workflows.nodeTypes.parallelJoin": "Parallel Join",
916
918
  "workflows.nodeTypes.start": "START",
917
919
  "workflows.nodeTypes.subWorkflow": "SUB-WORKFLOW",
918
920
  "workflows.nodeTypes.userTask": "USER TASK",
@@ -937,6 +939,16 @@
937
939
  "workflows.orderApproval.requestApproval": "Request Approval",
938
940
  "workflows.orderApproval.startError": "Failed to start approval workflow.",
939
941
  "workflows.orderApproval.submitDecision": "Submit Decision",
942
+ "workflows.parallel.branch.label": "Branch",
943
+ "workflows.parallel.branch.status.ACTIVE": "Active",
944
+ "workflows.parallel.branch.status.CANCELLED": "Cancelled",
945
+ "workflows.parallel.branch.status.COMPLETED": "Completed",
946
+ "workflows.parallel.branch.status.FAILED": "Failed",
947
+ "workflows.parallel.branch.status.PAUSED": "Paused",
948
+ "workflows.parallel.branch.status.WAITING_FOR_ACTIVITIES": "Waiting for activities",
949
+ "workflows.parallel.fork.label": "Parallel Fork",
950
+ "workflows.parallel.join.label": "Parallel Join",
951
+ "workflows.parallel.validation.title": "Parallel fork/join validation failed",
940
952
  "workflows.signals.awaiting": "Waiting for signal",
941
953
  "workflows.signals.correlationKey": "Correlation Key",
942
954
  "workflows.signals.payload": "Signal Payload",