floe 0.13.1 → 0.15.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ module ChildWorkflowMixin
7
+ def run_nonblock!(context)
8
+ start(context) unless context.state_started?
9
+
10
+ step_nonblock!(context) while running?(context)
11
+ return Errno::EAGAIN unless ready?(context)
12
+
13
+ finish(context) if ended?(context)
14
+ end
15
+
16
+ def finish(context)
17
+ if success?(context)
18
+ result = each_child_context(context).map(&:output)
19
+ context.output = process_output(context, result)
20
+ else
21
+ error = parse_error(context)
22
+ retry_state!(context, error) || catch_error!(context, error) || fail_workflow!(context, error)
23
+ end
24
+
25
+ super
26
+ end
27
+
28
+ def ready?(context)
29
+ !context.state_started? || each_child_workflow(context).any? { |wf, ctx| wf.step_nonblock_ready?(ctx) }
30
+ end
31
+
32
+ def wait_until(context)
33
+ each_child_workflow(context).filter_map { |wf, ctx| wf.wait_until(ctx) }.min
34
+ end
35
+
36
+ def waiting?(context)
37
+ each_child_workflow(context).any? { |wf, ctx| wf.waiting?(ctx) }
38
+ end
39
+
40
+ def running?(context)
41
+ !ended?(context)
42
+ end
43
+
44
+ def ended?(context)
45
+ each_child_context(context).all?(&:ended?)
46
+ end
47
+
48
+ def success?(context)
49
+ each_child_context(context).none?(&:failed?)
50
+ end
51
+
52
+ def each_child_context(context)
53
+ context.state[child_context_key].map { |ctx| Context.new(ctx) }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -9,10 +9,9 @@ module Floe
9
9
  def initialize(workflow, name, payload)
10
10
  super
11
11
 
12
- validate_state!(workflow)
13
-
14
- @choices = payload["Choices"].map.with_index { |choice, i| ChoiceRule.build(workflow, name + ["Choices", i.to_s], choice) }
12
+ @choices = payload["Choices"]&.map&.with_index { |choice, i| ChoiceRule.build(workflow, name + ["Choices", i.to_s], choice) }
15
13
  @default = payload["Default"]
14
+ validate_state!(workflow)
16
15
 
17
16
  @input_path = Path.new(payload.fetch("InputPath", "$"))
18
17
  @output_path = Path.new(payload.fetch("OutputPath", "$"))
@@ -23,6 +22,7 @@ module Floe
23
22
  output = output_path.value(context, input)
24
23
  next_state = choices.detect { |choice| choice.true?(context, output) }&.next || default
25
24
 
25
+ runtime_field_error!("Default", nil, "not defined and no match found", :floe_error => "States.NoChoiceMatched") if next_state.nil?
26
26
  context.next_state = next_state
27
27
  context.output = output
28
28
  super
@@ -44,12 +44,12 @@ module Floe
44
44
  end
45
45
 
46
46
  def validate_state_choices!
47
- missing_field_error!("Choices") unless payload.key?("Choices")
48
- invalid_field_error!("Choices", nil, "must be a non-empty array") unless payload["Choices"].kind_of?(Array) && !payload["Choices"].empty?
47
+ missing_field_error!("Choices") if @choices.nil?
48
+ invalid_field_error!("Choices", nil, "must be a non-empty array") unless @choices.kind_of?(Array) && !@choices.empty?
49
49
  end
50
50
 
51
51
  def validate_state_default!(workflow)
52
- invalid_field_error!("Default", payload["Default"], "is not found in \"States\"") if payload["Default"] && !workflow_state?(payload["Default"], workflow)
52
+ invalid_field_error!("Default", @default, "is not found in \"States\"") if @default && !workflow_state?(@default, workflow)
53
53
  end
54
54
  end
55
55
  end
