standard_procedure_operations 0.5.2 → 0.6.0
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 +112 -356
- data/app/jobs/operations/agent/find_timeouts_job.rb +5 -0
- data/app/jobs/operations/agent/runner_job.rb +5 -0
- data/app/jobs/operations/agent/timeout_job.rb +5 -0
- data/app/jobs/operations/agent/wake_agents_job.rb +5 -0
- data/app/models/concerns/operations/participant.rb +1 -1
- data/app/models/operations/agent/interaction_handler.rb +30 -0
- data/app/models/operations/agent/plan.rb +38 -0
- data/app/models/operations/agent/runner.rb +37 -0
- data/app/models/operations/{task/state_management → agent}/wait_handler.rb +4 -2
- data/app/models/operations/agent.rb +31 -0
- data/app/models/operations/task/data_carrier.rb +0 -2
- data/app/models/operations/task/exports.rb +4 -4
- data/app/models/operations/task/{state_management → plan}/action_handler.rb +3 -1
- data/app/models/operations/task/{state_management → plan}/decision_handler.rb +3 -1
- data/app/models/operations/task/{state_management/completion_handler.rb → plan/result_handler.rb} +3 -1
- data/app/models/operations/task/{state_management.rb → plan.rb} +2 -4
- data/app/models/operations/task/testing.rb +2 -1
- data/app/models/operations/task.rb +46 -30
- data/app/models/operations/task_participant.rb +2 -0
- data/db/migrate/{20250403075414_add_becomes_zombie_at_field.rb → 20250404085321_add_becomes_zombie_at_field.operations.rb} +1 -0
- data/db/migrate/20250407143513_agent_fields.rb +9 -0
- data/db/migrate/20250408124423_add_task_participant_indexes.rb +5 -0
- data/lib/operations/exporters/svg.rb +399 -0
- data/lib/operations/has_data_attributes.rb +50 -0
- data/lib/operations/invalid_state.rb +2 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +3 -2
- data/lib/tasks/operations_tasks.rake +3 -3
- metadata +21 -12
- data/app/jobs/operations/task_runner_job.rb +0 -11
- data/app/models/operations/task/background.rb +0 -39
- data/lib/operations/cannot_wait_in_foreground.rb +0 -2
- data/lib/operations/exporters/graphviz.rb +0 -164
@@ -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
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# TODO: Move this into its own gem as I'm already using elsewhere
|
2
|
+
module Operations::HasDataAttributes
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class_methods do
|
6
|
+
def data_attribute_in field_name, name, cast_type = :string, **options
|
7
|
+
name = name.to_sym
|
8
|
+
typecaster = cast_type.nil? ? nil : ActiveRecord::Type.lookup(cast_type)
|
9
|
+
typecast_value = ->(value) { typecaster.nil? ? value : typecaster.cast(value) }
|
10
|
+
define_attribute_method name
|
11
|
+
if cast_type != :boolean
|
12
|
+
define_method(name) { typecast_value.call(send(field_name.to_sym)[name]) || options[:default] }
|
13
|
+
else
|
14
|
+
define_method(name) do
|
15
|
+
value = typecast_value.call(send(field_name.to_sym)[name])
|
16
|
+
[true, false].include?(value) ? value : options[:default]
|
17
|
+
end
|
18
|
+
alias_method :"#{name}?", name
|
19
|
+
end
|
20
|
+
define_method(:"#{name}=") do |value|
|
21
|
+
attribute_will_change! name
|
22
|
+
send(field_name.to_sym)[name] = typecast_value.call(value)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def model_attribute_in field_name, name, class_name = nil, **options
|
27
|
+
id_attribute_name = :"#{name}_global_id"
|
28
|
+
data_attribute_in field_name, id_attribute_name, :string, **options
|
29
|
+
|
30
|
+
define_method(name.to_sym) do
|
31
|
+
id = send id_attribute_name.to_sym
|
32
|
+
id.nil? ? nil : GlobalID::Locator.locate(id)
|
33
|
+
rescue ActiveRecord::RecordNotFound
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
|
37
|
+
define_method(:"#{name}=") do |model|
|
38
|
+
raise ArgumentError.new("#{model} is not #{class_name} - #{name}") if class_name.present? && model.present? && !model.is_a?(class_name.constantize)
|
39
|
+
id = model.nil? ? nil : model.to_global_id.to_s
|
40
|
+
send :"#{id_attribute_name}=", id
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def data_attributes(*attributes) = attributes.each { |attribute| data_attribute(attribute) }
|
45
|
+
|
46
|
+
def data_attribute(name, cast_type = nil, **options) = data_attribute_in :data, name, cast_type, **options
|
47
|
+
|
48
|
+
def data_model(name, class_name = nil, **options) = model_attribute_in :data, name, class_name, **options
|
49
|
+
end
|
50
|
+
end
|
data/lib/operations/version.rb
CHANGED
data/lib/operations.rb
CHANGED
@@ -9,11 +9,12 @@ module Operations
|
|
9
9
|
end
|
10
10
|
attr_reader :task
|
11
11
|
end
|
12
|
+
require "operations/has_data_attributes"
|
12
13
|
require "operations/version"
|
13
14
|
require "operations/engine"
|
14
15
|
require "operations/failure"
|
15
|
-
require "operations/cannot_wait_in_foreground"
|
16
16
|
require "operations/timeout"
|
17
17
|
require "operations/no_decision"
|
18
|
-
require "operations/
|
18
|
+
require "operations/invalid_state"
|
19
|
+
require "operations/exporters/svg"
|
19
20
|
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
desc "
|
2
|
-
task :
|
3
|
-
Operations::
|
1
|
+
desc "Start the Agent Runner process"
|
2
|
+
task :agent_runner do
|
3
|
+
Operations::Agent::Runner.start
|
4
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.
|
4
|
+
version: 0.6.0
|
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-05-29 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: rails
|
@@ -47,31 +47,40 @@ files:
|
|
47
47
|
- LICENSE
|
48
48
|
- README.md
|
49
49
|
- Rakefile
|
50
|
+
- app/jobs/operations/agent/find_timeouts_job.rb
|
51
|
+
- app/jobs/operations/agent/runner_job.rb
|
52
|
+
- app/jobs/operations/agent/timeout_job.rb
|
53
|
+
- app/jobs/operations/agent/wake_agents_job.rb
|
50
54
|
- app/jobs/operations/application_job.rb
|
51
|
-
- app/jobs/operations/task_runner_job.rb
|
52
55
|
- app/models/concerns/operations/participant.rb
|
56
|
+
- app/models/operations/agent.rb
|
57
|
+
- app/models/operations/agent/interaction_handler.rb
|
58
|
+
- app/models/operations/agent/plan.rb
|
59
|
+
- app/models/operations/agent/runner.rb
|
60
|
+
- app/models/operations/agent/wait_handler.rb
|
53
61
|
- app/models/operations/task.rb
|
54
|
-
- app/models/operations/task/background.rb
|
55
62
|
- app/models/operations/task/data_carrier.rb
|
56
63
|
- app/models/operations/task/deletion.rb
|
57
64
|
- app/models/operations/task/exports.rb
|
58
65
|
- app/models/operations/task/input_validation.rb
|
59
|
-
- app/models/operations/task/
|
60
|
-
- app/models/operations/task/
|
61
|
-
- app/models/operations/task/
|
62
|
-
- app/models/operations/task/
|
63
|
-
- app/models/operations/task/state_management/wait_handler.rb
|
66
|
+
- app/models/operations/task/plan.rb
|
67
|
+
- app/models/operations/task/plan/action_handler.rb
|
68
|
+
- app/models/operations/task/plan/decision_handler.rb
|
69
|
+
- app/models/operations/task/plan/result_handler.rb
|
64
70
|
- app/models/operations/task/testing.rb
|
65
71
|
- app/models/operations/task_participant.rb
|
66
72
|
- config/routes.rb
|
67
73
|
- db/migrate/20250127160616_create_operations_tasks.rb
|
68
74
|
- db/migrate/20250309160616_create_operations_task_participants.rb
|
69
|
-
- db/migrate/
|
75
|
+
- db/migrate/20250404085321_add_becomes_zombie_at_field.operations.rb
|
76
|
+
- db/migrate/20250407143513_agent_fields.rb
|
77
|
+
- db/migrate/20250408124423_add_task_participant_indexes.rb
|
70
78
|
- lib/operations.rb
|
71
|
-
- lib/operations/cannot_wait_in_foreground.rb
|
72
79
|
- lib/operations/engine.rb
|
73
|
-
- lib/operations/exporters/
|
80
|
+
- lib/operations/exporters/svg.rb
|
74
81
|
- lib/operations/failure.rb
|
82
|
+
- lib/operations/has_data_attributes.rb
|
83
|
+
- lib/operations/invalid_state.rb
|
75
84
|
- lib/operations/matchers.rb
|
76
85
|
- lib/operations/no_decision.rb
|
77
86
|
- lib/operations/timeout.rb
|
@@ -1,39 +0,0 @@
|
|
1
|
-
module Operations::Task::Background
|
2
|
-
extend ActiveSupport::Concern
|
3
|
-
|
4
|
-
included do
|
5
|
-
scope :zombies, -> { zombies_at(Time.now) }
|
6
|
-
scope :zombies_at, ->(time) { where(becomes_zombie_at: ..time) }
|
7
|
-
end
|
8
|
-
|
9
|
-
class_methods do
|
10
|
-
def delay(value) = @background_delay = value
|
11
|
-
|
12
|
-
def timeout(value) = @execution_timeout = value
|
13
|
-
|
14
|
-
def on_timeout(&handler) = @on_timeout = handler
|
15
|
-
|
16
|
-
def background_delay = @background_delay ||= 1.second
|
17
|
-
|
18
|
-
def execution_timeout = @execution_timeout ||= 5.minutes
|
19
|
-
|
20
|
-
def timeout_handler = @on_timeout
|
21
|
-
|
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! }
|
25
|
-
end
|
26
|
-
|
27
|
-
def zombie? = Time.now > (updated_at + zombie_delay)
|
28
|
-
|
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
|
32
|
-
private def execution_timeout = self.class.execution_timeout
|
33
|
-
private def timeout_handler = self.class.timeout_handler
|
34
|
-
private def timeout!
|
35
|
-
return unless timeout_expired?
|
36
|
-
timeout_handler.nil? ? raise(Operations::Timeout.new("Timeout expired", self)) : timeout_handler.call
|
37
|
-
end
|
38
|
-
private def timeout_expired? = data[:_execution_timeout].present? && data[:_execution_timeout] < Time.now.utc
|
39
|
-
end
|