floe 0.13.1 → 0.15.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,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