@@ -4,9 +4,123 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Map < Floe::Workflow::State
7
- def initialize(*)
7
+ include ChildWorkflowMixin
8
+ include InputOutputMixin
9
+ include NonTerminalMixin
10
+ include RetryCatchMixin
11
+
12
+ attr_reader :end, :next, :parameters, :input_path, :output_path, :result_path,
13
+ :result_selector, :retry, :catch, :item_processor, :items_path,
14
+ :item_reader, :item_selector, :item_batcher, :result_writer,
15
+ :max_concurrency, :tolerated_failure_percentage, :tolerated_failure_count
16
+
17
+ def initialize(workflow, name, payload)
18
+ super
19
+
20
+ missing_field_error!("InputProcessor") if payload["ItemProcessor"].nil?
21
+
22
+ @next = payload["Next"]
23
+ @end = !!payload["End"]
24
+ @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
25
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
26
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
27
+ @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
28
+ @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
29
+ @retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
30
+ @catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
31
+ @item_processor = ItemProcessor.new(payload["ItemProcessor"], name)
32
+ @items_path = ReferencePath.new(payload.fetch("ItemsPath", "$"))
33
+ @item_reader = payload["ItemReader"]
34
+ @item_selector = payload["ItemSelector"]
35
+ @item_batcher = payload["ItemBatcher"]
36
+ @result_writer = payload["ResultWriter"]
37
+ @max_concurrency = payload["MaxConcurrency"]&.to_i
38
+ @tolerated_failure_percentage = payload["ToleratedFailurePercentage"]&.to_i
39
+ @tolerated_failure_count = payload["ToleratedFailureCount"]&.to_i
40
+
41
+ validate_state!(workflow)
42
+ end
43
+
44
+ def process_input(context)
45
+ input = super
46
+ items_path.value(context, input)
47
+ end
48
+
49
+ def start(context)
8
50
  super
9
- raise NotImplementedError
51
+
52
+ input = process_input(context)
53
+
54
+ context.state["ItemProcessorContext"] = input.map { |item| Context.new({"Execution" => {"Id" => context.execution["Id"]}}, :input => item.to_json).to_h }
55
+ end
56
+
57
+ def end?
58
+ @end
59
+ end
60
+
61
+ def success?(context)
62
+ contexts = each_child_context(context)
63
+ num_failed = contexts.count(&:failed?)
64
+ total = contexts.count
65
+
66
+ return true if num_failed.zero? || total.zero?
67
+ return false if tolerated_failure_count.nil? && tolerated_failure_percentage.nil?
68
+
69
+ # Some have failed, check the tolerated_failure thresholds to see if
70
+ # we should fail the whole state.
71
+ #
72
+ # If either ToleratedFailureCount or ToleratedFailurePercentage are breached
73
+ # then the whole state is considered failed.
74
+ count_tolerated = tolerated_failure_count.nil? || num_failed < tolerated_failure_count
75
+ pct_tolerated = tolerated_failure_percentage.nil? || tolerated_failure_percentage == 100 ||
76
+ ((100 * num_failed / total.to_f) < tolerated_failure_percentage)
77
+
78
+ count_tolerated && pct_tolerated
79
+ end
80
+
81
+ private
82
+
83
+ def step_nonblock!(context)
84
+ each_child_context(context).each do |ctx|
85
+ # If this iteration isn't already running and we can't start any more
86
+ next if !ctx.started? && concurrency_exceeded?(context)
87
+
88
+ item_processor.run_nonblock(ctx) if item_processor.step_nonblock_ready?(ctx)
89
+ end
90
+ end
91
+
92
+ def each_child_workflow(context)
93
+ each_child_context(context).map do |ctx|
94
+ [item_processor, Context.new(ctx)]
95
+ end
96
+ end
97
+
98
+ def concurrency_exceeded?(context)
99
+ max_concurrency && num_running(context) >= max_concurrency
100
+ end
101
+
102
+ def num_running(context)
103
+ each_child_context(context).count(&:running?)
104
+ end
105
+
106
+ def parse_error(context)
107
+ # If ToleratedFailureCount or ToleratedFailurePercentage is present
108
+ # then use States.ExceedToleratedFailureThreshold otherwise
109
+ # take the error from the first failed state
110
+ if tolerated_failure_count || tolerated_failure_percentage
111
+ {"Error" => "States.ExceedToleratedFailureThreshold"}
112
+ else
113
+ each_child_context(context).detect(&:failed?)&.output || {"Error" => "States.Error"}
114
+ end
115
+ end
116
+
117
+ def child_context_key
118
+ "ItemProcessorContext"
119
+ end
120
+
121
+ def validate_state!(workflow)
122
+ validate_state_next!(workflow)
123
+ invalid_field_error!("MaxConcurrency", @max_concurrency, "must be greater than 0") if @max_concurrency && @max_concurrency <= 0
10
124
  end
