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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatesLanguageMachine
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ExecutionError < Error
|
|
7
|
+
# @return [String] the name of the state where the error occurred
|
|
8
|
+
attr_reader :state_name
|
|
9
|
+
# @return [String] the cause of the error
|
|
10
|
+
attr_reader :cause
|
|
11
|
+
|
|
12
|
+
# @param state_name [String] the name of the state where the error occurred
|
|
13
|
+
# @param cause [String] the cause of the error
|
|
14
|
+
def initialize(state_name, cause)
|
|
15
|
+
@state_name = state_name
|
|
16
|
+
@cause = cause
|
|
17
|
+
super("Execution failed in state '#{state_name}': #{cause}")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class DefinitionError < Error; end
|
|
22
|
+
class StateNotFoundError < Error; end
|
|
23
|
+
class TimeoutError < Error; end
|
|
24
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module StatesLanguageMachine
|
|
6
|
+
class Execution
|
|
7
|
+
# @return [String, nil] the current state name
|
|
8
|
+
attr_accessor :current_state
|
|
9
|
+
# @return [Hash] the current output data
|
|
10
|
+
attr_accessor :output
|
|
11
|
+
# @return [Symbol] the execution status (:running, :succeeded, :failed)
|
|
12
|
+
attr_accessor :status
|
|
13
|
+
# @return [String, nil] the error type if execution failed
|
|
14
|
+
attr_accessor :error
|
|
15
|
+
# @return [String, nil] the cause of failure if execution failed
|
|
16
|
+
attr_accessor :cause
|
|
17
|
+
# @return [Array<Hash>] the execution history
|
|
18
|
+
attr_accessor :history
|
|
19
|
+
# @return [Logger, nil] the logger for execution
|
|
20
|
+
attr_accessor :logger
|
|
21
|
+
# @return [Hash] the execution context
|
|
22
|
+
attr_accessor :context
|
|
23
|
+
# @return [Time, nil] the execution end time
|
|
24
|
+
attr_accessor :end_time
|
|
25
|
+
|
|
26
|
+
# @return [StateMachine] the state machine being executed
|
|
27
|
+
attr_reader :state_machine
|
|
28
|
+
# @return [Hash] the original input data
|
|
29
|
+
attr_reader :input
|
|
30
|
+
# @return [String] the execution name
|
|
31
|
+
attr_reader :name
|
|
32
|
+
# @return [Time] the execution start time
|
|
33
|
+
attr_reader :start_time
|
|
34
|
+
|
|
35
|
+
# @param state_machine [StateMachine] the state machine to execute
|
|
36
|
+
# @param input [Hash] the input data for the execution
|
|
37
|
+
# @param name [String, nil] the name of the execution
|
|
38
|
+
# @param context [Hash] additional context for the execution
|
|
39
|
+
def initialize(state_machine, input = {}, name = nil, context = {})
|
|
40
|
+
@state_machine = state_machine
|
|
41
|
+
@input = input.dup
|
|
42
|
+
@name = name || "execution-#{Time.now.to_i}-#{SecureRandom.hex(4)}"
|
|
43
|
+
@current_state = state_machine.start_state
|
|
44
|
+
@output = input.dup
|
|
45
|
+
@status = :running
|
|
46
|
+
@history = []
|
|
47
|
+
@logger = context[:logger]
|
|
48
|
+
@context = context
|
|
49
|
+
@start_time = Time.now
|
|
50
|
+
@end_time = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Run the entire execution to completion
|
|
54
|
+
# @return [Execution] self
|
|
55
|
+
def run_all
|
|
56
|
+
while @status == :running && @current_state
|
|
57
|
+
run_next
|
|
58
|
+
end
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Run the next state in the execution
|
|
63
|
+
# @return [Execution] self
|
|
64
|
+
def run_next
|
|
65
|
+
return self unless @status == :running && @current_state
|
|
66
|
+
|
|
67
|
+
state = @state_machine.get_state(@current_state)
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
logger&.info("Executing state: #{@current_state}")
|
|
71
|
+
|
|
72
|
+
# Execute the current state
|
|
73
|
+
@output = state.execute(self, @output)
|
|
74
|
+
|
|
75
|
+
# Check if the state set the execution to failed
|
|
76
|
+
if @status == :failed
|
|
77
|
+
logger&.info("Execution failed in state: #{@current_state}")
|
|
78
|
+
@end_time ||= Time.now
|
|
79
|
+
return self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Determine next state - for choice states, we need to pass the output
|
|
83
|
+
next_state = state.next_state_name(@output)
|
|
84
|
+
|
|
85
|
+
logger&.info("State #{@current_state} completed. Next state: #{next_state}")
|
|
86
|
+
|
|
87
|
+
if state.end_state?
|
|
88
|
+
@status = :succeeded unless @status == :failed
|
|
89
|
+
@current_state = nil
|
|
90
|
+
@end_time = Time.now
|
|
91
|
+
logger&.info("Execution completed successfully")
|
|
92
|
+
elsif next_state
|
|
93
|
+
@current_state = next_state
|
|
94
|
+
logger&.info("Moving to next state: #{next_state}")
|
|
95
|
+
else
|
|
96
|
+
@status = :failed
|
|
97
|
+
@error = "NoNextState"
|
|
98
|
+
@cause = "State '#{@current_state}' has no next state and is not an end state"
|
|
99
|
+
@end_time = Time.now
|
|
100
|
+
logger&.error("Execution failed: #{@cause}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
rescue => e
|
|
104
|
+
@status = :failed
|
|
105
|
+
@error = e.is_a?(ExecutionError) ? e.cause : "ExecutionError"
|
|
106
|
+
@cause = e.message
|
|
107
|
+
@end_time = Time.now
|
|
108
|
+
logger&.error("Execution failed in state #{@current_state}: #{e.message}")
|
|
109
|
+
logger&.error(e.backtrace.join("\n")) if logger
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Update the execution output
|
|
116
|
+
# @param new_output [Hash] the new output data
|
|
117
|
+
def update_output(new_output)
|
|
118
|
+
@output = new_output
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Add an entry to the execution history
|
|
122
|
+
# @param state_name [String] the name of the state that was executed
|
|
123
|
+
# @param output [Hash] the output from the state execution
|
|
124
|
+
def add_history_entry(state_name, output)
|
|
125
|
+
@history << {
|
|
126
|
+
state_name: state_name,
|
|
127
|
+
input: @output, # Current input before execution
|
|
128
|
+
output: output,
|
|
129
|
+
timestamp: Time.now
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Boolean] whether the execution succeeded
|
|
134
|
+
def succeeded?
|
|
135
|
+
@status == :succeeded
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Boolean] whether the execution failed
|
|
139
|
+
def failed?
|
|
140
|
+
@status == :failed
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Boolean] whether the execution is still running
|
|
144
|
+
def running?
|
|
145
|
+
@status == :running
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Float] the total execution time in seconds
|
|
149
|
+
def execution_time
|
|
150
|
+
return @end_time - @start_time if @end_time
|
|
151
|
+
Time.now - @start_time
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [Hash] the execution details as a Hash
|
|
155
|
+
def to_h
|
|
156
|
+
{
|
|
157
|
+
name: @name,
|
|
158
|
+
status: @status,
|
|
159
|
+
current_state: @current_state,
|
|
160
|
+
input: @input,
|
|
161
|
+
output: @output,
|
|
162
|
+
error: @error,
|
|
163
|
+
cause: @cause,
|
|
164
|
+
start_time: @start_time,
|
|
165
|
+
end_time: @end_time,
|
|
166
|
+
execution_time: execution_time,
|
|
167
|
+
history: @history
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @return [String] the execution details as JSON
|
|
172
|
+
def to_json
|
|
173
|
+
JSON.pretty_generate(to_h)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatesLanguageMachine
|
|
4
|
+
# Base state class that provides common functionality for all states
|
|
5
|
+
class State
|
|
6
|
+
# @return [String] the name of the state
|
|
7
|
+
attr_reader :name
|
|
8
|
+
# @return [String] the type of the state
|
|
9
|
+
attr_reader :type
|
|
10
|
+
# @return [String, nil] the next state name
|
|
11
|
+
attr_reader :next_state
|
|
12
|
+
# @return [Boolean] whether this is an end state
|
|
13
|
+
attr_reader :end_state
|
|
14
|
+
|
|
15
|
+
# @param name [String] the name of the state
|
|
16
|
+
# @param definition [Hash] the state definition
|
|
17
|
+
def initialize(name, definition)
|
|
18
|
+
@name = name
|
|
19
|
+
@type = definition["Type"]
|
|
20
|
+
@next_state = definition["Next"]
|
|
21
|
+
@end_state = definition.key?("End") && definition["End"]
|
|
22
|
+
@definition = definition
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Get the next state name (can be overridden by subclasses that need input)
|
|
26
|
+
# @param input [Hash, nil] the input data (optional, for choice states)
|
|
27
|
+
# @return [String, nil] the next state name
|
|
28
|
+
def next_state_name(input = nil)
|
|
29
|
+
return nil if end_state?
|
|
30
|
+
@next_state
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether this state is an end state
|
|
34
|
+
def end_state?
|
|
35
|
+
@end_state
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Execute the state (to be implemented by subclasses)
|
|
39
|
+
# @param execution [Execution] the current execution
|
|
40
|
+
# @param input [Hash] the input data
|
|
41
|
+
# @return [Hash] the output data
|
|
42
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
|
43
|
+
def execute(execution, input)
|
|
44
|
+
raise NotImplementedError, "Subclasses must implement execute method"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module StatesLanguageMachine
|
|
7
|
+
class StateMachine
|
|
8
|
+
# @return [Hash] the raw definition of the state machine
|
|
9
|
+
attr_reader :definition
|
|
10
|
+
# @return [Hash<String, States::Base>] mapping of state names to state objects
|
|
11
|
+
attr_reader :states
|
|
12
|
+
# @return [String] the name of the starting state
|
|
13
|
+
attr_reader :start_state
|
|
14
|
+
# @return [Integer, nil] the timeout in seconds for the entire state machine
|
|
15
|
+
attr_reader :timeout_seconds
|
|
16
|
+
# @return [String, nil] the comment describing the state machine
|
|
17
|
+
attr_reader :comment
|
|
18
|
+
|
|
19
|
+
# @param definition [String, Hash] the state machine definition
|
|
20
|
+
# @param format [Symbol] the format of the definition (:yaml, :json, :hash)
|
|
21
|
+
def initialize(definition, format: :yaml)
|
|
22
|
+
@definition = parse_definition(definition, format)
|
|
23
|
+
validate_definition!
|
|
24
|
+
build_states
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Start a new execution of this state machine
|
|
28
|
+
# @param input [Hash] the input data for the execution
|
|
29
|
+
# @param execution_name [String, nil] the name of the execution
|
|
30
|
+
# @param context [Hash] additional context for the execution
|
|
31
|
+
# @return [Execution] the execution object
|
|
32
|
+
def start_execution(input = {}, execution_name = nil, context = {})
|
|
33
|
+
Execution.new(self, input, execution_name, context)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get a state by name
|
|
37
|
+
# @param state_name [String] the name of the state to retrieve
|
|
38
|
+
# @return [States::Base] the state object
|
|
39
|
+
# @raise [StateNotFoundError] if the state is not found
|
|
40
|
+
def get_state(state_name)
|
|
41
|
+
@states[state_name] || raise(StateNotFoundError, "State '#{state_name}' not found")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<String>] the names of all states in the machine
|
|
45
|
+
def state_names
|
|
46
|
+
@states.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Hash] the state machine definition as a Hash
|
|
50
|
+
def to_h
|
|
51
|
+
@definition.dup
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] the state machine definition as JSON
|
|
55
|
+
def to_json
|
|
56
|
+
JSON.pretty_generate(@definition)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] the state machine definition as YAML
|
|
60
|
+
def to_yaml
|
|
61
|
+
YAML.dump(@definition)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @param definition [String, Hash] the definition to parse
|
|
67
|
+
# @param format [Symbol] the format of the definition
|
|
68
|
+
# @return [Hash] the parsed definition
|
|
69
|
+
def parse_definition(definition, format)
|
|
70
|
+
case format
|
|
71
|
+
when :yaml
|
|
72
|
+
YAML.safe_load(definition, permitted_classes: [Symbol], aliases: true)
|
|
73
|
+
when :json
|
|
74
|
+
JSON.parse(definition)
|
|
75
|
+
when :hash
|
|
76
|
+
definition
|
|
77
|
+
else
|
|
78
|
+
raise DefinitionError, "Unsupported format: #{format}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validate the state machine definition
|
|
83
|
+
# @raise [DefinitionError] if the definition is invalid
|
|
84
|
+
def validate_definition!
|
|
85
|
+
raise DefinitionError, "Definition must be a Hash" unless @definition.is_a?(Hash)
|
|
86
|
+
raise DefinitionError, "Missing 'States' field" unless @definition["States"]
|
|
87
|
+
raise DefinitionError, "Missing 'StartAt' field" unless @definition["StartAt"]
|
|
88
|
+
|
|
89
|
+
@start_state = @definition["StartAt"]
|
|
90
|
+
@timeout_seconds = @definition["TimeoutSeconds"]
|
|
91
|
+
@comment = @definition["Comment"]
|
|
92
|
+
|
|
93
|
+
unless @definition["States"].key?(@start_state)
|
|
94
|
+
raise DefinitionError, "Start state '#{@start_state}' not found in States"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build state objects from the definition
|
|
99
|
+
def build_states
|
|
100
|
+
@states = {}
|
|
101
|
+
|
|
102
|
+
@definition["States"].each do |name, state_def|
|
|
103
|
+
@states[name] = create_state(name, state_def)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Create a state object from its definition
|
|
108
|
+
# @param name [String] the name of the state
|
|
109
|
+
# @param state_def [Hash] the state definition
|
|
110
|
+
# @return [States::Base] the created state object
|
|
111
|
+
# @raise [DefinitionError] if the state type is unknown
|
|
112
|
+
# Create a state object from its definition
|
|
113
|
+
# @param name [String] the name of the state
|
|
114
|
+
# @param state_def [Hash] the state definition
|
|
115
|
+
# @return [States::Base] the created state object
|
|
116
|
+
# @raise [DefinitionError] if the state type is unknown
|
|
117
|
+
def create_state(name, state_def)
|
|
118
|
+
type = state_def["Type"]
|
|
119
|
+
|
|
120
|
+
case type
|
|
121
|
+
when "Task"
|
|
122
|
+
States::Task.new(name, state_def)
|
|
123
|
+
when "Choice"
|
|
124
|
+
States::Choice.new(name, state_def)
|
|
125
|
+
when "Wait"
|
|
126
|
+
States::Wait.new(name, state_def)
|
|
127
|
+
when "Parallel"
|
|
128
|
+
States::Parallel.new(name, state_def)
|
|
129
|
+
when "Pass"
|
|
130
|
+
States::Pass.new(name, state_def)
|
|
131
|
+
when "Succeed"
|
|
132
|
+
States::Succeed.new(name, state_def)
|
|
133
|
+
when "Fail"
|
|
134
|
+
States::Fail.new(name, state_def)
|
|
135
|
+
else
|
|
136
|
+
raise DefinitionError, "Unknown state type: #{type}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatesLanguageMachine
|
|
4
|
+
module States
|
|
5
|
+
class Base < State
|
|
6
|
+
# @return [String, nil] the comment describing the state
|
|
7
|
+
attr_reader :comment
|
|
8
|
+
# @return [Hash] the raw state definition
|
|
9
|
+
attr_reader :definition
|
|
10
|
+
|
|
11
|
+
# @param name [String] the name of the state
|
|
12
|
+
# @param definition [Hash] the state definition
|
|
13
|
+
def initialize(name, definition)
|
|
14
|
+
super(name, definition)
|
|
15
|
+
@comment = definition["Comment"]
|
|
16
|
+
@definition = definition
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Validate the state definition
|
|
20
|
+
# @raise [DefinitionError] if the definition is invalid
|
|
21
|
+
def validate!
|
|
22
|
+
# Base validation - can be overridden by subclasses
|
|
23
|
+
if @end_state && @next_state
|
|
24
|
+
raise DefinitionError, "State '#{@name}' cannot have both End and Next"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless @end_state || @next_state
|
|
28
|
+
raise DefinitionError, "State '#{@name}' must have either End or Next"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
# Process the execution result and update history
|
|
35
|
+
# @param execution [Execution] the current execution
|
|
36
|
+
# @param result [Hash] the result to process
|
|
37
|
+
def process_result(execution, result)
|
|
38
|
+
execution.update_output(result)
|
|
39
|
+
execution.add_history_entry(@name, result)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Get a value from a nested hash using a dot-separated path
|
|
44
|
+
# @param data [Hash] the data to extract from
|
|
45
|
+
# @param path [String, nil] the dot-separated path
|
|
46
|
+
# @return [Object, nil] the value at the path, or nil if not found
|
|
47
|
+
def get_value_from_path(data, path)
|
|
48
|
+
return data unless path && data
|
|
49
|
+
|
|
50
|
+
# Handle nil or empty path
|
|
51
|
+
return data if path.nil? || path.empty?
|
|
52
|
+
|
|
53
|
+
# Remove leading '$.' if present (JSONPath format)
|
|
54
|
+
clean_path = path.start_with?('$.') ? path[2..] : path
|
|
55
|
+
|
|
56
|
+
# Handle root reference
|
|
57
|
+
return data if clean_path.empty?
|
|
58
|
+
|
|
59
|
+
# Split path and traverse
|
|
60
|
+
keys = clean_path.split('.')
|
|
61
|
+
|
|
62
|
+
current = data
|
|
63
|
+
keys.each do |key|
|
|
64
|
+
if current.is_a?(Hash) && current.key?(key)
|
|
65
|
+
current = current[key]
|
|
66
|
+
elsif current.is_a?(Array) && key =~ /^\d+$/
|
|
67
|
+
index = key.to_i
|
|
68
|
+
current = current[index] if index < current.length
|
|
69
|
+
else
|
|
70
|
+
# Return nil if any part of the path doesn't exist
|
|
71
|
+
return nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Break if we hit nil
|
|
75
|
+
break if current.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
current
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Set a value in a nested hash using a dot-separated path
|
|
82
|
+
# @param data [Hash] the data to modify
|
|
83
|
+
# @param path [String] the dot-separated path
|
|
84
|
+
# @param value [Object] the value to set
|
|
85
|
+
# @return [Hash] the modified data
|
|
86
|
+
def set_value_at_path(data, path, value)
|
|
87
|
+
return value unless path
|
|
88
|
+
|
|
89
|
+
keys = path.split('.')
|
|
90
|
+
final_key = keys.pop
|
|
91
|
+
|
|
92
|
+
target = keys.reduce(data) do |current, key|
|
|
93
|
+
current[key] ||= {}
|
|
94
|
+
current[key]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
target[final_key] = value
|
|
98
|
+
data
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Deep merge two hashes
|
|
102
|
+
# @param hash1 [Hash] the first hash
|
|
103
|
+
# @param hash2 [Hash] the second hash
|
|
104
|
+
# @return [Hash] the merged hash
|
|
105
|
+
def deep_merge(hash1, hash2)
|
|
106
|
+
hash1.merge(hash2) do |key, old_val, new_val|
|
|
107
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
108
|
+
deep_merge(old_val, new_val)
|
|
109
|
+
else
|
|
110
|
+
new_val
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Apply input path to filter input data
|
|
116
|
+
# @param input [Hash] the input data
|
|
117
|
+
# @param input_path [String, nil] the input path to apply
|
|
118
|
+
# @return [Hash] the filtered input data
|
|
119
|
+
def apply_input_path(input, input_path)
|
|
120
|
+
return input unless input_path
|
|
121
|
+
get_value_from_path(input, input_path) || {}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Apply output path to filter output data
|
|
125
|
+
# @param output [Hash] the output data
|
|
126
|
+
# @param output_path [String, nil] the output path to apply
|
|
127
|
+
# @return [Hash] the filtered output data
|
|
128
|
+
def apply_output_path(output, output_path)
|
|
129
|
+
return output unless output_path
|
|
130
|
+
set_value_at_path({}, output_path, output)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Apply result path to merge result with input
|
|
134
|
+
# @param input [Hash] the original input data
|
|
135
|
+
# @param result [Hash] the result data
|
|
136
|
+
# @param result_path [String, nil] the result path to apply
|
|
137
|
+
# @return [Hash] the merged data
|
|
138
|
+
def apply_result_path(input, result, result_path)
|
|
139
|
+
return result unless result_path
|
|
140
|
+
|
|
141
|
+
if result_path.nil?
|
|
142
|
+
input
|
|
143
|
+
else
|
|
144
|
+
set_value_at_path(input.dup, result_path, result)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatesLanguageMachine
|
|
4
|
+
module States
|
|
5
|
+
class Choice < Base
|
|
6
|
+
attr_reader :choices, :default
|
|
7
|
+
|
|
8
|
+
def initialize(name, definition)
|
|
9
|
+
super
|
|
10
|
+
@choices = definition["Choices"] || []
|
|
11
|
+
@default = definition["Default"]
|
|
12
|
+
@evaluated_next_state = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(execution, input)
|
|
16
|
+
execution.logger&.info("Executing choice state: #{@name}")
|
|
17
|
+
|
|
18
|
+
@evaluated_next_state = evaluate_choices(input, execution.logger)
|
|
19
|
+
|
|
20
|
+
execution.logger&.info("Selected next state: #{@evaluated_next_state}")
|
|
21
|
+
process_result(execution, input)
|
|
22
|
+
input
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def next_state_name(input = nil)
|
|
26
|
+
return nil if end_state?
|
|
27
|
+
return @evaluated_next_state if @evaluated_next_state
|
|
28
|
+
input ? evaluate_choices(input) : @default
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def evaluate_choices(input, logger = nil)
|
|
34
|
+
@choices.each do |choice|
|
|
35
|
+
puts choice
|
|
36
|
+
result = evaluate_choice(choice, input, logger)
|
|
37
|
+
puts result
|
|
38
|
+
if result
|
|
39
|
+
return choice["Next"]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
@default
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def evaluate_choice(choice, input, logger = nil)
|
|
46
|
+
if choice["And"]
|
|
47
|
+
return choice["And"].all? { |condition| evaluate_choice(condition, input, logger) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if choice["Or"]
|
|
51
|
+
return choice["Or"].any? { |condition| evaluate_choice(condition, input, logger) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if choice["Not"]
|
|
55
|
+
return !evaluate_choice(choice["Not"], input, logger)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
condition_type = choice.keys.find { |k| !["Variable", "Next", "Comment"].include?(k) }
|
|
59
|
+
return false unless condition_type
|
|
60
|
+
|
|
61
|
+
variable_path = choice["Variable"]
|
|
62
|
+
expected_value = choice[condition_type]
|
|
63
|
+
actual_value = get_value_from_path(input, variable_path)
|
|
64
|
+
|
|
65
|
+
evaluate_simple_condition(condition_type, actual_value, expected_value, logger)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Evaluate a simple condition
|
|
69
|
+
# @param condition_type [String] the type of condition
|
|
70
|
+
# @param actual_value [Object] the actual value from input
|
|
71
|
+
# @param expected_value [Object] the expected value from condition
|
|
72
|
+
# @param logger [Logger, nil] the logger for debug output
|
|
73
|
+
# @return [Boolean] whether the condition matches
|
|
74
|
+
def evaluate_simple_condition(condition_type, actual_value, expected_value, logger = nil)
|
|
75
|
+
puts condition_type
|
|
76
|
+
case condition_type
|
|
77
|
+
when "NumericEquals"
|
|
78
|
+
actual_num = to_number(actual_value)
|
|
79
|
+
expected_num = to_number(expected_value)
|
|
80
|
+
return false if actual_num.nil? || expected_num.nil?
|
|
81
|
+
actual_num == expected_num
|
|
82
|
+
when "NumericLessThan"
|
|
83
|
+
actual_num = to_number(actual_value)
|
|
84
|
+
expected_num = to_number(expected_value)
|
|
85
|
+
return false if actual_num.nil? || expected_num.nil?
|
|
86
|
+
puts "result of NumericLessThan", actual_num < expected_num
|
|
87
|
+
actual_num < expected_num
|
|
88
|
+
when "NumericGreaterThan"
|
|
89
|
+
actual_num = to_number(actual_value)
|
|
90
|
+
expected_num = to_number(expected_value)
|
|
91
|
+
return false if actual_num.nil? || expected_num.nil?
|
|
92
|
+
actual_num > expected_num
|
|
93
|
+
when "NumericLessThanEquals"
|
|
94
|
+
actual_num = to_number(actual_value)
|
|
95
|
+
expected_num = to_number(expected_value)
|
|
96
|
+
return false if actual_num.nil? || expected_num.nil?
|
|
97
|
+
actual_num <= expected_num
|
|
98
|
+
when "NumericGreaterThanEquals"
|
|
99
|
+
actual_num = to_number(actual_value)
|
|
100
|
+
expected_num = to_number(expected_value)
|
|
101
|
+
return false if actual_num.nil? || expected_num.nil?
|
|
102
|
+
actual_num >= expected_num
|
|
103
|
+
when "StringEquals"
|
|
104
|
+
actual_value.to_s == expected_value.to_s
|
|
105
|
+
when "BooleanEquals"
|
|
106
|
+
to_boolean(actual_value) == to_boolean(expected_value)
|
|
107
|
+
when "IsNull"
|
|
108
|
+
actual_value.nil?
|
|
109
|
+
when "IsPresent"
|
|
110
|
+
!actual_value.nil?
|
|
111
|
+
when "IsString"
|
|
112
|
+
actual_value.is_a?(String)
|
|
113
|
+
when "IsNumeric"
|
|
114
|
+
!!to_number(actual_value)
|
|
115
|
+
when "IsBoolean"
|
|
116
|
+
actual_value.is_a?(TrueClass) || actual_value.is_a?(FalseClass) ||
|
|
117
|
+
(actual_value.is_a?(String) && ["true", "false"].include?(actual_value.downcase))
|
|
118
|
+
else
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_number(value)
|
|
124
|
+
return value if value.is_a?(Numeric)
|
|
125
|
+
return nil if value.nil?
|
|
126
|
+
|
|
127
|
+
if value.is_a?(String)
|
|
128
|
+
Float(value) rescue nil
|
|
129
|
+
else
|
|
130
|
+
Float(value) rescue nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_boolean(value)
|
|
135
|
+
return value if [true, false].include?(value)
|
|
136
|
+
value.to_s.downcase == "true"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate!
|
|
140
|
+
# Skip base validation for choice states
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|