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.
- edda/__init__.py +56 -0
- edda/activity.py +505 -0
- edda/app.py +996 -0
- edda/compensation.py +326 -0
- edda/context.py +489 -0
- edda/events.py +505 -0
- edda/exceptions.py +64 -0
- edda/hooks.py +284 -0
- edda/locking.py +322 -0
- edda/outbox/__init__.py +15 -0
- edda/outbox/relayer.py +274 -0
- edda/outbox/transactional.py +112 -0
- edda/pydantic_utils.py +316 -0
- edda/replay.py +799 -0
- edda/retry.py +207 -0
- edda/serialization/__init__.py +9 -0
- edda/serialization/base.py +83 -0
- edda/serialization/json.py +102 -0
- edda/storage/__init__.py +9 -0
- edda/storage/models.py +194 -0
- edda/storage/protocol.py +737 -0
- edda/storage/sqlalchemy_storage.py +1809 -0
- edda/viewer_ui/__init__.py +20 -0
- edda/viewer_ui/app.py +1399 -0
- edda/viewer_ui/components.py +1105 -0
- edda/viewer_ui/data_service.py +880 -0
- edda/visualizer/__init__.py +11 -0
- edda/visualizer/ast_analyzer.py +383 -0
- edda/visualizer/mermaid_generator.py +355 -0
- edda/workflow.py +218 -0
- edda_framework-0.1.0.dist-info/METADATA +748 -0
- edda_framework-0.1.0.dist-info/RECORD +35 -0
- edda_framework-0.1.0.dist-info/WHEEL +4 -0
- edda_framework-0.1.0.dist-info/entry_points.txt +2 -0
- edda_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|