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.
@@ -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