edda-framework 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1105 @@
1
+ """
2
+ UI components for generating interactive Mermaid diagrams.
3
+ """
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from edda.visualizer.ast_analyzer import WorkflowAnalyzer
9
+ from edda.visualizer.mermaid_generator import MermaidGenerator
10
+
11
+
12
+ def generate_interactive_mermaid(instance_id: str, history: list[dict[str, Any]]) -> str:
13
+ """
14
+ Generate interactive Mermaid diagram from execution history.
15
+
16
+ Each node is clickable and emits an "activity_click" event with
17
+ instance_id and activity_id.
18
+
19
+ This function now detects:
20
+ - Loops (same activity executed multiple times)
21
+
22
+ Args:
23
+ instance_id: Workflow instance ID
24
+ history: List of execution activity dictionaries
25
+
26
+ Returns:
27
+ Mermaid flowchart diagram code with embedded click events
28
+ """
29
+ if not history:
30
+ return "flowchart TD\n Start([No activities])"
31
+
32
+ lines = ["flowchart TD"]
33
+
34
+ # Extract workflow name from first activity
35
+ workflow_name = history[0].get("workflow_name", "workflow")
36
+
37
+ # Start node
38
+ lines.append(f" Start([{workflow_name}])")
39
+
40
+ prev_node = "Start"
41
+ activity_occurrences: dict[str, list[str]] = {} # Track activity name -> list of activity_ids
42
+ activity_index = 0
43
+
44
+ for activity_data in history:
45
+ activity_id = activity_data.get("activity_id")
46
+ if not activity_id:
47
+ activity_index += 1
48
+ activity_id = f"activity_{activity_index}"
49
+
50
+ activity = activity_data.get("activity_name", activity_id)
51
+ status = activity_data.get("status", "unknown")
52
+
53
+ # Track activity occurrences for loop detection
54
+ if activity not in activity_occurrences:
55
+ activity_occurrences[activity] = []
56
+ activity_occurrences[activity].append(activity_id)
57
+
58
+ # Generate unique node ID based on activity_id (sanitized for Mermaid)
59
+ safe_id = activity_id.replace(":", "_").replace("-", "_")
60
+ node_id = f"N_{safe_id}"
61
+
62
+ # Detect if this is a repeated activity (loop iteration)
63
+ occurrence_count = len(activity_occurrences[activity])
64
+ label_suffix = f" ({occurrence_count}x)" if occurrence_count >= 2 else ""
65
+
66
+ # Node label with status icon
67
+ if status == "completed":
68
+ label = f"✅ {activity}{label_suffix}"
69
+ style_color = "fill:#d4edda,stroke:#28a745,stroke-width:2px"
70
+ elif status == "running":
71
+ label = f"⏳ {activity}{label_suffix}"
72
+ style_color = "fill:#fff3cd,stroke:#ffc107,stroke-width:2px"
73
+ elif status == "failed":
74
+ label = f"❌ {activity}{label_suffix}"
75
+ style_color = "fill:#f8d7da,stroke:#dc3545,stroke-width:2px"
76
+ elif status == "waiting_for_event":
77
+ label = f"⏸️ {activity}{label_suffix}"
78
+ style_color = "fill:#e7f3ff,stroke:#0066cc,stroke-width:2px"
79
+ elif status == "cancelled":
80
+ label = f"🚫 {activity}{label_suffix}"
81
+ style_color = "fill:#fff4e6,stroke:#ff9800,stroke-width:2px"
82
+ elif status == "compensated":
83
+ label = f"🔄 {activity}{label_suffix}"
84
+ style_color = "fill:#ffe6f0,stroke:#e91e63,stroke-width:2px,stroke-dasharray:5"
85
+ elif status == "compensation_failed":
86
+ label = f"⚠️ {activity}{label_suffix}"
87
+ style_color = "fill:#ffcccc,stroke:#cc0000,stroke-width:2px"
88
+ elif status == "event_received":
89
+ label = f"📨 {activity}{label_suffix}"
90
+ style_color = "fill:#e6f7ff,stroke:#1890ff,stroke-width:2px"
91
+ else:
92
+ label = f"{activity}{label_suffix}"
93
+ style_color = "fill:#f0f0f0,stroke:#666,stroke-width:2px"
94
+
95
+ # Node definition
96
+ lines.append(f' {node_id}["{label}"]')
97
+ lines.append(f" style {node_id} {style_color}")
98
+
99
+ # Click event - CRITICAL: embed click handler
100
+ # Pass instance_id and activity_id as separate string arguments to avoid JSON escaping issues
101
+ lines.append(
102
+ f' click {node_id} call emitEvent("activity_click", "{instance_id}", "{activity_id}")'
103
+ )
104
+
105
+ # Check if this is a loop iteration
106
+ if occurrence_count > 1:
107
+ # This is a loop iteration - show with different arrow style
108
+ lines.append(f" {prev_node} -.->|retry/loop| {node_id}")
109
+ else:
110
+ # Normal sequential flow
111
+ lines.append(f" {prev_node} --> {node_id}")
112
+
113
+ prev_node = node_id
114
+
115
+ # End node
116
+ lines.append(" End([Complete])")
117
+ lines.append(f" {prev_node} --> End")
118
+
119
+ return "\n".join(lines)
120
+
121
+
122
+ class HybridMermaidGenerator(MermaidGenerator):
123
+ """
124
+ Extended Mermaid generator that combines static workflow analysis with execution history.
125
+
126
+ This generator highlights executed paths while showing the complete workflow structure.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ instance_id: str,
132
+ executed_activities: set[str],
133
+ compensations: dict[str, dict[str, Any]] | None = None,
134
+ workflow_status: str = "running",
135
+ activity_status_map: dict[str, str] | None = None,
136
+ ):
137
+ """
138
+ Initialize hybrid Mermaid generator.
139
+
140
+ Args:
141
+ instance_id: Workflow instance ID for click events
142
+ executed_activities: Set of activity names that were executed
143
+ compensations: Optional mapping of activity_id -> compensation info
144
+ workflow_status: Status of the workflow instance (running, completed, failed, etc.)
145
+ activity_status_map: Optional mapping of activity name to status (completed, failed, etc.)
146
+ """
147
+ super().__init__()
148
+ self.instance_id = instance_id
149
+ self.executed_activities = executed_activities
150
+ self.compensations = compensations or {}
151
+ self.workflow_status = workflow_status
152
+ self.activity_status_map = activity_status_map or {}
153
+ self.activity_id_map: dict[str, str] = {} # Map activity name to activity_id for clicks
154
+ self.activity_execution_counts: dict[str, int] = {} # Map activity name to execution count
155
+ self.edge_counter = 0 # Track edge indices for linkStyle
156
+ self.executed_edges: list[int] = [] # Indices of executed edges for green styling
157
+ self.executed_compensations: list[dict[str, Any]] = [] # Compensation execution history
158
+
159
+ def generate(self, workflow: dict[str, Any]) -> str:
160
+ """
161
+ Generate Mermaid flowchart with execution highlighting and linkStyle.
162
+
163
+ Args:
164
+ workflow: Workflow dictionary from WorkflowAnalyzer
165
+
166
+ Returns:
167
+ Mermaid flowchart syntax as string with styled edges
168
+ """
169
+ # Reset counters
170
+ self.node_counter = 0
171
+ self.edge_counter = 0
172
+ self.executed_edges = []
173
+ self.lines = ["flowchart TD"]
174
+ self.compensation_nodes = []
175
+
176
+ # Start node
177
+ start_id = "Start"
178
+ self.lines.append(f" {start_id}([{workflow['name']}])")
179
+
180
+ # Generate steps
181
+ prev_id = start_id
182
+ prev_id = self._generate_steps(workflow["steps"], prev_id)
183
+
184
+ # End node
185
+ end_id = "End"
186
+ self.lines.append(f" {end_id}([Complete])")
187
+ self.lines.append(f" {prev_id} --> {end_id}")
188
+
189
+ # Mark this edge as executed if workflow is completed
190
+ if self.workflow_status == "completed":
191
+ self.executed_edges.append(self.edge_counter)
192
+
193
+ self.edge_counter += 1 # Count the final edge to End
194
+
195
+ # Add compensation execution section if any compensations were executed
196
+ if self.executed_compensations:
197
+ # Compensations are already in chronological order (no need to sort)
198
+ sorted_compensations = self.executed_compensations
199
+
200
+ # Add a visual separator (compensation execution section)
201
+ comp_start_id = self._next_node_id()
202
+ self.lines.append(f" {comp_start_id}[Compensation Execution]")
203
+ self.lines.append(
204
+ f" style {comp_start_id} fill:#fff3cd,stroke:#ffc107,stroke-width:2px"
205
+ )
206
+ self.lines.append(f" {end_id} -.->|rollback| {comp_start_id}")
207
+ self.edge_counter += 1
208
+
209
+ # Render each compensation execution
210
+ prev_comp_id = comp_start_id
211
+ for comp in sorted_compensations:
212
+ comp_name = comp["activity_name"]
213
+ comp_activity_id = comp["activity_id"]
214
+
215
+ # Create compensation node with rollback icon
216
+ comp_node_id = self._next_node_id()
217
+ label = f"🔄 {comp_name}"
218
+
219
+ self.lines.append(f' {comp_node_id}["{label}"]')
220
+ self.lines.append(
221
+ f" style {comp_node_id} fill:#f8d7da,stroke:#dc3545,stroke-width:3px"
222
+ )
223
+
224
+ # Add click event for compensation activity
225
+ self.lines.append(
226
+ f' click {comp_node_id} call emitEvent("activity_click", "{self.instance_id}", "{comp_activity_id}")'
227
+ )
228
+
229
+ # Connect with dashed arrow (compensation flow)
230
+ self.lines.append(f" {prev_comp_id} -.-> {comp_node_id}")
231
+ self.executed_edges.append(self.edge_counter)
232
+ self.edge_counter += 1
233
+
234
+ prev_comp_id = comp_node_id
235
+
236
+ # Add linkStyle for executed edges (green color)
237
+ if self.executed_edges:
238
+ edge_indices = ",".join(str(i) for i in self.executed_edges)
239
+ self.lines.append(f" linkStyle {edge_indices} stroke:#28a745,stroke-width:3px")
240
+
241
+ return "\n".join(self.lines)
242
+
243
+ def _generate_steps(self, steps: list[dict[str, Any]], prev_id: str) -> str:
244
+ """
245
+ Generate Mermaid nodes for a sequence of steps with execution highlighting.
246
+
247
+ Args:
248
+ steps: List of step dictionaries
249
+ prev_id: ID of the previous node
250
+
251
+ Returns:
252
+ ID of the last node in the sequence
253
+ """
254
+ current_id = prev_id
255
+
256
+ for step in steps:
257
+ step_type = step.get("type")
258
+
259
+ if step_type == "activity":
260
+ # Regular activity call
261
+ node_id = self._next_node_id()
262
+ func_name = step.get("activity_name", step.get("function", "unknown"))
263
+ executed = func_name in self.executed_activities
264
+
265
+ # Get execution count and status for this activity
266
+ exec_count = self.activity_execution_counts.get(func_name, 1)
267
+ status = self.activity_status_map.get(func_name)
268
+
269
+ # Check if this activity has compensation registered
270
+ has_compensation = False
271
+ if func_name in self.activity_id_map:
272
+ activity_id = self.activity_id_map[func_name]
273
+ has_compensation = activity_id in self.compensations
274
+
275
+ # Determine label prefix based on status
276
+ if status == "completed":
277
+ label_prefix = "✅ "
278
+ elif status == "failed":
279
+ label_prefix = "❌ "
280
+ elif status == "compensated":
281
+ label_prefix = "🔄 "
282
+ elif status is not None:
283
+ # Other executed statuses (running, waiting, etc.)
284
+ label_prefix = "⏳ "
285
+ else:
286
+ # Not executed
287
+ label_prefix = ""
288
+
289
+ # Build label with execution count badge if >= 2
290
+ if exec_count >= 2:
291
+ label = f"{label_prefix}{func_name} ({exec_count}x)"
292
+ else:
293
+ label = f"{label_prefix}{func_name}"
294
+
295
+ # Add compensation badge if registered
296
+ if has_compensation:
297
+ label += " ⚠"
298
+
299
+ # Style based on status
300
+ if status == "completed":
301
+ style_color = "fill:#d4edda,stroke:#28a745,stroke-width:3px" # Green
302
+ elif status == "failed":
303
+ style_color = "fill:#f8d7da,stroke:#dc3545,stroke-width:3px" # Red
304
+ elif status == "compensated":
305
+ style_color = (
306
+ "fill:#ffe6f0,stroke:#e91e63,stroke-width:3px,stroke-dasharray:5" # Pink
307
+ )
308
+ elif status is not None:
309
+ # Other executed statuses
310
+ style_color = "fill:#fff3cd,stroke:#ffc107,stroke-width:3px" # Yellow
311
+ else:
312
+ # Not executed
313
+ style_color = "fill:#f5f5f5,stroke:#ccc,stroke-width:1px" # Gray
314
+
315
+ self.lines.append(f' {node_id}["{label}"]')
316
+ self.lines.append(f" style {node_id} {style_color}")
317
+
318
+ # Add click event only for executed nodes
319
+ if executed and func_name in self.activity_id_map:
320
+ activity_id = self.activity_id_map[func_name]
321
+ self.lines.append(
322
+ f' click {node_id} call emitEvent("activity_click", "{self.instance_id}", "{activity_id}")'
323
+ )
324
+
325
+ # Edge styling based on execution
326
+ if executed:
327
+ self.lines.append(f" {current_id} --> {node_id}")
328
+ self.executed_edges.append(self.edge_counter)
329
+ else:
330
+ self.lines.append(f" {current_id} -.-> {node_id}")
331
+
332
+ self.edge_counter += 1
333
+ current_id = node_id
334
+
335
+ elif step_type == "compensation":
336
+ # Compensation registration
337
+ node_id = self._next_node_id()
338
+ func_name = step.get("activity_name", step.get("function", "unknown"))
339
+ self.lines.append(f" {node_id}[register_compensation:<br/>{func_name}]")
340
+ self.lines.append(f" {current_id} --> {node_id}")
341
+ self.lines.append(f" style {node_id} fill:#ffe6e6")
342
+
343
+ # Track compensation for reverse path
344
+ self.compensation_nodes.append((current_id, node_id))
345
+
346
+ current_id = node_id
347
+
348
+ elif step_type == "wait_event":
349
+ # Event waiting
350
+ node_id = self._next_node_id()
351
+ event_type = step.get("event_type", "unknown")
352
+ timeout = step.get("timeout")
353
+
354
+ label = f"wait_event:<br/>{event_type}"
355
+ if timeout:
356
+ label += f"<br/>timeout: {timeout}s"
357
+
358
+ self.lines.append(f" {node_id}{{{{{label}}}}}")
359
+ self.lines.append(f" {current_id} --> {node_id}")
360
+ self.lines.append(f" style {node_id} fill:#fff4e6")
361
+ current_id = node_id
362
+
363
+ elif step_type == "condition":
364
+ # Conditional branch (if/else)
365
+ current_id = self._generate_conditional(step, current_id)
366
+
367
+ elif step_type == "multi_condition":
368
+ # Multi-branch conditional (if-elif-else chain)
369
+ current_id = self._generate_multi_conditional(step, current_id)
370
+
371
+ elif step_type == "try":
372
+ # Try-except block
373
+ current_id = self._generate_try_except(step, current_id)
374
+
375
+ elif step_type == "loop":
376
+ # Loop (for/while)
377
+ current_id = self._generate_loop(step, current_id)
378
+
379
+ elif step_type == "match":
380
+ # Match-case statement (Python 3.10+)
381
+ current_id = self._generate_match(step, current_id)
382
+
383
+ return current_id
384
+
385
+ def _generate_conditional(
386
+ self,
387
+ condition: dict[str, Any],
388
+ prev_id: str,
389
+ edge_label: str = "",
390
+ incoming_executed: bool | None = None,
391
+ ) -> str:
392
+ """
393
+ Generate conditional branch (if/else) with execution highlighting.
394
+
395
+ Args:
396
+ condition: Condition step dictionary
397
+ prev_id: Previous node ID
398
+ edge_label: Optional label for edge from prev_id (e.g., "|No|")
399
+ incoming_executed: Whether the incoming edge was executed (for nested conditions)
400
+
401
+ Returns:
402
+ ID of merge node
403
+ """
404
+ # Condition node (diamond shape)
405
+ cond_id = self._next_node_id()
406
+ test_expr = condition.get("test", "condition")
407
+
408
+ # Sanitize test expression for Mermaid - remove ALL problematic characters
409
+ # Replace dict/list accessors and special chars
410
+ test_expr = (
411
+ test_expr.replace('"', "'")
412
+ .replace("{", "(")
413
+ .replace("}", ")")
414
+ .replace("[", ".")
415
+ .replace("]", "")
416
+ .replace("'", "")
417
+ )
418
+ # Limit length to avoid overflow
419
+ if len(test_expr) > 40:
420
+ test_expr = test_expr[:37] + "..."
421
+
422
+ # Use diamond shape for condition - correct Mermaid syntax
423
+ self.lines.append(f" {cond_id}{{{test_expr}?}}")
424
+
425
+ # Process branches first to determine execution status
426
+ if_branch = condition.get("if_branch", [])
427
+ else_branch = condition.get("else_branch", [])
428
+
429
+ # Check if branches contain executed activities
430
+ if_has_executed = self._branch_has_executed_activity(if_branch)
431
+ else_has_executed = self._branch_has_executed_activity(else_branch)
432
+
433
+ # Draw edge from prev_id to this condition
434
+ # Use incoming_executed if provided (for nested conditions), otherwise check branches
435
+ edge_executed = (
436
+ incoming_executed
437
+ if incoming_executed is not None
438
+ else (if_has_executed or else_has_executed)
439
+ )
440
+
441
+ if edge_label:
442
+ self.lines.append(f" {prev_id} -->{edge_label} {cond_id}")
443
+ else:
444
+ self.lines.append(f" {prev_id} --> {cond_id}")
445
+ if edge_executed:
446
+ self.executed_edges.append(self.edge_counter)
447
+ self.edge_counter += 1
448
+
449
+ self.lines.append(f" style {cond_id} fill:#fff3e0,stroke:#ff9800,stroke-width:2px")
450
+
451
+ # Create merge node with invisible/minimal label
452
+ merge_id = self._next_node_id()
453
+ # Use a minimal circle node for merging (instead of showing "N4")
454
+ self.lines.append(f" {merge_id}(( ))") # Small empty circle
455
+ self.lines.append(f" style {merge_id} fill:#ffffff,stroke:#ddd,stroke-width:1px")
456
+
457
+ # If branch
458
+ if if_branch:
459
+ # Generate if branch steps
460
+ if_start = cond_id
461
+ for i, step in enumerate(if_branch):
462
+ # Pass branch execution status ONLY for the first edge from condition
463
+ branch_executed = if_has_executed if i == 0 and if_start == cond_id else None
464
+ if_start = self._process_single_step(
465
+ step, if_start, "|Yes|" if if_start == cond_id else "", branch_executed
466
+ )
467
+ # Connect final node of if branch to merge
468
+ self.lines.append(f" {if_start} --> {merge_id}")
469
+ if if_has_executed:
470
+ self.executed_edges.append(self.edge_counter)
471
+ self.edge_counter += 1
472
+ else:
473
+ # No if branch - connect condition directly to merge
474
+ self.lines.append(f" {cond_id} -->|Yes| {merge_id}")
475
+ if if_has_executed:
476
+ self.executed_edges.append(self.edge_counter)
477
+ self.edge_counter += 1
478
+
479
+ # Else branch
480
+ if else_branch:
481
+ # Generate else branch steps
482
+ else_start = cond_id
483
+ for i, step in enumerate(else_branch):
484
+ # Pass branch execution status ONLY for the first edge from condition
485
+ branch_executed = else_has_executed if i == 0 and else_start == cond_id else None
486
+ else_start = self._process_single_step(
487
+ step, else_start, "|No|" if else_start == cond_id else "", branch_executed
488
+ )
489
+ # Connect final node of else branch to merge
490
+ self.lines.append(f" {else_start} --> {merge_id}")
491
+ if else_has_executed:
492
+ self.executed_edges.append(self.edge_counter)
493
+ self.edge_counter += 1
494
+ else:
495
+ # No else branch - connect condition directly to merge
496
+ self.lines.append(f" {cond_id} -->|No| {merge_id}")
497
+ if else_has_executed:
498
+ self.executed_edges.append(self.edge_counter)
499
+ self.edge_counter += 1
500
+
501
+ return merge_id
502
+
503
+ def _process_single_step(
504
+ self,
505
+ step: dict[str, Any],
506
+ prev_id: str,
507
+ edge_label: str = "",
508
+ branch_executed: bool | None = None,
509
+ ) -> str:
510
+ """
511
+ Process a single step and return the new current ID.
512
+
513
+ Args:
514
+ step: Step dictionary
515
+ prev_id: Previous node ID
516
+ edge_label: Optional label for the edge (e.g., "|Yes|")
517
+ branch_executed: Optional branch execution status (for conditional branch edges)
518
+
519
+ Returns:
520
+ ID of the generated node
521
+ """
522
+ step_type = step.get("type")
523
+
524
+ if step_type == "activity":
525
+ node_id = self._next_node_id()
526
+ func_name = step.get("activity_name", step.get("function", "unknown"))
527
+ # Use branch execution status if provided (for conditional edges),
528
+ # otherwise use activity execution status
529
+ if branch_executed is not None:
530
+ executed = branch_executed
531
+ else:
532
+ executed = func_name in self.executed_activities
533
+
534
+ # Get execution count and status for this activity
535
+ exec_count = self.activity_execution_counts.get(func_name, 1)
536
+ status = self.activity_status_map.get(func_name)
537
+
538
+ # Determine label prefix based on status
539
+ if status == "completed":
540
+ label_prefix = "✅ "
541
+ elif status == "failed":
542
+ label_prefix = "❌ "
543
+ elif status == "compensated":
544
+ label_prefix = "🔄 "
545
+ elif status is not None:
546
+ # Other executed statuses (running, waiting, etc.)
547
+ label_prefix = "⏳ "
548
+ else:
549
+ # Not executed
550
+ label_prefix = ""
551
+
552
+ # Build label with execution count badge if >= 2
553
+ if exec_count >= 2:
554
+ label = f"{label_prefix}{func_name} ({exec_count}x)"
555
+ else:
556
+ label = f"{label_prefix}{func_name}"
557
+
558
+ # Style based on status
559
+ if status == "completed":
560
+ style_color = "fill:#d4edda,stroke:#28a745,stroke-width:3px" # Green
561
+ elif status == "failed":
562
+ style_color = "fill:#f8d7da,stroke:#dc3545,stroke-width:3px" # Red
563
+ elif status == "compensated":
564
+ style_color = (
565
+ "fill:#ffe6f0,stroke:#e91e63,stroke-width:3px,stroke-dasharray:5" # Pink
566
+ )
567
+ elif status is not None:
568
+ # Other executed statuses
569
+ style_color = "fill:#fff3cd,stroke:#ffc107,stroke-width:3px" # Yellow
570
+ else:
571
+ # Not executed
572
+ style_color = "fill:#f5f5f5,stroke:#ccc,stroke-width:1px" # Gray
573
+
574
+ self.lines.append(f' {node_id}["{label}"]')
575
+ self.lines.append(f" style {node_id} {style_color}")
576
+
577
+ # Add click event only for executed nodes
578
+ if executed and func_name in self.activity_id_map:
579
+ activity_id = self.activity_id_map[func_name]
580
+ self.lines.append(
581
+ f' click {node_id} call emitEvent("activity_click", "{self.instance_id}", "{activity_id}")'
582
+ )
583
+
584
+ # Edge with optional label
585
+ # Correct Mermaid syntax:
586
+ # Solid: A --> B or A -->|label| B
587
+ # Dashed: A -.-> B or A -.->|label| B
588
+ if edge_label:
589
+ # Edge from condition node with label (e.g., |Yes| or |No|)
590
+ arrow = "-->" if executed else "-.->"
591
+ self.lines.append(f" {prev_id} {arrow}{edge_label} {node_id}")
592
+ else:
593
+ # Normal edge without label
594
+ arrow = "-->" if executed else "-.->"
595
+ self.lines.append(f" {prev_id} {arrow} {node_id}")
596
+
597
+ # Track edge index for linkStyle
598
+ if executed:
599
+ self.executed_edges.append(self.edge_counter)
600
+ self.edge_counter += 1
601
+
602
+ return node_id
603
+
604
+ elif step_type == "condition":
605
+ # Handle nested conditional
606
+ # Recursively generate the nested condition
607
+ # Pass edge_label and branch_executed (as incoming_executed)
608
+ return self._generate_conditional(step, prev_id, edge_label, branch_executed)
609
+
610
+ # For other step types, fall back to parent implementation
611
+ return prev_id
612
+
613
+ def _branch_has_executed_activity(self, branch: list[dict[str, Any]]) -> bool:
614
+ """
615
+ Check if a branch contains any executed activities.
616
+
617
+ For conditional branches (if/elif/else), we only check DIRECT activities
618
+ in the branch, not nested conditions. Nested conditions are evaluated
619
+ separately when _generate_conditional is called recursively.
620
+
621
+ Args:
622
+ branch: List of step dictionaries representing a branch
623
+
624
+ Returns:
625
+ True if any direct activity in the branch was executed
626
+ """
627
+ for step in branch:
628
+ step_type = step.get("type")
629
+
630
+ if step_type == "activity":
631
+ func_name = step.get("activity_name", step.get("function", "unknown"))
632
+ if func_name in self.executed_activities:
633
+ return True
634
+
635
+ elif step_type == "condition":
636
+ # Skip nested conditions - they are handled separately
637
+ # Only check direct activities in this branch
638
+ pass
639
+
640
+ elif step_type == "loop":
641
+ # For loops, check recursively since the loop body is part of
642
+ # the same execution path
643
+ body = step.get("body", [])
644
+ if self._branch_has_executed_activity(body):
645
+ return True
646
+
647
+ elif step_type == "match":
648
+ # For match statements, check recursively
649
+ cases = step.get("cases", [])
650
+ for case in cases:
651
+ case_body = case.get("body", [])
652
+ if self._branch_has_executed_activity(case_body):
653
+ return True
654
+
655
+ return False
656
+
657
+ def _branch_has_unclaimed_executed_activity(
658
+ self, branch: list[dict[str, Any]], claimed_activities: set[str]
659
+ ) -> bool:
660
+ """
661
+ Check if a branch contains executed activities that haven't been claimed yet.
662
+
663
+ This method is used to prevent multiple branches with the same activity name
664
+ from all being marked as executed in if-elif-else chains. Only the first
665
+ branch (in order) that contains an unclaimed executed activity will be marked.
666
+
667
+ Args:
668
+ branch: List of step dictionaries representing a branch
669
+ claimed_activities: Set of activity names already claimed by previous branches
670
+ (will be modified to add newly claimed activities)
671
+
672
+ Returns:
673
+ True if the branch contains unclaimed executed activities
674
+ """
675
+ found_unclaimed = False
676
+
677
+ for step in branch:
678
+ step_type = step.get("type")
679
+
680
+ if step_type == "activity":
681
+ func_name = step.get("activity_name", step.get("function", "unknown"))
682
+ if func_name in self.executed_activities and func_name not in claimed_activities:
683
+ # This branch has an unclaimed executed activity
684
+ # Claim it so other branches won't use it
685
+ claimed_activities.add(func_name)
686
+ found_unclaimed = True
687
+
688
+ elif step_type == "condition":
689
+ # Skip nested conditions - they are handled separately
690
+ pass
691
+
692
+ elif step_type == "loop":
693
+ # For loops, check recursively
694
+ body = step.get("body", [])
695
+ if self._branch_has_unclaimed_executed_activity(body, claimed_activities):
696
+ found_unclaimed = True
697
+
698
+ elif step_type == "match":
699
+ # For match statements, check recursively
700
+ cases = step.get("cases", [])
701
+ for case in cases:
702
+ case_body = case.get("body", [])
703
+ if self._branch_has_unclaimed_executed_activity(case_body, claimed_activities):
704
+ found_unclaimed = True
705
+
706
+ return found_unclaimed
707
+
708
+ def _generate_loop(self, loop: dict[str, Any], prev_id: str) -> str:
709
+ """
710
+ Generate loop structure with execution highlighting and minimal exit node.
711
+
712
+ Args:
713
+ loop: Loop dictionary
714
+ prev_id: Previous node ID
715
+
716
+ Returns:
717
+ ID of exit node after loop
718
+ """
719
+ loop_type = loop.get("loop_type", "loop")
720
+ loop_id = self._next_node_id()
721
+
722
+ # Generate loop label
723
+ if loop_type == "for":
724
+ target = loop.get("target", "item")
725
+ iter_expr = loop.get("iter", "items")
726
+ label = f"for {target} in {iter_expr}"
727
+ else: # while
728
+ test = loop.get("test", "condition")
729
+ label = f"while {test}"
730
+
731
+ # Create loop node
732
+ self.lines.append(f' {loop_id}["{label}"]')
733
+ self.lines.append(f" {prev_id} --> {loop_id}")
734
+ self.edge_counter += 1 # Edge from prev to loop
735
+ self.lines.append(f" style {loop_id} fill:#fff0f0")
736
+
737
+ # Check if loop body contains executed activities
738
+ body = loop.get("body", [])
739
+ body_has_executed = self._branch_has_executed_activity(body)
740
+
741
+ # Process loop body
742
+ if body:
743
+ body_end = self._generate_steps(body, loop_id)
744
+ # Loop back edge
745
+ self.lines.append(f" {body_end} -.loop.-> {loop_id}")
746
+ if body_has_executed:
747
+ self.executed_edges.append(self.edge_counter)
748
+ self.edge_counter += 1
749
+
750
+ # Create exit node as small empty circle (instead of labeled node)
751
+ exit_id = self._next_node_id()
752
+ self.lines.append(f" {exit_id}(( ))") # Small empty circle
753
+ self.lines.append(f" style {exit_id} fill:#ffffff,stroke:#ddd,stroke-width:1px")
754
+ self.lines.append(f" {loop_id} -->|exit| {exit_id}")
755
+ self.edge_counter += 1
756
+
757
+ return exit_id
758
+
759
+ def _generate_match(self, match: dict[str, Any], prev_id: str) -> str:
760
+ """
761
+ Generate match-case structure (Python 3.10+) with execution highlighting.
762
+
763
+ Args:
764
+ match: Match block dictionary
765
+ prev_id: Previous node ID
766
+
767
+ Returns:
768
+ ID of merge node after match
769
+ """
770
+ # Match node (diamond shape for the subject)
771
+ match_id = self._next_node_id()
772
+ subject = match.get("subject", "value")
773
+
774
+ # Sanitize subject expression for Mermaid
775
+ subject = (
776
+ subject.replace('"', "'")
777
+ .replace("{", "(")
778
+ .replace("}", ")")
779
+ .replace("[", ".")
780
+ .replace("]", "")
781
+ .replace("'", "")
782
+ )
783
+ if len(subject) > 30:
784
+ subject = subject[:27] + "..."
785
+
786
+ self.lines.append(f" {match_id}{{{{match {subject}}}}}")
787
+ self.lines.append(f" {prev_id} --> {match_id}")
788
+ self.edge_counter += 1
789
+ self.lines.append(f" style {match_id} fill:#e8f5e9,stroke:#4caf50,stroke-width:2px")
790
+
791
+ # Create merge node
792
+ merge_id = self._next_node_id()
793
+ self.lines.append(f" {merge_id}(( ))") # Small empty circle for merge
794
+ self.lines.append(f" style {merge_id} fill:#ffffff,stroke:#ddd,stroke-width:1px")
795
+
796
+ # Process each case
797
+ cases = match.get("cases", [])
798
+ for _i, case in enumerate(cases):
799
+ pattern = case.get("pattern", "_")
800
+ guard = case.get("guard")
801
+ body = case.get("body", [])
802
+
803
+ # Check if this case contains executed activities
804
+ case_has_executed = self._branch_has_executed_activity(body)
805
+
806
+ # Sanitize pattern for Mermaid
807
+ pattern = (
808
+ pattern.replace('"', "'").replace("{", "(").replace("}", ")").replace("|", " or ")
809
+ )
810
+ if len(pattern) > 25:
811
+ pattern = pattern[:22] + "..."
812
+
813
+ # Create label for the edge
814
+ if guard:
815
+ # Sanitize guard
816
+ guard_str = guard.replace('"', "'").replace("{", "(").replace("}", ")")
817
+ if len(guard_str) > 15:
818
+ guard_str = guard_str[:12] + "..."
819
+ edge_label = f"case {pattern} if {guard_str}"
820
+ else:
821
+ edge_label = f"case {pattern}"
822
+
823
+ # Process case body
824
+ if body:
825
+ # Create a case-specific start node to ensure proper branching visualization
826
+ case_start_id = self._next_node_id()
827
+ self.lines.append(f" {case_start_id}(( ))")
828
+
829
+ # Edge from match to case start
830
+ arrow = "-->" if case_has_executed else "-.->"
831
+ self.lines.append(f" {match_id} {arrow}|{edge_label}| {case_start_id}")
832
+ if case_has_executed:
833
+ self.executed_edges.append(self.edge_counter)
834
+ self.edge_counter += 1
835
+
836
+ self.lines.append(
837
+ f" style {case_start_id} fill:#fff,stroke:#999,stroke-width:1px"
838
+ )
839
+
840
+ # Generate body steps starting from case_start_id
841
+ case_end = self._generate_steps(body, case_start_id)
842
+
843
+ # Connect final node of case to merge
844
+ self.lines.append(f" {case_end} --> {merge_id}")
845
+ if case_has_executed:
846
+ self.executed_edges.append(self.edge_counter)
847
+ self.edge_counter += 1
848
+ else:
849
+ # Empty case body - direct connection to merge
850
+ arrow = "-->" if case_has_executed else "-.->"
851
+ self.lines.append(f" {match_id} {arrow}|{edge_label}| {merge_id}")
852
+ if case_has_executed:
853
+ self.executed_edges.append(self.edge_counter)
854
+ self.edge_counter += 1
855
+
856
+ return merge_id
857
+
858
+ def _generate_multi_conditional(self, multi_cond: dict[str, Any], prev_id: str) -> str:
859
+ """
860
+ Generate multi-branch conditional (if-elif-else chain) with execution highlighting.
861
+
862
+ Similar to match-case rendering but for if-elif-else chains.
863
+
864
+ Args:
865
+ multi_cond: Multi-condition dictionary with branches
866
+ prev_id: Previous node ID
867
+
868
+ Returns:
869
+ ID of merge node after conditional
870
+ """
871
+ # Decision node (diamond shape)
872
+ decision_id = self._next_node_id()
873
+
874
+ # Use a generic label for the decision point
875
+ self.lines.append(f" {decision_id}{{{{if-elif-else}}}}")
876
+ self.lines.append(f" {prev_id} --> {decision_id}")
877
+ self.edge_counter += 1
878
+ self.lines.append(f" style {decision_id} fill:#e8f5e9,stroke:#4caf50,stroke-width:2px")
879
+
880
+ # Create merge node
881
+ merge_id = self._next_node_id()
882
+ self.lines.append(f" {merge_id}(( ))") # Small empty circle for merge
883
+ self.lines.append(f" style {merge_id} fill:#ffffff,stroke:#ddd,stroke-width:1px")
884
+
885
+ # Process each branch
886
+ branches = multi_cond.get("branches", [])
887
+
888
+ # Track which activities have been claimed by previous branches
889
+ # This prevents multiple branches with the same activity name from all being marked as executed
890
+ claimed_activities: set[str] = set()
891
+
892
+ # IMPORTANT: Process branches in REVERSE order to determine execution
893
+ # This ensures that later branches (which typically have more specific conditions)
894
+ # claim executed activities first, preventing earlier branches from being
895
+ # incorrectly marked as executed.
896
+ #
897
+ # Example: if both Branch 1 and Branch 4 contain auto_reject_loan:
898
+ # - Process Branch 4 first → finds auto_reject_loan → claims it → marked as executed
899
+ # - Process Branch 1 second → auto_reject_loan already claimed → not marked as executed
900
+ #
901
+ # We build a list of (index, branch, execution_status) tuples in reverse order,
902
+ # then render them in normal order for correct diagram layout.
903
+ branch_execution_status: list[bool] = []
904
+
905
+ for i in reversed(range(len(branches))):
906
+ branch = branches[i]
907
+ test = branch.get("test") # None for else branch
908
+ body = branch.get("body", [])
909
+
910
+ # Check if this branch contains executed activities that haven't been claimed yet
911
+ branch_has_executed = self._branch_has_unclaimed_executed_activity(
912
+ body, claimed_activities
913
+ )
914
+ branch_execution_status.append(branch_has_executed)
915
+
916
+ # Reverse the execution status list to match normal branch order
917
+ branch_execution_status.reverse()
918
+
919
+ # Now render branches in normal order (top to bottom)
920
+ for i, branch in enumerate(branches):
921
+ test = branch.get("test") # None for else branch
922
+ body = branch.get("body", [])
923
+
924
+ # Use pre-computed execution status (from reverse-order processing)
925
+ branch_has_executed = branch_execution_status[i]
926
+
927
+ # Create edge label
928
+ if test is None:
929
+ # This is the else branch
930
+ edge_label = "else"
931
+ elif i == 0:
932
+ # First branch (if)
933
+ test_str = self._sanitize_condition_expr(test)
934
+ edge_label = f"if {test_str}"
935
+ else:
936
+ # elif branches
937
+ test_str = self._sanitize_condition_expr(test)
938
+ edge_label = f"elif {test_str}"
939
+
940
+ # Process branch body
941
+ if body:
942
+ # Create a branch-specific start node to ensure proper branching visualization
943
+ branch_start_id = self._next_node_id()
944
+ self.lines.append(f" {branch_start_id}(( ))")
945
+
946
+ # Edge from decision to branch start
947
+ arrow = "-->" if branch_has_executed else "-.->"
948
+ self.lines.append(f" {decision_id} {arrow}|{edge_label}| {branch_start_id}")
949
+ if branch_has_executed:
950
+ self.executed_edges.append(self.edge_counter)
951
+ self.edge_counter += 1
952
+
953
+ self.lines.append(
954
+ f" style {branch_start_id} fill:#fff,stroke:#999,stroke-width:1px"
955
+ )
956
+
957
+ # Generate body steps starting from branch_start_id
958
+ branch_end = self._generate_steps(body, branch_start_id)
959
+
960
+ # Connect final node of branch to merge
961
+ self.lines.append(f" {branch_end} --> {merge_id}")
962
+ if branch_has_executed:
963
+ self.executed_edges.append(self.edge_counter)
964
+ self.edge_counter += 1
965
+ else:
966
+ # Empty branch body - direct connection to merge
967
+ arrow = "-->" if branch_has_executed else "-.->"
968
+ self.lines.append(f" {decision_id} {arrow}|{edge_label}| {merge_id}")
969
+ if branch_has_executed:
970
+ self.executed_edges.append(self.edge_counter)
971
+ self.edge_counter += 1
972
+
973
+ return merge_id
974
+
975
+ def _sanitize_condition_expr(self, expr: str) -> str:
976
+ """
977
+ Sanitize condition expression for Mermaid edge labels.
978
+
979
+ Args:
980
+ expr: Condition expression string
981
+
982
+ Returns:
983
+ Sanitized expression suitable for Mermaid
984
+ """
985
+ # Replace problematic characters for Mermaid
986
+ sanitized = (
987
+ expr.replace('"', "'")
988
+ .replace("{", "(")
989
+ .replace("}", ")")
990
+ .replace("[", ".")
991
+ .replace("]", "")
992
+ .replace("'", "")
993
+ )
994
+
995
+ # Limit length to avoid overflow
996
+ if len(sanitized) > 35:
997
+ sanitized = sanitized[:32] + "..."
998
+
999
+ return sanitized
1000
+
1001
+
1002
+ def generate_hybrid_mermaid(
1003
+ _workflow_name: str,
1004
+ instance_id: str,
1005
+ history: list[dict[str, Any]],
1006
+ source_code: str,
1007
+ compensations: dict[str, dict[str, Any]] | None = None,
1008
+ workflow_status: str = "running",
1009
+ ) -> str:
1010
+ """
1011
+ Generate hybrid Mermaid diagram combining static analysis and execution history.
1012
+
1013
+ Args:
1014
+ workflow_name: Name of the workflow
1015
+ instance_id: Workflow instance ID
1016
+ history: List of execution activity dictionaries
1017
+ source_code: Source code of the workflow function
1018
+ compensations: Optional mapping of activity_id -> compensation info
1019
+ workflow_status: Status of the workflow instance (running, completed, failed, etc.)
1020
+
1021
+ Returns:
1022
+ Mermaid flowchart diagram code with execution highlighting
1023
+ """
1024
+ if compensations is None:
1025
+ compensations = {}
1026
+ try:
1027
+ # Analyze workflow structure from source code
1028
+ analyzer = WorkflowAnalyzer()
1029
+ workflows = analyzer.analyze(source_code)
1030
+
1031
+ if not workflows:
1032
+ # Fallback to history-only diagram
1033
+ return generate_interactive_mermaid(instance_id, history)
1034
+
1035
+ workflow_structure = workflows[0]
1036
+
1037
+ # Extract executed activities from history
1038
+ executed_activities = set()
1039
+ activity_id_map = {}
1040
+ activity_execution_counts: dict[str, int] = {}
1041
+ activity_status_map: dict[str, str] = {} # activity_name -> status
1042
+ executed_compensations: list[dict[str, Any]] = [] # Track compensation executions
1043
+
1044
+ for activity_data in history:
1045
+ activity_name = activity_data.get("activity_name")
1046
+ activity_id = activity_data.get("activity_id")
1047
+ status = activity_data.get("status")
1048
+
1049
+ if activity_name and activity_id:
1050
+ # Normalize activity name (remove "Compensate: " prefix if present)
1051
+ normalized_name = activity_name
1052
+ if activity_name.startswith("Compensate: "):
1053
+ normalized_name = activity_name.replace("Compensate: ", "")
1054
+ # Track compensation executions separately
1055
+ executed_compensations.append(
1056
+ {
1057
+ "activity_name": normalized_name,
1058
+ "activity_id": activity_id,
1059
+ "status": status,
1060
+ }
1061
+ )
1062
+ else:
1063
+ executed_activities.add(normalized_name)
1064
+ # Map first occurrence for click events
1065
+ if normalized_name not in activity_id_map:
1066
+ activity_id_map[normalized_name] = activity_id
1067
+ # Count execution occurrences
1068
+ activity_execution_counts[normalized_name] = (
1069
+ activity_execution_counts.get(normalized_name, 0) + 1
1070
+ )
1071
+ # Store latest status for each activity
1072
+ if status:
1073
+ activity_status_map[normalized_name] = status
1074
+
1075
+ # Generate hybrid diagram
1076
+ generator = HybridMermaidGenerator(
1077
+ instance_id,
1078
+ executed_activities,
1079
+ compensations,
1080
+ workflow_status,
1081
+ activity_status_map,
1082
+ )
1083
+ generator.activity_id_map = activity_id_map
1084
+ generator.activity_execution_counts = activity_execution_counts
1085
+ generator.executed_compensations = executed_compensations # Add compensation executions
1086
+
1087
+ return generator.generate(workflow_structure)
1088
+
1089
+ except Exception as e:
1090
+ # Fallback to history-only diagram on any error
1091
+ print(f"Warning: Hybrid diagram generation failed, falling back to history-only: {e}")
1092
+ return generate_interactive_mermaid(instance_id, history)
1093
+
1094
+
1095
+ def format_json_for_display(data: Any) -> str:
1096
+ """
1097
+ Format data as pretty-printed JSON.
1098
+
1099
+ Args:
1100
+ data: Data to format
1101
+
1102
+ Returns:
1103
+ Formatted JSON string
1104
+ """
1105
+ return json.dumps(data, indent=2, ensure_ascii=False)