standard_procedure_operations 0.5.1 → 0.5.3
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.
- checksums.yaml +4 -4
- data/README.md +19 -14
- data/app/models/operations/task/background.rb +11 -0
- data/app/models/operations/task.rb +2 -1
- data/db/migrate/20250403075414_add_becomes_zombie_at_field.rb +6 -0
- data/lib/operations/exporters/svg.rb +399 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +1 -1
- data/lib/tasks/operations_tasks.rake +4 -4
- metadata +5 -4
- data/lib/operations/exporters/graphviz.rb +0 -164
- /data/db/migrate/{20250309_create_operations_task_participants.rb → 20250309160616_create_operations_task_participants.rb} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 05e2b09556bdcf1a1be75461eb956d4be95e0a736c49cf7cf40d83feb16e7ee3
|
4
|
+
data.tar.gz: 5fadaecabdc294707fc6a82f57c8163390494c7bb57afddb2701a9bd35ee3cc6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5db051c5929ba86f92a0eff0417fd84bc5e0aa5f368d5ef66855b5ad94df5b205da996b96235ae8802b07fa954a09464fda892bc670fa30df65f59a7c20e67a5
|
7
|
+
data.tar.gz: d155b7c72d0f452f792e75cae431e196f8dd22d844e9953959ec95fa1582a963644777317ba33c7b1c74a43c28b38c4489ae166a1ebce64f83d1c58ad0d6c55d
|
data/README.md
CHANGED
@@ -295,7 +295,7 @@ For example, you create your task as:
|
|
295
295
|
@alice = User.find 123
|
296
296
|
@task = DoSomethingImportant.call user: @alice
|
297
297
|
```
|
298
|
-
There will
|
298
|
+
There will be a `TaskParticipant` record with a `context` of "data", `role` of "user" and `participant` of `@alice`.
|
299
299
|
|
300
300
|
Likewise, you can see all the tasks that Alice was involved with using:
|
301
301
|
```ruby
|
@@ -470,6 +470,16 @@ class WaitForSomething < Operations::Task
|
|
470
470
|
end
|
471
471
|
```
|
472
472
|
|
473
|
+
#### Zombie tasks
|
474
|
+
|
475
|
+
There's a chance that the `Operations::TaskRunnerJob` might get lost - maybe there's a crash in some process and the job does not restart correctly. As the process for handling background tasks relies on the task "waking up", performing the next action, then queuing up the next task-runner, if the background job does not queue as expected, the task will sit there, waiting forever.
|
476
|
+
|
477
|
+
To monitor for this, every task can be checked to see if it is a `zombie?`. This means that the current time is more than 3 times the expected delay, compared to the `updated_at` field. So if the `delay` is set to 1 minute and the task last woke up more than 3 minutes ago, it is classed as a zombie.
|
478
|
+
|
479
|
+
There are two ways to handle zombies.
|
480
|
+
- Manually; add a user interface listing your tasks with a "Restart" button. The "Restart" button calls `restart` on the task (which internally schedules a new task runner job).
|
481
|
+
- Automatically; set up a cron job which calls the `operations:restart_zombie_tasks` rake task. This rake task searches for zombie jobs and calls `restart` on them. Note that cron jobs have a minimum resolution of 1 minute so this will cause pauses in tasks with a delay measured in seconds. Also be aware that a cron job that calls a rake task will load the entire Rails stack as a new process, so be sure that your server has sufficient memory to cope. If you're using [SolidQueue](https://github.com/rails/solid_queue/), the job runner already sets up a separate "supervisor" process and allows you to define [recurring jobs](https://github.com/rails/solid_queue/#recurring-tasks) with a resolution of 1 second. This may be a suitable solution, but I've not tried it yet.
|
482
|
+
|
473
483
|
## Testing
|
474
484
|
Because operations are intended to model long, complex, flowcharts of decisions and actions, it can be a pain coming up with the combinations of inputs to test every path through the sequence.
|
475
485
|
|
@@ -550,20 +560,17 @@ If you are using RSpec, you must `require "operations/matchers"` to make the mat
|
|
550
560
|
|
551
561
|
## Visualization
|
552
562
|
|
553
|
-
Operations tasks can be visualized as flowcharts using the built-in
|
563
|
+
Operations tasks can be visualized as flowcharts using the built-in SVG exporter. This helps you understand the flow of your operations and can be useful for documentation.
|
554
564
|
|
555
565
|
```ruby
|
556
|
-
# Export a task to
|
557
|
-
exporter = Operations::Exporters::
|
558
|
-
|
559
|
-
# Save as PNG
|
560
|
-
exporter.save("my_task_flow.png")
|
566
|
+
# Export a task to SVG
|
567
|
+
exporter = Operations::Exporters::SVG.new(MyTask)
|
561
568
|
|
562
569
|
# Save as SVG
|
563
|
-
exporter.save("my_task_flow.svg"
|
570
|
+
exporter.save("my_task_flow.svg")
|
564
571
|
|
565
|
-
# Get
|
566
|
-
|
572
|
+
# Get SVG content directly
|
573
|
+
svg_string = exporter.to_svg
|
567
574
|
```
|
568
575
|
|
569
576
|
### Custom Condition Labels
|
@@ -588,8 +595,6 @@ The visualization includes:
|
|
588
595
|
- Transition conditions between states with custom labels when provided
|
589
596
|
- Special handling for custom transition blocks
|
590
597
|
|
591
|
-
Note: To use the GraphViz exporter, you need to have the GraphViz tool installed on your system. On macOS, you can install it with `brew install graphviz`, on Ubuntu with `apt-get install graphviz`, and on Windows with the installer from the [GraphViz website](https://graphviz.org/download/).
|
592
|
-
|
593
598
|
## Installation
|
594
599
|
Step 1: Add the gem to your Rails application's Gemfile:
|
595
600
|
```ruby
|
@@ -632,5 +637,5 @@ The gem is available as open source under the terms of the [LGPL License](/LICEN
|
|
632
637
|
- [x] Make Operations::Task work in the background using ActiveJob
|
633
638
|
- [x] Add pause/resume capabilities (for example, when a task needs to wait for user input)
|
634
639
|
- [x] Add wait for sub-tasks capabilities
|
635
|
-
- [x] Add
|
636
|
-
- [ ] Option to change background job queue and priority settings
|
640
|
+
- [x] Add visualization export for task flows
|
641
|
+
- [ ] Option to change background job queue and priority settings
|
@@ -1,6 +1,11 @@
|
|
1
1
|
module Operations::Task::Background
|
2
2
|
extend ActiveSupport::Concern
|
3
3
|
|
4
|
+
included do
|
5
|
+
scope :zombies, -> { zombies_at(Time.now) }
|
6
|
+
scope :zombies_at, ->(time) { where(becomes_zombie_at: ..time) }
|
7
|
+
end
|
8
|
+
|
4
9
|
class_methods do
|
5
10
|
def delay(value) = @background_delay = value
|
6
11
|
|
@@ -15,9 +20,15 @@ module Operations::Task::Background
|
|
15
20
|
def timeout_handler = @on_timeout
|
16
21
|
|
17
22
|
def with_timeout(data) = data.merge(_execution_timeout: execution_timeout.from_now.utc)
|
23
|
+
|
24
|
+
def restart_zombie_tasks = zombies.find_each { |t| t.restart! }
|
18
25
|
end
|
19
26
|
|
27
|
+
def zombie? = Time.now > (updated_at + zombie_delay)
|
28
|
+
|
20
29
|
private def background_delay = self.class.background_delay
|
30
|
+
private def zombie_delay = background_delay * 3
|
31
|
+
private def zombie_time = becomes_zombie_at || Time.now
|
21
32
|
private def execution_timeout = self.class.execution_timeout
|
22
33
|
private def timeout_handler = self.class.timeout_handler
|
23
34
|
private def timeout!
|
@@ -35,9 +35,10 @@ module Operations
|
|
35
35
|
end
|
36
36
|
|
37
37
|
def perform_later
|
38
|
-
waiting
|
38
|
+
update! status: "waiting", becomes_zombie_at: Time.now + zombie_delay
|
39
39
|
TaskRunnerJob.set(wait_until: background_delay.from_now).perform_later self
|
40
40
|
end
|
41
|
+
alias_method :restart!, :perform_later
|
41
42
|
|
42
43
|
def self.call(**)
|
43
44
|
build(background: false, **).tap do |task|
|
@@ -0,0 +1,399 @@
|
|
1
|
+
module Operations
|
2
|
+
module Exporters
|
3
|
+
# A pure Ruby SVG exporter for task visualization with no external dependencies
|
4
|
+
class SVG
|
5
|
+
attr_reader :task_class
|
6
|
+
|
7
|
+
COLORS = {
|
8
|
+
decision: "#4e79a7", # Blue
|
9
|
+
action: "#f28e2b", # Orange
|
10
|
+
wait: "#76b7b2", # Teal
|
11
|
+
result: "#59a14f", # Green
|
12
|
+
start: "#59a14f", # Green
|
13
|
+
block: "#bab0ab" # Grey
|
14
|
+
}
|
15
|
+
|
16
|
+
def self.export(task_class)
|
17
|
+
new(task_class).to_svg
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(task_class)
|
21
|
+
@task_class = task_class
|
22
|
+
@node_positions = {}
|
23
|
+
@next_id = 0
|
24
|
+
end
|
25
|
+
|
26
|
+
# Generate SVG representation of the task flow
|
27
|
+
def to_svg
|
28
|
+
task_hash = task_class.to_h
|
29
|
+
|
30
|
+
# Calculate node positions using simple layout algorithm
|
31
|
+
calculate_node_positions(task_hash)
|
32
|
+
|
33
|
+
# Generate SVG
|
34
|
+
generate_svg(task_hash)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Save the SVG to a file
|
38
|
+
def save(filename, format: :svg)
|
39
|
+
if format != :svg && format != :png
|
40
|
+
raise ArgumentError, "Only SVG format is supported without GraphViz"
|
41
|
+
end
|
42
|
+
|
43
|
+
File.write(filename, to_svg)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def generate_id
|
49
|
+
id = "node_#{@next_id}"
|
50
|
+
@next_id += 1
|
51
|
+
id
|
52
|
+
end
|
53
|
+
|
54
|
+
def calculate_node_positions(task_hash)
|
55
|
+
# Simple layout algorithm that positions nodes in columns by state type
|
56
|
+
# This is a minimal implementation; a real layout algorithm would be more complex
|
57
|
+
|
58
|
+
# Group nodes by type for column-based layout
|
59
|
+
nodes_by_type = {decision: [], action: [], wait: [], result: []}
|
60
|
+
|
61
|
+
task_hash[:states].each do |state_name, state_info|
|
62
|
+
nodes_by_type[state_info[:type]] << state_name if nodes_by_type.key?(state_info[:type])
|
63
|
+
end
|
64
|
+
|
65
|
+
# Process destination states from the go_to transitions to ensure they're included
|
66
|
+
task_hash[:states].each do |state_name, state_info|
|
67
|
+
state_info[:transitions]&.each do |_, target|
|
68
|
+
next if target.nil? || target.is_a?(Proc)
|
69
|
+
target_sym = target.to_sym
|
70
|
+
|
71
|
+
# Ensure target state exists in task hash
|
72
|
+
unless task_hash[:states].key?(target_sym)
|
73
|
+
# This is a placeholder for a missing state (likely a result)
|
74
|
+
task_hash[:states][target_sym] = {type: :result}
|
75
|
+
nodes_by_type[:result] << target_sym if nodes_by_type.key?(:result)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Also process next_state for actions
|
80
|
+
if state_info[:next_state] && !task_hash[:states].key?(state_info[:next_state])
|
81
|
+
task_hash[:states][state_info[:next_state]] = {type: :result}
|
82
|
+
nodes_by_type[:result] << state_info[:next_state] if nodes_by_type.key?(:result)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Calculate positions (this is simplified - a real algorithm would handle edge crossings better)
|
87
|
+
x_offset = 200 # Increased from 100 to give more space
|
88
|
+
column_width = 200
|
89
|
+
row_height = 150
|
90
|
+
|
91
|
+
# Position initial state (usually a decision)
|
92
|
+
task_hash[:initial_state]
|
93
|
+
@node_positions["START"] = [100, 100] # Moved START from 50 to 100
|
94
|
+
|
95
|
+
# Position nodes by type in columns
|
96
|
+
[:decision, :action, :wait, :result].each_with_index do |type, col_idx|
|
97
|
+
nodes_by_type[type].each_with_index do |node_name, row_idx|
|
98
|
+
# Position nodes in a grid layout
|
99
|
+
x = x_offset + (col_idx * column_width)
|
100
|
+
y = 100 + (row_idx * row_height)
|
101
|
+
@node_positions[node_name] = [x, y]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def generate_svg(task_hash)
|
107
|
+
# SVG dimensions based on node positions
|
108
|
+
max_x = @node_positions.values.map(&:first).max + 150
|
109
|
+
max_y = @node_positions.values.map(&:last).max + 150
|
110
|
+
|
111
|
+
# Start SVG document
|
112
|
+
svg = <<~SVG
|
113
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{max_x}" height="#{max_y}" viewBox="0 0 #{max_x} #{max_y}">
|
114
|
+
<defs>
|
115
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
116
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#333" />
|
117
|
+
</marker>
|
118
|
+
</defs>
|
119
|
+
SVG
|
120
|
+
|
121
|
+
# Draw edges (connections between nodes)
|
122
|
+
svg += draw_edges(task_hash)
|
123
|
+
|
124
|
+
# Draw all nodes
|
125
|
+
svg += draw_nodes(task_hash)
|
126
|
+
|
127
|
+
# Close SVG document
|
128
|
+
svg += "</svg>"
|
129
|
+
|
130
|
+
svg
|
131
|
+
end
|
132
|
+
|
133
|
+
def draw_nodes(task_hash)
|
134
|
+
svg = ""
|
135
|
+
|
136
|
+
# Add starting node
|
137
|
+
if task_hash[:initial_state] && @node_positions[task_hash[:initial_state]]
|
138
|
+
svg += draw_circle(
|
139
|
+
@node_positions["START"][0],
|
140
|
+
@node_positions["START"][1],
|
141
|
+
20,
|
142
|
+
COLORS[:start],
|
143
|
+
"START"
|
144
|
+
)
|
145
|
+
end
|
146
|
+
|
147
|
+
# Add nodes for each state
|
148
|
+
task_hash[:states].each do |state_name, state_info|
|
149
|
+
next unless @node_positions[state_name]
|
150
|
+
|
151
|
+
x, y = @node_positions[state_name]
|
152
|
+
node_label = create_node_label(state_name, state_info)
|
153
|
+
|
154
|
+
svg += case state_info[:type]
|
155
|
+
when :decision
|
156
|
+
draw_diamond(x, y, 120, 80, COLORS[:decision], node_label)
|
157
|
+
when :action
|
158
|
+
draw_rectangle(x, y, 160, 80, COLORS[:action], node_label)
|
159
|
+
when :wait
|
160
|
+
draw_rectangle(x, y, 160, 80, COLORS[:wait], node_label, dashed: true)
|
161
|
+
when :result
|
162
|
+
draw_rectangle(x, y, 160, 80, COLORS[:result], node_label)
|
163
|
+
else
|
164
|
+
draw_rectangle(x, y, 160, 80, "#cccccc", node_label)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
svg
|
169
|
+
end
|
170
|
+
|
171
|
+
def draw_edges(task_hash)
|
172
|
+
svg = ""
|
173
|
+
|
174
|
+
# Add edge from START to initial state
|
175
|
+
if task_hash[:initial_state] && @node_positions[task_hash[:initial_state]]
|
176
|
+
start_x, start_y = @node_positions["START"]
|
177
|
+
end_x, end_y = @node_positions[task_hash[:initial_state]]
|
178
|
+
svg += draw_arrow(start_x + 20, start_y, end_x - 60, end_y, "")
|
179
|
+
end
|
180
|
+
|
181
|
+
# Add edges for transitions
|
182
|
+
task_hash[:states].each do |state_name, state_info|
|
183
|
+
case state_info[:type]
|
184
|
+
when :decision
|
185
|
+
state_info[:transitions]&.each do |condition, target|
|
186
|
+
# Skip Proc targets as they're custom actions or nil targets
|
187
|
+
next if target.nil? || target.is_a?(Proc)
|
188
|
+
|
189
|
+
if @node_positions[state_name] && @node_positions[target.to_sym]
|
190
|
+
start_x, start_y = @node_positions[state_name]
|
191
|
+
end_x, end_y = @node_positions[target.to_sym]
|
192
|
+
|
193
|
+
# Use condition as label, or target state name if no condition
|
194
|
+
label = condition.to_s
|
195
|
+
if label.empty? || label == "nil"
|
196
|
+
label = "→ #{target}"
|
197
|
+
end
|
198
|
+
|
199
|
+
svg += draw_arrow(start_x + 60, start_y, end_x - 80, end_y, label)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
when :action
|
203
|
+
if state_info[:next_state] && @node_positions[state_name] && @node_positions[state_info[:next_state]]
|
204
|
+
start_x, start_y = @node_positions[state_name]
|
205
|
+
end_x, end_y = @node_positions[state_info[:next_state]]
|
206
|
+
label = "→ #{state_info[:next_state]}"
|
207
|
+
svg += draw_arrow(start_x + 80, start_y, end_x - 80, end_y, label)
|
208
|
+
end
|
209
|
+
when :wait
|
210
|
+
# Add a self-loop for wait condition
|
211
|
+
if @node_positions[state_name]
|
212
|
+
x, y = @node_positions[state_name]
|
213
|
+
svg += draw_self_loop(x, y, 160, 80, "waiting")
|
214
|
+
|
215
|
+
# Add transitions
|
216
|
+
state_info[:transitions]&.each do |condition, target|
|
217
|
+
# Skip Proc targets or nil targets
|
218
|
+
next if target.nil? || target.is_a?(Proc)
|
219
|
+
|
220
|
+
if @node_positions[target.to_sym]
|
221
|
+
start_x, start_y = @node_positions[state_name]
|
222
|
+
end_x, end_y = @node_positions[target.to_sym]
|
223
|
+
|
224
|
+
# Use condition as label, or target state name if no condition
|
225
|
+
label = condition.to_s
|
226
|
+
if label.empty? || label == "nil"
|
227
|
+
label = "→ #{target}"
|
228
|
+
end
|
229
|
+
|
230
|
+
svg += draw_arrow(start_x + 80, start_y, end_x - 80, end_y, label)
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
svg
|
238
|
+
end
|
239
|
+
|
240
|
+
# Helper methods for drawing SVG elements
|
241
|
+
|
242
|
+
def draw_rectangle(x, y, width, height, color, text, dashed: false)
|
243
|
+
style = dashed ? "fill:#{color};stroke:#333;stroke-width:2;stroke-dasharray:5,5;" : "fill:#{color};stroke:#333;stroke-width:2;"
|
244
|
+
|
245
|
+
# Create a unique ID for a group to contain both the shape and text
|
246
|
+
id = "rect-#{generate_id}"
|
247
|
+
|
248
|
+
<<~SVG
|
249
|
+
<g id="#{id}">
|
250
|
+
<rect x="#{x - width / 2}" y="#{y - height / 2}" width="#{width}" height="#{height}" rx="10" ry="10" style="#{style}" />
|
251
|
+
<text x="#{x}" y="#{y}" text-anchor="middle" fill="white" font-family="Arial" font-size="12px">#{escape_text(text)}</text>
|
252
|
+
</g>
|
253
|
+
SVG
|
254
|
+
end
|
255
|
+
|
256
|
+
def draw_diamond(x, y, width, height, color, text)
|
257
|
+
points = [
|
258
|
+
[x, y - height / 2],
|
259
|
+
[x + width / 2, y],
|
260
|
+
[x, y + height / 2],
|
261
|
+
[x - width / 2, y]
|
262
|
+
].map { |px, py| "#{px},#{py}" }.join(" ")
|
263
|
+
|
264
|
+
# Create a unique ID for a group to contain both the shape and text
|
265
|
+
id = "diamond-#{generate_id}"
|
266
|
+
|
267
|
+
<<~SVG
|
268
|
+
<g id="#{id}">
|
269
|
+
<polygon points="#{points}" style="fill:#{color};stroke:#333;stroke-width:2;" />
|
270
|
+
<text x="#{x}" y="#{y}" text-anchor="middle" fill="white" font-family="Arial" font-size="12px">#{escape_text(text)}</text>
|
271
|
+
</g>
|
272
|
+
SVG
|
273
|
+
end
|
274
|
+
|
275
|
+
def draw_circle(x, y, radius, color, text)
|
276
|
+
# Create a unique ID for a group to contain both the shape and text
|
277
|
+
id = "circle-#{generate_id}"
|
278
|
+
|
279
|
+
<<~SVG
|
280
|
+
<g id="#{id}">
|
281
|
+
<circle cx="#{x}" cy="#{y}" r="#{radius}" style="fill:#{color};stroke:#333;stroke-width:2;" />
|
282
|
+
<text x="#{x}" y="#{y}" text-anchor="middle" fill="white" font-family="Arial" font-size="12px">#{escape_text(text)}</text>
|
283
|
+
</g>
|
284
|
+
SVG
|
285
|
+
end
|
286
|
+
|
287
|
+
def draw_arrow(x1, y1, x2, y2, label, dashed: false)
|
288
|
+
# Calculate the line and add an offset to avoid overlap with nodes
|
289
|
+
dx = x2 - x1
|
290
|
+
dy = y2 - y1
|
291
|
+
length = Math.sqrt(dx * dx + dy * dy)
|
292
|
+
|
293
|
+
# Make sure we don't divide by zero
|
294
|
+
if length.zero?
|
295
|
+
return ""
|
296
|
+
end
|
297
|
+
|
298
|
+
# Calculate control points for a slight curve
|
299
|
+
# This helps when there are multiple edges between the same nodes
|
300
|
+
mx = (x1 + x2) / 2
|
301
|
+
my = (y1 + y2) / 2
|
302
|
+
|
303
|
+
# Add slight curve for better visualization
|
304
|
+
cx = mx + dy * 0.2
|
305
|
+
cy = my - dx * 0.2
|
306
|
+
|
307
|
+
style = dashed ? "stroke:#333;stroke-width:2;fill:none;stroke-dasharray:5,5;" : "stroke:#333;stroke-width:2;fill:none;"
|
308
|
+
marker = "marker-end=\"url(#arrowhead)\""
|
309
|
+
|
310
|
+
# Create a unique ID for a group to contain both the path and label
|
311
|
+
id = "arrow-#{generate_id}"
|
312
|
+
|
313
|
+
svg = <<~SVG
|
314
|
+
<g id="#{id}">
|
315
|
+
<path d="M#{x1},#{y1} Q#{cx},#{cy} #{x2},#{y2}" style="#{style}" #{marker} />
|
316
|
+
SVG
|
317
|
+
|
318
|
+
# Add label if provided
|
319
|
+
if label && !label.empty?
|
320
|
+
# Create a white background for the label to improve readability
|
321
|
+
svg += <<~SVG
|
322
|
+
<rect x="#{mx + dy * 0.1 - 40}" y="#{my - dx * 0.1 - 10}" width="80" height="20" rx="5" ry="5" fill="white" fill-opacity="0.8" />
|
323
|
+
<text x="#{mx + dy * 0.1}" y="#{my - dx * 0.1}" text-anchor="middle" fill="#333" font-family="Arial" font-size="10px">#{escape_text(label)}</text>
|
324
|
+
SVG
|
325
|
+
end
|
326
|
+
|
327
|
+
svg += "</g>"
|
328
|
+
svg
|
329
|
+
end
|
330
|
+
|
331
|
+
def draw_self_loop(x, y, width, height, label)
|
332
|
+
# Create a self-loop arrow (circle with an arrow)
|
333
|
+
style = "stroke:#333;stroke-width:2;fill:none;stroke-dasharray:5,5;"
|
334
|
+
|
335
|
+
# Create a unique ID for a group to contain both the loop and label
|
336
|
+
id = "loop-#{generate_id}"
|
337
|
+
|
338
|
+
svg = <<~SVG
|
339
|
+
<g id="#{id}">
|
340
|
+
<ellipse cx="#{x - width / 2}" cy="#{y}" rx="20" ry="30" style="#{style}" />
|
341
|
+
<path d="M#{x - width / 2 - 10},#{y - 10} L#{x - width / 2 - 20},#{y} L#{x - width / 2 - 10},#{y + 10}" style="stroke:#333;stroke-width:2;fill:none;" />
|
342
|
+
SVG
|
343
|
+
|
344
|
+
# Add label with background
|
345
|
+
label_x = x - width / 2 - 30
|
346
|
+
label_y = y - 25
|
347
|
+
|
348
|
+
svg += <<~SVG
|
349
|
+
<rect x="#{label_x - 30}" y="#{label_y - 10}" width="60" height="20" rx="5" ry="5" fill="white" fill-opacity="0.8" />
|
350
|
+
<text x="#{label_x}" y="#{label_y}" text-anchor="middle" fill="#333" font-family="Arial" font-size="10px">#{escape_text(label)}</text>
|
351
|
+
</g>
|
352
|
+
SVG
|
353
|
+
|
354
|
+
svg
|
355
|
+
end
|
356
|
+
|
357
|
+
def create_node_label(state_name, state_info)
|
358
|
+
label = state_name.to_s
|
359
|
+
|
360
|
+
# Add inputs if present
|
361
|
+
if state_info[:inputs].present?
|
362
|
+
inputs_list = state_info[:inputs].map(&:to_s).join(", ")
|
363
|
+
label += "\nInputs: #{inputs_list}"
|
364
|
+
end
|
365
|
+
|
366
|
+
# Add optional inputs if present
|
367
|
+
if state_info[:optional_inputs].present?
|
368
|
+
optional_list = state_info[:optional_inputs].map(&:to_s).join(", ")
|
369
|
+
label += "\nOptional: #{optional_list}"
|
370
|
+
end
|
371
|
+
|
372
|
+
label
|
373
|
+
end
|
374
|
+
|
375
|
+
def escape_text(text)
|
376
|
+
# Split the text into lines for multi-line support
|
377
|
+
lines = text.to_s.split("\n")
|
378
|
+
|
379
|
+
if lines.length <= 1
|
380
|
+
# Simple case: just escape the text for a single line
|
381
|
+
return text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """)
|
382
|
+
end
|
383
|
+
|
384
|
+
# For multi-line text, create tspan elements with proper alignment
|
385
|
+
line_height = 1.2 # em units
|
386
|
+
total_height = (lines.length - 1) * line_height
|
387
|
+
y_offset = -total_height / 2 # Start at negative half height to center vertically
|
388
|
+
|
389
|
+
# Escape XML special characters and add tspan elements for multi-line text
|
390
|
+
lines.map do |line|
|
391
|
+
line_text = line.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """)
|
392
|
+
tspan = "<tspan x=\"0\" y=\"#{y_offset}em\" text-anchor=\"middle\">#{line_text}</tspan>"
|
393
|
+
y_offset += line_height # Move down for next line
|
394
|
+
tspan
|
395
|
+
end.join("")
|
396
|
+
end
|
397
|
+
end
|
398
|
+
end
|
399
|
+
end
|
data/lib/operations/version.rb
CHANGED
data/lib/operations.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
desc "Restart any zombie tasks"
|
2
|
+
task :restart_zombie_tasks do
|
3
|
+
Operations::Task.restart_zombie_tasks
|
4
|
+
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: standard_procedure_operations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rahoul Baruah
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-04-04 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: rails
|
@@ -65,11 +65,12 @@ files:
|
|
65
65
|
- app/models/operations/task_participant.rb
|
66
66
|
- config/routes.rb
|
67
67
|
- db/migrate/20250127160616_create_operations_tasks.rb
|
68
|
-
- db/migrate/
|
68
|
+
- db/migrate/20250309160616_create_operations_task_participants.rb
|
69
|
+
- db/migrate/20250403075414_add_becomes_zombie_at_field.rb
|
69
70
|
- lib/operations.rb
|
70
71
|
- lib/operations/cannot_wait_in_foreground.rb
|
71
72
|
- lib/operations/engine.rb
|
72
|
-
- lib/operations/exporters/
|
73
|
+
- lib/operations/exporters/svg.rb
|
73
74
|
- lib/operations/failure.rb
|
74
75
|
- lib/operations/matchers.rb
|
75
76
|
- lib/operations/no_decision.rb
|
@@ -1,164 +0,0 @@
|
|
1
|
-
require "graphviz"
|
2
|
-
|
3
|
-
module Operations
|
4
|
-
module Exporters
|
5
|
-
class Graphviz
|
6
|
-
attr_reader :task_class
|
7
|
-
|
8
|
-
def self.export(task_class)
|
9
|
-
new(task_class).to_dot
|
10
|
-
end
|
11
|
-
|
12
|
-
def initialize(task_class)
|
13
|
-
@task_class = task_class
|
14
|
-
end
|
15
|
-
|
16
|
-
# Generate a DOT representation of the task flow
|
17
|
-
def to_dot
|
18
|
-
graph.output(dot: String)
|
19
|
-
end
|
20
|
-
|
21
|
-
# Generate and save the graph to a file
|
22
|
-
# Supported formats: dot, png, svg, pdf, etc.
|
23
|
-
def save(filename, format: :png)
|
24
|
-
graph.output(format => filename)
|
25
|
-
end
|
26
|
-
|
27
|
-
# Generate GraphViz object representing the task flow
|
28
|
-
def graph
|
29
|
-
@graph ||= build_graph
|
30
|
-
end
|
31
|
-
|
32
|
-
private
|
33
|
-
|
34
|
-
def build_graph
|
35
|
-
task_hash = task_class.to_h
|
36
|
-
g = GraphViz.new(:G, type: :digraph, rankdir: "LR")
|
37
|
-
|
38
|
-
# Set up node styles
|
39
|
-
g.node[:shape] = "box"
|
40
|
-
g.node[:style] = "rounded"
|
41
|
-
g.node[:fontname] = "Arial"
|
42
|
-
g.node[:fontsize] = "12"
|
43
|
-
g.edge[:fontname] = "Arial"
|
44
|
-
g.edge[:fontsize] = "10"
|
45
|
-
|
46
|
-
# Create nodes for each state
|
47
|
-
nodes = {}
|
48
|
-
task_hash[:states].each do |state_name, state_info|
|
49
|
-
node_style = node_style_for(state_info[:type])
|
50
|
-
node_label = create_node_label(state_name, state_info)
|
51
|
-
nodes[state_name] = g.add_nodes(state_name.to_s, label: node_label, **node_style)
|
52
|
-
end
|
53
|
-
|
54
|
-
# Add edges for transitions
|
55
|
-
task_hash[:states].each do |state_name, state_info|
|
56
|
-
case state_info[:type]
|
57
|
-
when :decision
|
58
|
-
add_decision_edges(g, nodes, state_name, state_info[:transitions])
|
59
|
-
when :action
|
60
|
-
if state_info[:next_state]
|
61
|
-
g.add_edges(nodes[state_name], nodes[state_info[:next_state]])
|
62
|
-
end
|
63
|
-
when :wait
|
64
|
-
add_wait_edges(g, nodes, state_name, state_info[:transitions])
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
# Mark initial state
|
69
|
-
if nodes[task_hash[:initial_state]]
|
70
|
-
initial_node = g.add_nodes("START", shape: "circle", style: "filled", fillcolor: "#59a14f", fontcolor: "white")
|
71
|
-
g.add_edges(initial_node, nodes[task_hash[:initial_state]])
|
72
|
-
end
|
73
|
-
|
74
|
-
g
|
75
|
-
end
|
76
|
-
|
77
|
-
def node_style_for(type)
|
78
|
-
case type
|
79
|
-
when :decision
|
80
|
-
{shape: "diamond", style: "filled", fillcolor: "#4e79a7", fontcolor: "white"}
|
81
|
-
when :action
|
82
|
-
{shape: "box", style: "filled", fillcolor: "#f28e2b", fontcolor: "white"}
|
83
|
-
when :wait
|
84
|
-
{shape: "box", style: "filled,dashed", fillcolor: "#76b7b2", fontcolor: "white"}
|
85
|
-
when :result
|
86
|
-
{shape: "box", style: "filled", fillcolor: "#59a14f", fontcolor: "white"}
|
87
|
-
else
|
88
|
-
{shape: "box"}
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
def create_node_label(state_name, state_info)
|
93
|
-
label = state_name.to_s
|
94
|
-
|
95
|
-
# Add inputs if present
|
96
|
-
if state_info[:inputs].present?
|
97
|
-
inputs_list = state_info[:inputs].map(&:to_s).join(", ")
|
98
|
-
label += "\nInputs: #{inputs_list}"
|
99
|
-
end
|
100
|
-
|
101
|
-
# Add optional inputs if present
|
102
|
-
if state_info[:optional_inputs].present?
|
103
|
-
optional_list = state_info[:optional_inputs].map(&:to_s).join(", ")
|
104
|
-
label += "\nOptional: #{optional_list}"
|
105
|
-
end
|
106
|
-
|
107
|
-
label
|
108
|
-
end
|
109
|
-
|
110
|
-
def add_decision_edges(graph, nodes, state_name, transitions)
|
111
|
-
# Get the handler for this state to access condition labels
|
112
|
-
task_state = task_class.respond_to?(:states) ? task_class.states[state_name.to_sym] : nil
|
113
|
-
handler = task_state[:handler] if task_state
|
114
|
-
|
115
|
-
transitions.each_with_index do |(condition, target), index|
|
116
|
-
# Get custom label if available
|
117
|
-
label = (handler&.respond_to?(:condition_labels) && handler.condition_labels[index]) ? handler.condition_labels[index] : target.to_s
|
118
|
-
|
119
|
-
if (target.is_a?(Symbol) || target.is_a?(String)) && nodes[target.to_sym]
|
120
|
-
graph.add_edges(nodes[state_name], nodes[target.to_sym], label: label)
|
121
|
-
elsif target.respond_to?(:call)
|
122
|
-
# Create a special node to represent the custom action
|
123
|
-
block_node_name = "#{state_name}_#{condition}_block"
|
124
|
-
block_node = graph.add_nodes(block_node_name,
|
125
|
-
label: "#{condition} Custom Action",
|
126
|
-
shape: "note",
|
127
|
-
style: "filled",
|
128
|
-
fillcolor: "#bab0ab",
|
129
|
-
fontcolor: "black")
|
130
|
-
|
131
|
-
graph.add_edges(nodes[state_name], block_node,
|
132
|
-
label: label,
|
133
|
-
style: "dashed")
|
134
|
-
end
|
135
|
-
end
|
136
|
-
end
|
137
|
-
|
138
|
-
def add_wait_edges(graph, nodes, state_name, transitions)
|
139
|
-
# Get the handler for this state to access condition labels
|
140
|
-
task_state = task_class.respond_to?(:states) ? task_class.states[state_name.to_sym] : nil
|
141
|
-
handler = task_state[:handler] if task_state
|
142
|
-
|
143
|
-
# Add a self-loop for wait condition
|
144
|
-
graph.add_edges(nodes[state_name], nodes[state_name],
|
145
|
-
label: "waiting",
|
146
|
-
style: "dashed",
|
147
|
-
constraint: "false",
|
148
|
-
dir: "back")
|
149
|
-
|
150
|
-
# Add edges for each transition
|
151
|
-
transitions.each_with_index do |(condition, target), index|
|
152
|
-
# Get custom label if available
|
153
|
-
label = (handler&.respond_to?(:condition_labels) && handler.condition_labels[index]) ? handler.condition_labels[index] : target.to_s
|
154
|
-
|
155
|
-
if (target.is_a?(Symbol) || target.is_a?(String)) && nodes[target.to_sym]
|
156
|
-
graph.add_edges(nodes[state_name], nodes[target.to_sym],
|
157
|
-
label: label,
|
158
|
-
style: "solid")
|
159
|
-
end
|
160
|
-
end
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
end
|