ruby_slm 0.1.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 +7 -0
- data/.idea/.gitignore +8 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +768 -0
- data/Rakefile +16 -0
- data/examples/test_complex_workflow.rb +747 -0
- data/examples/test_parallel_complex_workflow.rb +983 -0
- data/lib/ruby_slm/errors.rb +24 -0
- data/lib/ruby_slm/execution.rb +176 -0
- data/lib/ruby_slm/state.rb +47 -0
- data/lib/ruby_slm/state_machine.rb +140 -0
- data/lib/ruby_slm/states/base.rb +149 -0
- data/lib/ruby_slm/states/choice.rb +144 -0
- data/lib/ruby_slm/states/fail.rb +62 -0
- data/lib/ruby_slm/states/parallel.rb +178 -0
- data/lib/ruby_slm/states/pass.rb +42 -0
- data/lib/ruby_slm/states/succeed.rb +39 -0
- data/lib/ruby_slm/states/task.rb +523 -0
- data/lib/ruby_slm/states/wait.rb +123 -0
- data/lib/ruby_slm/version.rb +5 -0
- data/lib/ruby_slm.rb +50 -0
- data/sig/states_language_machine.rbs +4 -0
- data/test/test_state_machine.rb +52 -0
- metadata +146 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'jsonpath'
|
|
6
|
+
|
|
7
|
+
module StatesLanguageMachine
|
|
8
|
+
module States
|
|
9
|
+
class Task < Base
|
|
10
|
+
# @return [String] the resource ARN or URI to invoke
|
|
11
|
+
attr_reader :resource
|
|
12
|
+
# @return [Integer, nil] the timeout in seconds for the task
|
|
13
|
+
attr_reader :timeout_seconds
|
|
14
|
+
# @return [Integer, nil] the heartbeat interval in seconds
|
|
15
|
+
attr_reader :heartbeat_seconds
|
|
16
|
+
# @return [Array<Hash>] the retry configuration
|
|
17
|
+
attr_reader :retry
|
|
18
|
+
# @return [Array<Hash>] the catch configuration
|
|
19
|
+
attr_reader :catch
|
|
20
|
+
# @return [Hash] the parameters to pass to the resource
|
|
21
|
+
attr_reader :parameters
|
|
22
|
+
# @return [String, nil] the result path
|
|
23
|
+
attr_reader :result_path
|
|
24
|
+
# @return [Hash, nil] the result selector
|
|
25
|
+
attr_reader :result_selector
|
|
26
|
+
# @return [String, nil] the input path
|
|
27
|
+
attr_reader :input_path
|
|
28
|
+
# @return [String, nil] the output path
|
|
29
|
+
attr_reader :output_path
|
|
30
|
+
# @return [String, nil] the credentials ARN for the task
|
|
31
|
+
attr_reader :credentials
|
|
32
|
+
# @return [String, nil] the comment for the state
|
|
33
|
+
attr_reader :comment
|
|
34
|
+
|
|
35
|
+
# Intrinsic functions supported by AWS Step Functions
|
|
36
|
+
INTRINSIC_FUNCTIONS = %w[
|
|
37
|
+
States.Format States.StringToJson States.JsonToString
|
|
38
|
+
States.Array States.ArrayPartition States.ArrayContains
|
|
39
|
+
States.ArrayRange States.ArrayGetItem States.ArrayLength
|
|
40
|
+
States.ArrayUnique States.Base64Encode States.Base64Decode
|
|
41
|
+
States.Hash States.JsonMerge States.MathRandom
|
|
42
|
+
States.MathAdd States.StringSplit States.UUID
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# @param name [String] the name of the state
|
|
46
|
+
# @param definition [Hash] the state definition
|
|
47
|
+
def initialize(name, definition)
|
|
48
|
+
super
|
|
49
|
+
@resource = definition["Resource"]
|
|
50
|
+
@timeout_seconds = definition["TimeoutSeconds"]
|
|
51
|
+
@heartbeat_seconds = definition["HeartbeatSeconds"]
|
|
52
|
+
@parameters = definition["Parameters"] || {}
|
|
53
|
+
@result_path = definition["ResultPath"]
|
|
54
|
+
@result_selector = definition["ResultSelector"]
|
|
55
|
+
@input_path = definition["InputPath"]
|
|
56
|
+
@output_path = definition["OutputPath"]
|
|
57
|
+
@credentials = definition["Credentials"]
|
|
58
|
+
@comment = definition["Comment"]
|
|
59
|
+
@retry = definition["Retry"] || []
|
|
60
|
+
@catch = definition["Catch"] || []
|
|
61
|
+
|
|
62
|
+
# Initialize retry and catch objects
|
|
63
|
+
@retry_objects = @retry.map { |r| RetryPolicy.new(r) }
|
|
64
|
+
@catch_objects = @catch.map { |c| CatchPolicy.new(c) }
|
|
65
|
+
|
|
66
|
+
validate!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param execution [Execution] the current execution
|
|
70
|
+
# @param input [Hash] the input data for the state
|
|
71
|
+
# @return [Hash] the output data from the state
|
|
72
|
+
def execute(execution, input)
|
|
73
|
+
execution.logger&.info("Executing task state: #{@name}")
|
|
74
|
+
execution.context[:current_state] = @name
|
|
75
|
+
execution.context[:state_entered_time] = Time.now
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
if @timeout_seconds || @heartbeat_seconds
|
|
79
|
+
execute_with_timeout(execution, input)
|
|
80
|
+
else
|
|
81
|
+
execute_without_timeout(execution, input)
|
|
82
|
+
end
|
|
83
|
+
rescue => error
|
|
84
|
+
handle_execution_error(execution, error, input)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if this task supports retry for a specific error
|
|
89
|
+
# @param error [Exception] the error to check
|
|
90
|
+
# @param attempt [Integer] the current attempt number
|
|
91
|
+
# @return [RetryPolicy, nil] the retry policy if applicable
|
|
92
|
+
def retry_policy_for(error, attempt)
|
|
93
|
+
@retry_objects.find do |retry_policy|
|
|
94
|
+
retry_policy.matches?(error, attempt)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if this task has a catch handler for a specific error
|
|
99
|
+
# @param error [Exception] the error to check
|
|
100
|
+
#
|
|
101
|
+
# @return [CatchPolicy, nil] the catch policy if applicable
|
|
102
|
+
def catch_policy_for(error)
|
|
103
|
+
@catch_objects.find do |catch_policy|
|
|
104
|
+
catch_policy.matches?(error)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Execute task with timeout and heartbeat support
|
|
111
|
+
def execute_with_timeout(execution, input)
|
|
112
|
+
timeout = @timeout_seconds || 999999 # Very high default timeout
|
|
113
|
+
|
|
114
|
+
Timeout.timeout(timeout) do
|
|
115
|
+
if @heartbeat_seconds
|
|
116
|
+
execute_with_heartbeat(execution, input)
|
|
117
|
+
else
|
|
118
|
+
execute_task_logic(execution, input)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
rescue Timeout::Error
|
|
122
|
+
execution.logger&.error("Task '#{@name}' timed out after #{timeout} seconds")
|
|
123
|
+
raise TaskTimeoutError.new("Task timed out after #{timeout} seconds")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Execute task with heartbeat monitoring
|
|
127
|
+
def execute_with_heartbeat(execution, input)
|
|
128
|
+
heartbeat_thread = start_heartbeat_monitor(execution)
|
|
129
|
+
result = execute_task_logic(execution, input)
|
|
130
|
+
heartbeat_thread&.kill
|
|
131
|
+
result
|
|
132
|
+
rescue => error
|
|
133
|
+
heartbeat_thread&.kill
|
|
134
|
+
raise error
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Start heartbeat monitoring thread
|
|
138
|
+
def start_heartbeat_monitor(execution)
|
|
139
|
+
return unless @heartbeat_seconds
|
|
140
|
+
|
|
141
|
+
Thread.new do
|
|
142
|
+
loop do
|
|
143
|
+
sleep @heartbeat_seconds
|
|
144
|
+
execution.logger&.debug("Heartbeat from task: #{@name}")
|
|
145
|
+
# In a real implementation, you might send actual heartbeat notifications
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Execute task without timeout constraints
|
|
151
|
+
def execute_without_timeout(execution, input)
|
|
152
|
+
execute_task_logic(execution, input)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Main task execution logic
|
|
156
|
+
def execute_task_logic(execution, input)
|
|
157
|
+
# Apply input path
|
|
158
|
+
processed_input = apply_input_path(input, @input_path)
|
|
159
|
+
|
|
160
|
+
# Apply parameters with intrinsic function support
|
|
161
|
+
final_input = apply_parameters(processed_input, execution.context)
|
|
162
|
+
|
|
163
|
+
# Execute the actual task
|
|
164
|
+
execution.logger&.debug("Invoking resource: #{@resource}")
|
|
165
|
+
result = execute_task(execution, final_input)
|
|
166
|
+
|
|
167
|
+
# Apply result selector
|
|
168
|
+
selected_result = apply_result_selector(result, execution.context)
|
|
169
|
+
|
|
170
|
+
# Apply result path
|
|
171
|
+
output = apply_result_path(input, selected_result, @result_path)
|
|
172
|
+
|
|
173
|
+
# Apply output path
|
|
174
|
+
final_output = apply_output_path(output, @output_path)
|
|
175
|
+
|
|
176
|
+
process_result(execution, final_output)
|
|
177
|
+
final_output
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Execute the task using the configured executor
|
|
181
|
+
def execute_task(execution, input)
|
|
182
|
+
if execution.context[:task_executor]
|
|
183
|
+
execution.context[:task_executor].call(@resource, input, @credentials)
|
|
184
|
+
else
|
|
185
|
+
simulate_task_execution(input)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Simulate task execution for testing/demo
|
|
190
|
+
def simulate_task_execution(input)
|
|
191
|
+
# Simulate some processing time
|
|
192
|
+
sleep(0.1) if ENV['SIMULATE_TASK_DELAY']
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
"task_result" => "completed",
|
|
196
|
+
"resource" => @resource,
|
|
197
|
+
"input_received" => input,
|
|
198
|
+
"timestamp" => Time.now.to_i,
|
|
199
|
+
"execution_id" => SecureRandom.uuid,
|
|
200
|
+
"simulated" => true
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Apply parameters with intrinsic function support
|
|
205
|
+
def apply_parameters(parameters_template, context)
|
|
206
|
+
return parameters_template if parameters_template.empty?
|
|
207
|
+
|
|
208
|
+
evaluate_parameters(parameters_template, context)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Recursively evaluate parameters including intrinsic functions
|
|
212
|
+
def evaluate_parameters(value, context)
|
|
213
|
+
case value
|
|
214
|
+
when Hash
|
|
215
|
+
value.transform_values { |v| evaluate_parameters(v, context) }
|
|
216
|
+
when Array
|
|
217
|
+
value.map { |v| evaluate_parameters(v, context) }
|
|
218
|
+
when String
|
|
219
|
+
evaluate_intrinsic_functions(value, context)
|
|
220
|
+
else
|
|
221
|
+
value
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Evaluate intrinsic functions and JSONPath references
|
|
226
|
+
def evaluate_intrinsic_functions(value, context)
|
|
227
|
+
# Handle JSONPath references (starts with $.)
|
|
228
|
+
if value.start_with?('$.')
|
|
229
|
+
return get_value_from_path(context[value[2..-1].to_sym] || {}, value)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Handle intrinsic functions
|
|
233
|
+
intrinsic_function = INTRINSIC_FUNCTIONS.find { |func| value.include?(func) }
|
|
234
|
+
return value unless intrinsic_function
|
|
235
|
+
|
|
236
|
+
case intrinsic_function
|
|
237
|
+
when 'States.Format'
|
|
238
|
+
evaluate_format_function(value, context)
|
|
239
|
+
when 'States.StringToJson'
|
|
240
|
+
evaluate_string_to_json(value, context)
|
|
241
|
+
when 'States.JsonToString'
|
|
242
|
+
evaluate_json_to_string(value, context)
|
|
243
|
+
when 'States.Array'
|
|
244
|
+
evaluate_array_function(value, context)
|
|
245
|
+
when 'States.MathRandom'
|
|
246
|
+
evaluate_math_random(value, context)
|
|
247
|
+
when 'States.UUID'
|
|
248
|
+
SecureRandom.uuid
|
|
249
|
+
else
|
|
250
|
+
value # Return as-is for unimplemented functions
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Evaluate States.Format intrinsic function
|
|
255
|
+
def evaluate_format_function(value, context)
|
|
256
|
+
# Extract format string and arguments from the intrinsic function
|
|
257
|
+
match = value.match(/States\.Format\('([^']+)',\s*(.+)\)/)
|
|
258
|
+
return value unless match
|
|
259
|
+
|
|
260
|
+
format_string = match[1]
|
|
261
|
+
arguments_json = match[2]
|
|
262
|
+
|
|
263
|
+
begin
|
|
264
|
+
arguments = evaluate_parameters(JSON.parse(arguments_json), context)
|
|
265
|
+
format(format_string, *arguments)
|
|
266
|
+
rescue => e
|
|
267
|
+
value # Return original if parsing fails
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Evaluate States.StringToJson intrinsic function
|
|
272
|
+
def evaluate_string_to_json(value, context)
|
|
273
|
+
match = value.match(/States\.StringToJson\((.+)\)/)
|
|
274
|
+
return value unless match
|
|
275
|
+
|
|
276
|
+
string_value = evaluate_parameters(match[1], context)
|
|
277
|
+
JSON.parse(string_value)
|
|
278
|
+
rescue => e
|
|
279
|
+
value
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Evaluate States.JsonToString intrinsic function
|
|
283
|
+
def evaluate_json_to_string(value, context)
|
|
284
|
+
match = value.match(/States\.JsonToString\((.+)\)/)
|
|
285
|
+
return value unless match
|
|
286
|
+
|
|
287
|
+
json_value = evaluate_parameters(match[1], context)
|
|
288
|
+
JSON.generate(json_value)
|
|
289
|
+
rescue => e
|
|
290
|
+
value
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Evaluate States.Array intrinsic function
|
|
294
|
+
def evaluate_array_function(value, context)
|
|
295
|
+
match = value.match(/States\.Array\((.+)\)/)
|
|
296
|
+
return value unless match
|
|
297
|
+
|
|
298
|
+
elements_json = match[1]
|
|
299
|
+
evaluate_parameters(JSON.parse("[#{elements_json}]"), context)
|
|
300
|
+
rescue => e
|
|
301
|
+
value
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Evaluate States.MathRandom intrinsic function
|
|
305
|
+
def evaluate_math_random(value, context)
|
|
306
|
+
match = value.match(/States\.MathRandom\((\d+),\s*(\d+)\)/)
|
|
307
|
+
return value unless match
|
|
308
|
+
|
|
309
|
+
min = match[1].to_i
|
|
310
|
+
max = match[2].to_i
|
|
311
|
+
rand(min..max)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Apply result selector to filter and transform task result
|
|
315
|
+
def apply_result_selector(result, context)
|
|
316
|
+
return result unless @result_selector
|
|
317
|
+
|
|
318
|
+
evaluate_parameters(@result_selector, context.merge(task_result: result))
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Handle execution errors with retry and catch logic
|
|
322
|
+
def handle_execution_error(execution, error, input)
|
|
323
|
+
execution.logger&.error("Task execution failed: #{error.class.name} - #{error.message}")
|
|
324
|
+
|
|
325
|
+
# Check if we should retry
|
|
326
|
+
retry_policy = retry_policy_for(error, execution.context[:attempt] || 1)
|
|
327
|
+
if retry_policy
|
|
328
|
+
return handle_retry(execution, error, input, retry_policy)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Check if we have a catch handler
|
|
332
|
+
catch_policy = catch_policy_for(error)
|
|
333
|
+
if catch_policy
|
|
334
|
+
return handle_catch(execution, error, input, catch_policy)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# No retry or catch - re-raise the error
|
|
338
|
+
raise error
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Handle retry logic
|
|
342
|
+
def handle_retry(execution, error, input, retry_policy)
|
|
343
|
+
execution.context[:attempt] = (execution.context[:attempt] || 1) + 1
|
|
344
|
+
execution.logger&.info("Retrying task (attempt #{execution.context[:attempt]})")
|
|
345
|
+
|
|
346
|
+
# Apply retry interval
|
|
347
|
+
sleep(retry_policy.interval_seconds) if retry_policy.interval_seconds > 0
|
|
348
|
+
|
|
349
|
+
# Retry the execution
|
|
350
|
+
execute(execution, input)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Handle catch logic
|
|
354
|
+
def handle_catch(execution, error, input, catch_policy)
|
|
355
|
+
execution.logger&.info("Handling error with catch policy: #{catch_policy.next}")
|
|
356
|
+
|
|
357
|
+
# Prepare error result
|
|
358
|
+
error_result = {
|
|
359
|
+
"Error" => error.class.name,
|
|
360
|
+
"Cause" => error.message
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# Apply result path from catch policy or use default
|
|
364
|
+
result_path = catch_policy.result_path || @result_path
|
|
365
|
+
output = apply_result_path(input, error_result, result_path)
|
|
366
|
+
|
|
367
|
+
# Transition to next state specified in catch policy
|
|
368
|
+
execution.context[:next_state] = catch_policy.next
|
|
369
|
+
output
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Validate the task state definition
|
|
373
|
+
def validate!
|
|
374
|
+
super
|
|
375
|
+
|
|
376
|
+
raise DefinitionError, "Task state '#{@name}' must have a Resource" unless @resource
|
|
377
|
+
|
|
378
|
+
if @timeout_seconds && @timeout_seconds <= 0
|
|
379
|
+
raise DefinitionError, "TimeoutSeconds must be positive"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
if @heartbeat_seconds && @heartbeat_seconds <= 0
|
|
383
|
+
raise DefinitionError, "HeartbeatSeconds must be positive"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
if @heartbeat_seconds && @timeout_seconds && @heartbeat_seconds >= @timeout_seconds
|
|
387
|
+
raise DefinitionError, "HeartbeatSeconds must be less than TimeoutSeconds"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
validate_retry_policies!
|
|
391
|
+
validate_catch_policies!
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def validate_retry_policies!
|
|
395
|
+
@retry_objects.each(&:validate!)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def validate_catch_policies!
|
|
399
|
+
@catch_objects.each(&:validate!)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Helper method to get value from JSONPath
|
|
403
|
+
def get_value_from_path(data, path)
|
|
404
|
+
JsonPath.new(path).first(data)
|
|
405
|
+
rescue
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Helper method to set value at JSONPath
|
|
410
|
+
def set_value_at_path(data, path, value)
|
|
411
|
+
# Simple implementation - for production use a proper JSONPath setter
|
|
412
|
+
if path == "$"
|
|
413
|
+
value
|
|
414
|
+
else
|
|
415
|
+
deep_merge(data, create_nested_hash(path.gsub('$.', '').split('.'), value))
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def create_nested_hash(keys, value)
|
|
420
|
+
return value if keys.empty?
|
|
421
|
+
{ keys.first => create_nested_hash(keys[1..-1], value) }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def deep_merge(hash1, hash2)
|
|
425
|
+
hash1.merge(hash2) do |key, old_val, new_val|
|
|
426
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
427
|
+
deep_merge(old_val, new_val)
|
|
428
|
+
else
|
|
429
|
+
new_val
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Retry policy class
|
|
436
|
+
class RetryPolicy
|
|
437
|
+
attr_reader :error_equals, :interval_seconds, :max_attempts, :backoff_rate
|
|
438
|
+
|
|
439
|
+
def initialize(definition)
|
|
440
|
+
@error_equals = Array(definition["ErrorEquals"])
|
|
441
|
+
@interval_seconds = definition["IntervalSeconds"] || 1
|
|
442
|
+
@max_attempts = definition["MaxAttempts"] || 3
|
|
443
|
+
@backoff_rate = definition["BackoffRate"] || 2.0
|
|
444
|
+
@max_delay = definition["MaxDelay"] || 3600 # 1 hour default
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def matches?(error, attempt)
|
|
448
|
+
return false if attempt >= @max_attempts
|
|
449
|
+
|
|
450
|
+
@error_equals.any? do |error_match|
|
|
451
|
+
case error_match
|
|
452
|
+
when "States.ALL"
|
|
453
|
+
true
|
|
454
|
+
when "States.Timeout"
|
|
455
|
+
error.is_a?(TaskTimeoutError)
|
|
456
|
+
when "States.TaskFailed"
|
|
457
|
+
error.is_a?(StandardError) && !error.is_a?(TaskTimeoutError)
|
|
458
|
+
when "States.Permissions"
|
|
459
|
+
error.is_a?(SecurityError) || error.message.include?("permission")
|
|
460
|
+
else
|
|
461
|
+
error.class.name == error_match || error.message.include?(error_match)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def validate!
|
|
467
|
+
if @error_equals.empty?
|
|
468
|
+
raise DefinitionError, "Retry policy must specify ErrorEquals"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
if @interval_seconds < 0
|
|
472
|
+
raise DefinitionError, "IntervalSeconds must be non-negative"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
if @max_attempts < 0
|
|
476
|
+
raise DefinitionError, "MaxAttempts must be non-negative"
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Catch policy class
|
|
482
|
+
class CatchPolicy
|
|
483
|
+
attr_reader :error_equals, :next, :result_path
|
|
484
|
+
|
|
485
|
+
def initialize(definition)
|
|
486
|
+
@error_equals = Array(definition["ErrorEquals"])
|
|
487
|
+
@next = definition["Next"]
|
|
488
|
+
@result_path = definition["ResultPath"]
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def matches?(error)
|
|
492
|
+
@error_equals.any? do |error_match|
|
|
493
|
+
case error_match
|
|
494
|
+
when "States.ALL"
|
|
495
|
+
true
|
|
496
|
+
when "States.Timeout"
|
|
497
|
+
error.is_a?(TaskTimeoutError)
|
|
498
|
+
when "States.TaskFailed"
|
|
499
|
+
error.is_a?(StandardError) && !error.is_a?(TaskTimeoutError)
|
|
500
|
+
when "States.Permissions"
|
|
501
|
+
error.is_a?(SecurityError) || error.message.include?("permission")
|
|
502
|
+
else
|
|
503
|
+
error.class.name == error_match || error.message.include?(error_match)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def validate!
|
|
509
|
+
if @error_equals.empty?
|
|
510
|
+
raise DefinitionError, "Catch policy must specify ErrorEquals"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
unless @next
|
|
514
|
+
raise DefinitionError, "Catch policy must specify Next state"
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Custom error classes
|
|
520
|
+
class TaskTimeoutError < StandardError; end
|
|
521
|
+
class TaskExecutionError < StandardError; end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
|
|
3
|
+
module StatesLanguageMachine
|
|
4
|
+
module States
|
|
5
|
+
class Wait
|
|
6
|
+
attr_reader :state_type, :seconds, :timestamp, :seconds_path, :timestamp_path, :next_state, :end_state
|
|
7
|
+
|
|
8
|
+
def initialize(definition, state_name)
|
|
9
|
+
@state_name = state_name
|
|
10
|
+
|
|
11
|
+
# Ensure definition is a Hash and extract values safely
|
|
12
|
+
@state_type = definition.is_a?(Hash) ? definition["Type"] : nil
|
|
13
|
+
@seconds = definition.is_a?(Hash) ? definition["Seconds"] : nil
|
|
14
|
+
@timestamp = definition.is_a?(Hash) ? definition["Timestamp"] : nil
|
|
15
|
+
@seconds_path = definition.is_a?(Hash) ? definition["SecondsPath"] : nil
|
|
16
|
+
@timestamp_path = definition.is_a?(Hash) ? definition["TimestampPath"] : nil
|
|
17
|
+
@next_state = definition.is_a?(Hash) ? definition["Next"] : nil
|
|
18
|
+
|
|
19
|
+
# Safely handle End key - check if it exists and is truthy
|
|
20
|
+
if definition.is_a?(Hash)
|
|
21
|
+
@end_state = definition.key?("End") ? !!definition["End"] : false
|
|
22
|
+
else
|
|
23
|
+
@end_state = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
validate
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def execute(context)
|
|
30
|
+
# Determine how long to wait
|
|
31
|
+
wait_seconds = calculate_wait_seconds(context)
|
|
32
|
+
|
|
33
|
+
# Perform the wait
|
|
34
|
+
sleep(wait_seconds) if wait_seconds > 0
|
|
35
|
+
|
|
36
|
+
# Return execution result
|
|
37
|
+
ExecutionResult.new(
|
|
38
|
+
next_state: @end_state ? nil : @next_state,
|
|
39
|
+
output: context.execution_input,
|
|
40
|
+
end_execution: @end_state
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def calculate_wait_seconds(context)
|
|
47
|
+
if @seconds
|
|
48
|
+
@seconds.to_i
|
|
49
|
+
elsif @timestamp
|
|
50
|
+
target_time = Time.parse(@timestamp) # Use :: to specify the class method
|
|
51
|
+
wait_time = target_time - Time.now
|
|
52
|
+
wait_time > 0 ? wait_time : 0
|
|
53
|
+
elsif @seconds_path
|
|
54
|
+
seconds_value = extract_path_value(context.execution_input, @seconds_path)
|
|
55
|
+
validate_seconds_value(seconds_value)
|
|
56
|
+
seconds_value.to_i
|
|
57
|
+
elsif @timestamp_path
|
|
58
|
+
timestamp_value = extract_path_value(context.execution_input, @timestamp_path)
|
|
59
|
+
target_time = Time.parse(timestamp_value) # Use :: to specify the class method
|
|
60
|
+
wait_time = target_time - Time.now
|
|
61
|
+
wait_time > 0 ? wait_time : 0
|
|
62
|
+
else
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_path_value(input, path)
|
|
68
|
+
# Simple path extraction - you might want to use a JSONPath library
|
|
69
|
+
if path.start_with?("$.")
|
|
70
|
+
key = path[2..-1]
|
|
71
|
+
input[key]
|
|
72
|
+
else
|
|
73
|
+
input[path]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_seconds_value(seconds)
|
|
78
|
+
return if seconds.is_a?(Integer) && seconds >= 0
|
|
79
|
+
return if seconds.is_a?(String) && seconds.match?(/^\d+$/) && seconds.to_i >= 0
|
|
80
|
+
|
|
81
|
+
raise StatesLanguageMachine::Error, "Seconds value must be a positive integer"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate
|
|
85
|
+
raise StatesLanguageMachine::Error, "State definition must be a Hash" unless @state_type
|
|
86
|
+
|
|
87
|
+
wait_methods = [@seconds, @timestamp, @seconds_path, @timestamp_path].compact
|
|
88
|
+
raise StatesLanguageMachine::Error, "Wait state must specify one of: Seconds, Timestamp, SecondsPath, or TimestampPath" if wait_methods.empty?
|
|
89
|
+
|
|
90
|
+
raise StatesLanguageMachine::Error, "Wait state can only specify one wait method" if wait_methods.size > 1
|
|
91
|
+
|
|
92
|
+
validate_seconds_value(@seconds) if @seconds
|
|
93
|
+
|
|
94
|
+
if @timestamp
|
|
95
|
+
begin
|
|
96
|
+
::Time.parse(@timestamp) # Use :: to specify the class method
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
raise StatesLanguageMachine::Error, "Invalid timestamp format: #{@timestamp}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if @end_state && @next_state
|
|
103
|
+
raise StatesLanguageMachine::Error, "Wait state cannot have both 'End' and 'Next'"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unless @end_state || @next_state
|
|
107
|
+
raise StatesLanguageMachine::Error, "Wait state must have either 'End' or 'Next'"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Simple result class for execution
|
|
113
|
+
class ExecutionResult
|
|
114
|
+
attr_reader :next_state, :output, :end_execution
|
|
115
|
+
|
|
116
|
+
def initialize(next_state: nil, output: nil, end_execution: false)
|
|
117
|
+
@next_state = next_state
|
|
118
|
+
@output = output
|
|
119
|
+
@end_execution = end_execution
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/ruby_slm.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruby_slm/version"
|
|
4
|
+
require_relative "ruby_slm/errors"
|
|
5
|
+
require_relative "ruby_slm/state_machine"
|
|
6
|
+
require_relative "ruby_slm/state"
|
|
7
|
+
require_relative "ruby_slm/execution"
|
|
8
|
+
|
|
9
|
+
# State implementations
|
|
10
|
+
require_relative "ruby_slm/states/base"
|
|
11
|
+
require_relative "ruby_slm/states/task"
|
|
12
|
+
require_relative "ruby_slm/states/choice"
|
|
13
|
+
require_relative "ruby_slm/states/wait"
|
|
14
|
+
require_relative "ruby_slm/states/parallel"
|
|
15
|
+
require_relative "ruby_slm/states/pass"
|
|
16
|
+
require_relative "ruby_slm/states/succeed"
|
|
17
|
+
require_relative "ruby_slm/states/fail"
|
|
18
|
+
|
|
19
|
+
module StatesLanguageMachine
|
|
20
|
+
class << self
|
|
21
|
+
# Create a state machine from a YAML string
|
|
22
|
+
# @param yaml_string [String] the YAML definition of the state machine
|
|
23
|
+
# @return [StateMachine] the parsed state machine
|
|
24
|
+
def from_yaml(yaml_string)
|
|
25
|
+
StateMachine.new(yaml_string)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Create a state machine from a YAML file
|
|
29
|
+
# @param file_path [String] the path to the YAML file
|
|
30
|
+
# @return [StateMachine] the parsed state machine
|
|
31
|
+
def from_yaml_file(file_path)
|
|
32
|
+
yaml_content = File.read(file_path)
|
|
33
|
+
StateMachine.new(yaml_content)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create a state machine from a JSON string
|
|
37
|
+
# @param json_string [String] the JSON definition of the state machine
|
|
38
|
+
# @return [StateMachine] the parsed state machine
|
|
39
|
+
def from_json(json_string)
|
|
40
|
+
StateMachine.new(json_string, format: :json)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create a state machine from a Hash
|
|
44
|
+
# @param hash [Hash] the Hash definition of the state machine
|
|
45
|
+
# @return [StateMachine] the parsed state machine
|
|
46
|
+
def from_hash(hash)
|
|
47
|
+
StateMachine.new(hash, format: :hash)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|