11
125
  end
12
126
  end
@@ -4,9 +4,72 @@ module Floe
4
4
  class Workflow
5
5
  module States
6
6
  class Parallel < Floe::Workflow::State
7
- def initialize(*)
7
+ include ChildWorkflowMixin
8
+ include InputOutputMixin
9
+ include NonTerminalMixin
10
+ include RetryCatchMixin
11
+
12
+ attr_reader :end, :next, :parameters, :input_path, :output_path, :result_path,
13
+ :result_selector, :retry, :catch, :branches
14
+
15
+ def initialize(workflow, name, payload)
16
+ super
17
+
18
+ missing_field_error!("Branches") if payload["Branches"].nil?
19
+
20
+ @next = payload["Next"]
21
+ @end = !!payload["End"]
22
+ @parameters = PayloadTemplate.new(payload["Parameters"]) if payload["Parameters"]
23
+ @input_path = Path.new(payload.fetch("InputPath", "$"))
24
+ @output_path = Path.new(payload.fetch("OutputPath", "$"))
25
+ @result_path = ReferencePath.new(payload.fetch("ResultPath", "$"))
26
+ @result_selector = PayloadTemplate.new(payload["ResultSelector"]) if payload["ResultSelector"]
27
+ @retry = payload["Retry"].to_a.map { |retrier| Retrier.new(retrier) }
28
+ @catch = payload["Catch"].to_a.map { |catcher| Catcher.new(catcher) }
29
+ @branches = payload["Branches"].map { |branch| Branch.new(branch) }
30
+
31
+ validate_state!(workflow)
32
+ end
33
+
34
+ def start(context)
8
35
  super
9
- raise NotImplementedError
36
+
37
+ input = process_input(context)
38
+
39
+ context.state["BranchContext"] = branches.map { |_branch| Context.new({"Execution" => {"Id" => context.execution["Id"]}}, :input => input.to_json).to_h }
40
+ end
41
+
42
+ def end?
43
+ @end
44
+ end
45
+
46
+ private
47
+
48
+ def step_nonblock!(context)
49
+ each_child_workflow(context).each do |wf, ctx|
50
+ wf.run_nonblock(ctx) if wf.step_nonblock_ready?(ctx)
51
+ end
52
+ end
53
+
54
+ def each_child_workflow(context)
55
+ branches.filter_map.with_index do |branch, i|
56
+ ctx = context.state.dig("BranchContext", i)
57
+ next if ctx.nil?
58
+
59
+ [branch, Context.new(ctx)]
60
+ end
61
+ end
62
+
63
+ def parse_error(context)
64
+ each_child_context(context).detect(&:failed?)&.output || {"Error" => "States.Error"}
65
+ end
66
+
67
+ def child_context_key
68
+ "BranchContext"
69
+ end
70
+
71
+ def validate_state!(workflow)
72
+ validate_state_next!(workflow)
10
73
  end
11
74
  end
12
75
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class Workflow
5
+ module States
6
+ module RetryCatchMixin
7
+ def find_retrier(error)
8
+ self.retry.detect { |r| r.match_error?(error) }
9
+ end
10
+
11
+ def find_catcher(error)
12
+ self.catch.detect { |c| c.match_error?(error) }
13
+ end
14
+
15
+ def retry_state!(context, error)
16
+ retrier = find_retrier(error["Error"]) if error
17
+ return if retrier.nil?
18
+
19
+ # If a different retrier is hit reset the context
20
+ if !context["State"].key?("RetryCount") || context["State"]["Retrier"] != retrier.error_equals
21
+ context["State"]["RetryCount"] = 0
22
+ context["State"]["Retrier"] = retrier.error_equals
23
+ end
24
+
25
+ context["State"]["RetryCount"] += 1
26
+
27
+ return if context["State"]["RetryCount"] > retrier.max_attempts
28
+
29
+ wait_until!(context, :seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
30
+ context.next_state = context.state_name
31
+ context.output = error
32
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}] got error[#{context.json_output}]...Retry - delay: #{wait_until(context)}")
33
+ true
34
+ end
35
+
36
+ def catch_error!(context, error)
37
+ catcher = find_catcher(error["Error"]) if error
38
+ return if catcher.nil?
39
+
40
+ context.next_state = catcher.next
41
+ context.output = catcher.result_path.set(context.input, error)
42
+ logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...CatchError - next state: [#{context.next_state}] output: [#{context.json_output}]")
43
+
44
+ true
45
+ end
46
+
47
+ def fail_workflow!(context, error)
48
+ # next_state is nil, and will be set to nil again in super
49
+ # keeping in here for completeness
50
+ context.next_state = nil
51
+ context.output = error
52
+ logger.error("Running state: [#{long_name}] with input [#{context.json_input}]...Complete workflow - output: [#{context.json_output}]")
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -6,6 +6,7 @@ module Floe
6
6
  class Task < Floe::Workflow::State
7
7
  include InputOutputMixin
8
8
  include NonTerminalMixin
9
+ include RetryCatchMixin
9
10
 
10
11
  attr_reader :credentials, :end, :heartbeat_seconds, :next, :parameters,
11
12
  :result_selector, :resource, :timeout_seconds, :retry, :catch,
@@ -82,54 +83,6 @@ module Floe
82
83
  runner.success?(context.state["RunnerContext"])
83
84
  end
84
85
 
85
- def find_retrier(error)
86
- self.retry.detect { |r| r.match_error?(error) }
87
- end
88
-
89
- def find_catcher(error)
90
- self.catch.detect { |c| c.match_error?(error) }
91
- end
92
-
93
- def retry_state!(context, error)
94
- retrier = find_retrier(error["Error"]) if error
95
- return if retrier.nil?
96
-
97
- # If a different retrier is hit reset the context
98
- if !context["State"].key?("RetryCount") || context["State"]["Retrier"] != retrier.error_equals
99
- context["State"]["RetryCount"] = 0
100
- context["State"]["Retrier"] = retrier.error_equals
101
- end
102
-
103
- context["State"]["RetryCount"] += 1
104
-
105
- return if context["State"]["RetryCount"] > retrier.max_attempts
106
-
107
- wait_until!(context, :seconds => retrier.sleep_duration(context["State"]["RetryCount"]))
108
- context.next_state = context.state_name
109
- context.output = error
110
- logger.info("Running state: [#{long_name}] with input [#{context.json_input}] got error[#{context.json_output}]...Retry - delay: #{wait_until(context)}")
111
- true
112
- end
113
-
114
- def catch_error!(context, error)
115
- catcher = find_catcher(error["Error"]) if error
116
- return if catcher.nil?
117
-
118
- context.next_state = catcher.next
119
- context.output = catcher.result_path.set(context.input, error)
120
- logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...CatchError - next state: [#{context.next_state}] output: [#{context.json_output}]")
121
-
122
- true
123
- end
124
-
125
- def fail_workflow!(context, error)
126
- # next_state is nil, and will be set to nil again in super
127
- # keeping in here for completeness
128
- context.next_state = nil
129
- context.output = error
130
- logger.error("Running state: [#{long_name}] with input [#{context.json_input}]...Complete workflow - output: [#{context.json_output}]")
131
- end
132
-
133
86
  def parse_error(output)
134
87
  return if output.nil?
135
88
  return output if output.kind_of?(Hash)
data/lib/floe/workflow.rb CHANGED
@@ -4,9 +4,8 @@ require "securerandom"
4
4
  require "json"
5
5
 
6
6
  module Floe
7
- class Workflow
7
+ class Workflow < Floe::WorkflowBase
8
8
  include Logging
9
- include ValidationMixin
10
9
 
11
10
  class << self
12
11
  def load(path_or_io, context = nil, credentials = {}, name = nil)
@@ -19,7 +18,7 @@ module Floe
19
18
 
20
19
  def wait(workflows, timeout: nil, &block)
21
20
  workflows = [workflows] if workflows.kind_of?(self)
22
- logger.info("checking #{workflows.count} workflows...")
21
+ logger.info("Checking #{workflows.count} workflows...")
23
22
 
24
23
  run_until = Time.now.utc + timeout if timeout.to_i > 0
25
24
  ready = []
@@ -63,30 +62,24 @@ module Floe
63
62
 
64
63
  loop do
65
64
  # Block until an event is raised
66
- event, runner_context = queue.pop
65
+ event, data = queue.pop
67
66
  break if event.nil?
68
67
 
69
- # If the event is for one of our workflows set the updated runner_context
70
- workflows.each do |workflow|
71
- next unless workflow.context.state.dig("RunnerContext", "container_ref") == runner_context["container_ref"]
72
-
73
- workflow.context.state["RunnerContext"] = runner_context
74
- end
75
-
76
- break if queue.empty?
68
+ # break out of the loop if the event is for one of our workflows
69
+ break if queue.empty? || workflows.detect { |wf| wf.execution_id == data["execution_id"] }
77
70
  end
78
71
  ensure
79
72
  sleep_thread&.kill
80
73
  end
81
74
 
82
- logger.info("checking #{workflows.count} workflows...Complete - #{ready.count} ready")
75
+ logger.info("Checking #{workflows.count} workflows...Complete - #{ready.count} ready")
83
76
  ready
84
77
  ensure
85
78
  wait_thread&.kill
86
79
  end
87
80
  end
88
81
 
89
- attr_reader :context, :payload, :states, :states_by_name, :start_at, :name, :comment
82
+ attr_reader :comment, :context
90
83
 
91
84
  def initialize(payload, context = nil, credentials = nil, name = nil)
92
85
  payload = JSON.parse(payload) if payload.kind_of?(String)
@@ -97,20 +90,10 @@ module Floe
97
90
  # caller should really put credentials into context and not pass that variable
98
91
  context.credentials = credentials if credentials
99
92
 
100
- # NOTE: this is a string, and states use an array
101
- @name = name || "State Machine"
102
- @payload = payload
103
- @context = context
104
- @comment = payload["Comment"]
105
- @start_at = payload["StartAt"]
106
-
107
- # NOTE: Everywhere else we include our name (i.e.: parent name) when building the child name.
108
- # When creating the states, we are dropping our name (i.e.: the workflow name)
109
- @states = payload["States"].to_a.map { |state_name, state| State.build!(self, ["States", state_name], state) }
110
-
111
- validate_workflow
93
+ @context = context
94
+ @comment = payload["Comment"]
112
95
 
113
- @states_by_name = @states.each_with_object({}) { |state, result| result[state.short_name] = state }
96
+ super(payload, name)
114
97
  rescue Floe::Error
115
98
  raise
116
99
  rescue => err
@@ -175,7 +158,7 @@ module Floe
175
158
  context.state["Input"] = context.execution["Input"].dup
176
159
  context.state["Guid"] = SecureRandom.uuid
177
160
 
178
- context.execution["Id"] = SecureRandom.uuid
161
+ context.execution["Id"] ||= SecureRandom.uuid
179
162
  context.execution["StartTime"] = Time.now.utc.iso8601
180
163
 
181
164
  self
@@ -183,7 +166,7 @@ module Floe
183
166
 
184
167
  # NOTE: Expecting the context to be initialized (via start_workflow) before this
185
168
  def current_state
186
- @states_by_name[context.state_name]
169
+ states_by_name[context.state_name]
187
170
  end
188
171
 
189
172
  # backwards compatibility. Caller should access directly from context
@@ -191,14 +174,12 @@ module Floe
191
174
  @context.credentials
192
175
  end
193
176
 
194
- private
195
-
196
- def validate_workflow
197
- missing_field_error!("States") if @states.empty?
198
- missing_field_error!("StartAt") if @start_at.nil?
199
- invalid_field_error!("StartAt", @start_at, "is not found in \"States\"") unless workflow_state?(@start_at, self)
177
+ def execution_id
178
+ @context.execution["Id"]
200
179
  end
201
180
 
181
+ private
182
+
202
183
  def step!
203
184
  next_state = {"Name" => context.next_state, "Guid" => SecureRandom.uuid, "PreviousStateGuid" => context.state["Guid"]}
204
185
 
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Floe
4
+ class WorkflowBase
5
+ include ValidationMixin
6
+
7
+ attr_reader :name, :payload, :start_at, :states, :states_by_name
8
+
9
+ def initialize(payload, name = nil)
10
+ # NOTE: this is a string, and states use an array
11
+ @name = name || "State Machine"
12
+ @payload = payload
13
+ @start_at = payload["StartAt"]
14
+
15
+ # NOTE: Everywhere else we include our name (i.e.: parent name) when building the child name.
16
+ # When creating the states, we are dropping our name (i.e.: the workflow name)
17
+ @states = payload["States"].to_a.map { |state_name, state| Floe::Workflow::State.build!(self, ["States", state_name], state) }
18
+ @states_by_name = @states.to_h { |state| [state.short_name, state] }
19
+
20
+ validate_workflow!
21
+ end
22
+
23
+ def run(context)
24
+ run_nonblock(context) until context.ended?
25
+ end
26
+
27
+ def run_nonblock(context)
28
+ start_workflow(context)
29
+ loop while step_nonblock(context) == 0 && !context.ended?
30
+ self
31
+ end
32
+
33
+ def step_nonblock(context)
34
+ return Errno::EPERM if context.ended?
35
+
36
+ result = current_state(context).run_nonblock!(context)
37
+ return result if result != 0
38
+
39
+ context.state_history << context.state
40
+ context.next_state ? step!(context) : end_workflow!(context)
41
+
42
+ result
43
+ end
44
+
45
+ def step_nonblock_ready?(context)
46
+ !context.started? || current_state(context).ready?(context)
47
+ end
48
+
49
+ def waiting?(context)
50
+ current_state(context)&.waiting?(context)
51
+ end
52
+
53
+ def wait_until(context)
54
+ current_state(context)&.wait_until(context)
55
+ end
56
+
57
+ def start_workflow(context)
58
+ return if context.state_name
59
+
60
+ context.state["Name"] = start_at
61
+ context.state["Input"] = context.execution["Input"].dup
62
+
63
+ context.execution["StartTime"] = Time.now.utc.iso8601
64
+
65
+ self
66
+ end
67
+
68
+ def current_state(context)
69
+ states_by_name[context.state_name]
70
+ end
71
+
72
+ def end?(context)
73
+ context.ended?
74
+ end
75
+
76
+ def output(context)
77
+ context.output.to_json if end?(context)
78
+ end
79
+
80
+ private
81
+
82
+ def step!(context)
83
+ next_state = {"Name" => context.next_state}
84
+
85
+ # if rerunning due to an error (and we are using Retry)
86
+ if context.state_name == context.next_state && context.failed? && context.state.key?("Retrier")
87
+ next_state.merge!(context.state.slice("RetryCount", "Input", "Retrier"))
88
+ else
89
+ next_state["Input"] = context.output
90
+ end
91
+
92
+ context.state = next_state
93
+ end
94
+
95
+ # Avoiding State#running? because that is potentially expensive.
96
+ # State#run_nonblock! already called running? via State#ready? and
97
+ # called State#finished -- which is what Context#state_finished? is detecting
98
+ def end_workflow!(context)
99
+ context.execution["EndTime"] = context.state["FinishedTime"]
100
+ end
101
+
102
+ def validate_workflow!
103
+ missing_field_error!("States") if @states.empty?
104
+ missing_field_error!("StartAt") if @start_at.nil?
105
+ invalid_field_error!("StartAt", @start_at, "is not found in \"States\"") unless workflow_state?(@start_at, self)
106
+ end
107
+ end
108
+ end