wolflow 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +239 -0
  4. data/README.md +393 -0
  5. data/lib/wolflow/cycle.rb +67 -0
  6. data/lib/wolflow/dsl.rb +93 -0
  7. data/lib/wolflow/errors.rb +13 -0
  8. data/lib/wolflow/exclusive_choice.rb +106 -0
  9. data/lib/wolflow/extensions.rb +20 -0
  10. data/lib/wolflow/multi_choice.rb +127 -0
  11. data/lib/wolflow/multi_merge.rb +20 -0
  12. data/lib/wolflow/operators/attribute.rb +22 -0
  13. data/lib/wolflow/operators/base.rb +67 -0
  14. data/lib/wolflow/operators/literal.rb +22 -0
  15. data/lib/wolflow/operators/operation.rb +42 -0
  16. data/lib/wolflow/recursion.rb +21 -0
  17. data/lib/wolflow/simple.rb +134 -0
  18. data/lib/wolflow/simple_merge.rb +19 -0
  19. data/lib/wolflow/start.rb +9 -0
  20. data/lib/wolflow/structured_loop.rb +106 -0
  21. data/lib/wolflow/structured_synchronized_merge.rb +45 -0
  22. data/lib/wolflow/synchronization.rb +17 -0
  23. data/lib/wolflow/task.rb +195 -0
  24. data/lib/wolflow/task_spec.rb +114 -0
  25. data/lib/wolflow/version.rb +5 -0
  26. data/lib/wolflow/workflow.rb +82 -0
  27. data/lib/wolflow/workflow_spec.rb +82 -0
  28. data/lib/wolflow.rb +25 -0
  29. data/sig/cycle.rbs +7 -0
  30. data/sig/dsl.rbs +36 -0
  31. data/sig/exclusive_choice.rbs +12 -0
  32. data/sig/multi_choice.rbs +23 -0
  33. data/sig/multi_merge.rbs +6 -0
  34. data/sig/operators/attribute.rbs +11 -0
  35. data/sig/operators/base.rbs +21 -0
  36. data/sig/operators/literal.rbs +15 -0
  37. data/sig/operators/operation.rbs +10 -0
  38. data/sig/operators.rbs +7 -0
  39. data/sig/recursion.rbs +4 -0
  40. data/sig/simple.rbs +22 -0
  41. data/sig/simple_merge.rbs +6 -0
  42. data/sig/start.rbs +4 -0
  43. data/sig/structured_loop.rbs +8 -0
  44. data/sig/structured_synchronized_merge.rbs +6 -0
  45. data/sig/synchronization.rbs +6 -0
  46. data/sig/task.rbs +66 -0
  47. data/sig/task_spec.rbs +52 -0
  48. data/sig/wolflow.rbs +18 -0
  49. data/sig/workflow.rbs +33 -0
  50. data/sig/workflow_spec.rbs +20 -0
  51. metadata +115 -0
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Simple < TaskSpec
5
+ attr_reader :next_tasks
6
+
7
+ def initialize(next_tasks: [], **kwargs)
8
+ super(**kwargs)
9
+ @next_tasks = next_tasks
10
+ end
11
+
12
+ def on_complete(task)
13
+ task.mark_as_complete!
14
+
15
+ predict(task, @next_tasks)
16
+ end
17
+
18
+ def connect(*task_specs)
19
+ task_specs.each do |task_spec|
20
+ connect_one(task_spec)
21
+ end
22
+
23
+ if block_given?
24
+ yield(*task_specs)
25
+ return self
26
+ end
27
+
28
+ return task_specs.first if task_specs.size <= 1
29
+
30
+ task_specs
31
+ end
32
+
33
+ def choose(*else_task_specs, **args)
34
+ choice = ExclusiveChoice.new(workflow_spec: @workflow_spec, else_tasks: else_task_specs, **args)
35
+ next_tasks << choice
36
+ choice.prev_tasks << self
37
+
38
+ if block_given?
39
+ yield(choice, *else_task_specs)
40
+ return self
41
+ end
42
+
43
+ [choice, *else_task_specs]
44
+ end
45
+
46
+ def precedes?(task_spec)
47
+ @next_tasks.include?(task_spec)
48
+ end
49
+
50
+ def to_hash
51
+ {
52
+ id: @id,
53
+ name: @name,
54
+ next_tasks: @next_tasks.map(&:id)
55
+ }
56
+ end
57
+
58
+ def to_hash_tree
59
+ hash_tree = @next_tasks.flat_map(&:to_hash_tree)
60
+ hash_tree.unshift(to_hash)
61
+ hash_tree
62
+ end
63
+
64
+ def inspect
65
+ "#<#{self.class}:#{hash} " \
66
+ "@id=#{@id} " \
67
+ "@next_tasks=#{@next_tasks.map(&:id)}>"
68
+ end
69
+
70
+ def workflow_spec=(workflow_spec)
71
+ return if workflow_spec == @workflow_spec
72
+
73
+ super
74
+
75
+ @next_tasks.each do |task_spec|
76
+ task_spec.workflow_spec = workflow_spec
77
+ end
78
+ end
79
+
80
+ # private-ish
81
+ def connects_with(tasks)
82
+ return unless @connects_to
83
+
84
+ @connects_to.each do |id|
85
+ connect(tasks.fetch(id))
86
+ end
87
+
88
+ @connects_to = nil
89
+ end
90
+
91
+ def build_next_tasks(task, i: 0, next_tasks: @next_tasks)
92
+ children = next_tasks.map do |next_task|
93
+ id = next_task.id
94
+ id = "#{id}_#{i}" unless i.zero?
95
+ task.class.new(
96
+ id: id,
97
+ task_spec: next_task,
98
+ workflow: task.workflow,
99
+ root: task.root
100
+ )
101
+ end
102
+ task.connect(*children)
103
+ children.each do |child|
104
+ child.task_spec.build_next_tasks(child, i: i)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def connect_one(task_spec)
111
+ if @workflow_spec
112
+ task_spec.workflow_spec = @workflow_spec
113
+ elsif task_spec.workflow_spec
114
+ raise TaskSpecError, "#{task_spec} already defined in a workflow spec"
115
+ end
116
+
117
+ task_spec.prev_tasks << self
118
+ @next_tasks << task_spec
119
+ end
120
+
121
+ class << self
122
+ def from_hash(hash)
123
+ case hash
124
+ in { next_tasks: [*, String, *] => next_tasks, **args }
125
+ spec = super(**args)
126
+ spec.connects_to = next_tasks
127
+ spec
128
+ else
129
+ super
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class SimpleMerge < Simple
5
+ def join(*task_specs)
6
+ task_specs.each { |spec| spec.connect(self) }
7
+
8
+ self
9
+ end
10
+
11
+ def on_complete(task)
12
+ return unless task.parents.one?(&:completed?)
13
+
14
+ task.parents.each { |parent| parent.disconnect(task) unless parent.completed? }
15
+
16
+ super
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Start < Simple
5
+ def initialize(**args)
6
+ super(id: "start", **args)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class StructuredLoop < ExclusiveChoice
5
+ def connect(condition, *task_specs)
6
+ raise TaskSpecError, "can only link with one parent" unless task_specs.size == 1
7
+
8
+ # @type var task_spec: TaskSpec
9
+ task_spec = task_specs.first
10
+
11
+ raise TaskSpecError, "can only link with parent" unless child_of?(task_spec)
12
+
13
+ super
14
+ end
15
+
16
+ def on_complete(task)
17
+ @condition_next_tasks.each do |cond, next_tasks|
18
+ next unless cond.call(task)
19
+
20
+ parent_task = task.children.find { |child_task| next_tasks.include?(child_task.task_spec) }
21
+
22
+ raise Error, "parent task not found for #{task}" unless parent_task
23
+
24
+ reset_task(parent_task)
25
+
26
+ return # rubocop:disable Lint/NonLocalExitFromIterator
27
+ end
28
+ task.mark_as_complete!
29
+
30
+ predict(task, @else_tasks)
31
+ end
32
+
33
+ def to_hash
34
+ hs = super
35
+ hs[:reset_task] = hs.delete(:condition_next_tasks).first
36
+ hs[:else_tasks] = hs.delete(:else_tasks)
37
+ hs
38
+ end
39
+
40
+ def each_child(task, &blk)
41
+ super(task) do |child|
42
+ next unless task.task_spec.else_tasks.include?(child.task_spec)
43
+
44
+ blk.call(child)
45
+ end
46
+ end
47
+
48
+ # private-ish
49
+ def connects_with(tasks)
50
+ return unless @connects_to
51
+
52
+ cond, id = @connects_to.fetch(:reset_task)
53
+ cond = Operators.from_hash(cond)
54
+ reset_task = tasks[id]
55
+ reset_task.connects_with(tasks)
56
+ connect(cond, reset_task)
57
+ connect_else(*@connects_to.fetch(:else_tasks).map(&tasks.method(:[])))
58
+ end
59
+
60
+ private
61
+
62
+ def connect_cond(condition, task_specs)
63
+ @condition_next_tasks << [condition, task_specs]
64
+ end
65
+
66
+ def predict(task, next_tasks); end
67
+
68
+ def reset_task(task)
69
+ task.reset!
70
+
71
+ task.task_spec.each_child(task) do |child_task|
72
+ next unless child_of?(child_task.task_spec)
73
+
74
+ next if self == child_task.task_spec
75
+
76
+ reset_task(child_task)
77
+ end
78
+ end
79
+
80
+ class << self
81
+ def from_hash(hash)
82
+ case hash
83
+ in {
84
+ reset_task: [{ type: String, **}, TaskSpec],
85
+ else_tasks: [*, TaskSpec, *] => else_tasks,
86
+ **args
87
+ }
88
+ super
89
+ in {
90
+ reset_task: [{ type: String, **}, String] => reset_task,
91
+ else_tasks: [*, String, *] => else_tasks,
92
+ **args
93
+ }
94
+ spec = super(**args)
95
+ spec.connects_to = {
96
+ reset_task: reset_task,
97
+ else_tasks: else_tasks
98
+ }
99
+ spec
100
+ else # rubocop:disable Lint/DuplicateBranch
101
+ super
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class StructuredSynchronizedMerge < Simple
5
+ def join(*task_specs)
6
+ multi_choices = task_specs.map do |task_spec|
7
+ task_spec.each_ancestor.find do |spec|
8
+ spec.is_a?(MultiChoice)
9
+ end or raise(TaskSpecError, "#{task_spec.id} is not from a multi-choice branch")
10
+ end
11
+
12
+ raise TaskSpecError, "not branches from the same multi-choice" unless multi_choices.uniq.size == 1
13
+
14
+ # @type var multi_choice: MultiChoice
15
+ multi_choice = multi_choices.first
16
+
17
+ unless multi_choice.next_tasks.size == multi_choices.size
18
+ raise TaskSpecError,
19
+ "not joining all branches from the common multi-choice"
20
+ end
21
+
22
+ task_specs.each { |spec| spec.connect(self) }
23
+
24
+ self
25
+ end
26
+
27
+ def on_complete(task)
28
+ parents_with_multi_choice = task.parents.select(&:completed?).filter_map do |parent|
29
+ multi_choice = parent.each_parent.find { |t| t.task_spec.is_a?(MultiChoice) }
30
+ [parent, multi_choice] if multi_choice
31
+ end
32
+
33
+ multi_choices = parents_with_multi_choice.map(&:last).uniq
34
+
35
+ return unless multi_choices.size == 1
36
+
37
+ # @type var multi_choice: Task
38
+ multi_choice = multi_choices.first
39
+
40
+ return unless multi_choice.children.size == parents_with_multi_choice.size
41
+
42
+ super
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Synchronization < Simple
5
+ def join(*task_specs)
6
+ task_specs.each { |spec| spec.connect(self) }
7
+
8
+ self
9
+ end
10
+
11
+ def on_complete(task)
12
+ return unless task.parents.all?(&:completed?)
13
+
14
+ super
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Task
5
+ attr_reader :id, :task_spec, :root, :workflow, :parents, :children, :data
6
+
7
+ def initialize(
8
+ id: object_id.to_s,
9
+ task_spec: nil,
10
+ workflow: nil,
11
+ completed: false,
12
+ root: nil,
13
+ parents: [],
14
+ children: [],
15
+ data: {}
16
+ )
17
+ @id = id
18
+ @task_spec = task_spec
19
+ @completed = completed
20
+ @workflow = workflow
21
+ @root = root
22
+ @parents = parents
23
+ @children = children
24
+ @data = data
25
+ end
26
+
27
+ def initialize_dup(other)
28
+ super
29
+ @parents = other.instance_variable_get(:@parents).dup
30
+ @children = other.instance_variable_get(:@children).dup
31
+ @data = other.instance_variable_get(:@data).dup
32
+ end
33
+
34
+ def initialize_clone(other, **kwargs)
35
+ super
36
+ @parents = other.instance_variable_get(:@parents).clone(**kwargs)
37
+ @children = other.instance_variable_get(:@children).clone(**kwargs)
38
+ @data = other.instance_variable_get(:@data).clone(**kwargs)
39
+ end
40
+
41
+ def completed?
42
+ @completed
43
+ end
44
+
45
+ def complete!
46
+ @task_spec.on_complete(self) if @task_spec
47
+ end
48
+
49
+ def mark_as_complete!
50
+ @completed = true
51
+ end
52
+
53
+ def reset!
54
+ @completed = false
55
+ end
56
+
57
+ def connect(*tasks)
58
+ tasks.each do |task|
59
+ connect_one(task)
60
+ end
61
+
62
+ if block_given?
63
+ yield(*tasks)
64
+ return self
65
+ end
66
+
67
+ return tasks.first if tasks.size <= 1
68
+
69
+ tasks
70
+ end
71
+
72
+ def disconnect(task)
73
+ unless @children.include?(task) && task.parents.include?(self)
74
+ raise TaskError, "#{self} and #{task} are not connected"
75
+ end
76
+
77
+ task.parents.delete(self)
78
+ @children.delete(task)
79
+ end
80
+
81
+ def build_task_tree
82
+ raise TaskError, "there's already a task tree" unless @children.empty?
83
+
84
+ @task_spec.build_next_tasks(self)
85
+ end
86
+
87
+ def copy_task_tree(i)
88
+ @task_spec.build_next_tasks(self, i: i)
89
+ end
90
+
91
+ def each(visited = [], &blk)
92
+ return enum_for(__method__, visited) unless blk
93
+
94
+ blk.call(self) unless @task_spec.id.start_with?("__")
95
+
96
+ visited << self
97
+
98
+ @task_spec.each_child(self) do |child|
99
+ # do not allow multiple visits on the same node
100
+ next if visited.include?(child)
101
+
102
+ if child.task_spec.is_a?(Synchronization) && !child.parents.all? { |parent| visited.include?(parent) }
103
+ # only jump in after all parents are consumed
104
+ next
105
+ end
106
+
107
+ child.each(visited, &blk)
108
+ end
109
+ self
110
+ end
111
+
112
+ def each_parent(visited = [self], &blk)
113
+ return enum_for(__method__, visited) unless blk
114
+
115
+ @task_spec.each_parent(self) do |parent|
116
+ # do not allow multiple visits on the same node
117
+ next if visited.include?(parent)
118
+
119
+ visited << parent
120
+
121
+ blk.call(parent)
122
+
123
+ parent.each_parent(visited, &blk)
124
+ end
125
+ self
126
+ end
127
+
128
+ def inspect
129
+ "#<#{self.class}:#{hash} " \
130
+ "@id=#{@id} " \
131
+ "@parents=#{@parents.map(&:id)} " \
132
+ "@children=#{@children.map(&:id)} " \
133
+ "@completed=#{@completed}>"
134
+ end
135
+
136
+ # private-ish
137
+ def connects_with(tasks)
138
+ @parents = @parents.map do |parent|
139
+ parent.is_a?(Task) ? parent : tasks.fetch(parent)
140
+ end
141
+ @children = @children.map do |child|
142
+ child.is_a?(Task) ? child : tasks.fetch(child)
143
+ end
144
+ end
145
+
146
+ def to_hash
147
+ {
148
+ id: @id,
149
+ parents: @parents.map(&:id),
150
+ children: @children.map(&:id),
151
+ completed: @completed,
152
+ data: (@data.to_hash unless @data.empty?),
153
+ task_spec_id: (@task_spec.id if @task_spec)
154
+ }.compact
155
+ end
156
+
157
+ class << self
158
+ def from_hash(hash)
159
+ if hash.key?(:task_spec) && !hash[:task_spec].is_a?(TaskSpec)
160
+ task_spec = TaskSpec.spec_types[hash[:task_spec]]
161
+
162
+ raise Error, "no task spec registered for #{hash[:task_spec]}" unless task_spec
163
+
164
+ hash[:task_spec] = task_spec.new
165
+ end
166
+
167
+ new(**hash)
168
+ end
169
+ end
170
+
171
+ protected
172
+
173
+ def root=(rt)
174
+ @root = rt
175
+ @children.each do |task|
176
+ task.root = rt
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ def connect_one(task)
183
+ raise TaskError, "#{task} is from a different workflow" if @workflow && @workflow != task.workflow
184
+
185
+ unless @task_spec.nil? || @task_spec.precedes?(task.task_spec)
186
+ raise TaskError,
187
+ "#{task} can't connect from task spec"
188
+ end
189
+
190
+ task.parents << self
191
+ task.root = @root || self
192
+ @children << task
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class TaskSpec
5
+ using StringExtensions unless ::String.method_defined?(:underscore)
6
+
7
+ attr_reader :id, :name, :workflow_spec, :prev_tasks
8
+
9
+ attr_writer :connects_to
10
+
11
+ def initialize(id: object_id.to_s, name: self.class.spec_type, workflow_spec: nil, prev_tasks: [])
12
+ @id = id
13
+ @name = name
14
+ @workflow_spec = workflow_spec
15
+ @prev_tasks = prev_tasks
16
+ end
17
+
18
+ def each_child(task, &)
19
+ task.children.each(&)
20
+ end
21
+
22
+ def each_parent(task, &)
23
+ task.parents.each(&)
24
+ end
25
+
26
+ def each_successor(visited = [self], &blk)
27
+ return enum_for(__method__, visited) unless blk
28
+
29
+ next_tasks.each do |spec|
30
+ next if visited.include?(spec)
31
+
32
+ blk.call(spec)
33
+
34
+ visited << spec
35
+
36
+ spec.each_successor(visited, &blk)
37
+ end
38
+ end
39
+
40
+ def each_ancestor(visited = [self], &blk)
41
+ return enum_for(__method__, visited) unless blk
42
+
43
+ @prev_tasks.each do |spec|
44
+ next if visited.include?(spec)
45
+
46
+ blk.call(spec)
47
+
48
+ visited << spec
49
+
50
+ spec.each_ancestor(visited, &blk)
51
+ end
52
+ end
53
+
54
+ def workflow_spec=(workflow_spec)
55
+ if @workflow_spec
56
+ return if workflow_spec == @workflow_spec
57
+
58
+ raise TaskSpecError, "#{self} already defined in a workflow spec"
59
+ end
60
+
61
+ @workflow_spec = workflow_spec
62
+ end
63
+
64
+ def child_of?(spec)
65
+ spec.next_tasks.include?(self) || spec.next_tasks.one? { |child_spec| child_of?(child_spec) }
66
+ end
67
+
68
+ private
69
+
70
+ def predict(task, next_tasks)
71
+ return unless task.children.empty?
72
+
73
+ next_tasks.each do |task_spec|
74
+ task.connect(Task.new(task_spec: task_spec, workflow: task.workflow))
75
+ end
76
+ end
77
+
78
+ @spec_types = {}
79
+
80
+ class << self
81
+ attr_reader :spec_types, :spec_type
82
+
83
+ def inherited(subclass)
84
+ super
85
+
86
+ return superclass.inherited(subclass) unless self == TaskSpec
87
+
88
+ name = subclass.name
89
+
90
+ return unless name # anon class
91
+
92
+ tag = name.demodulize.underscore
93
+
94
+ raise Error, "spec type for #{tag} already exists" if @spec_types.key?(tag)
95
+
96
+ subclass.instance_variable_set(:@spec_type, tag)
97
+ subclass.instance_variable_get(:@spec_type).freeze
98
+
99
+ @spec_types[tag] = subclass
100
+ end
101
+
102
+ def from_hash(hash)
103
+ case hash
104
+ in { id: String => id, name: String => name, ** }
105
+ TaskSpec.spec_types[name].new(**hash)
106
+ in { id: String => id }
107
+ new(id: id)
108
+ else
109
+ raise TaskSpecError, "can't deserialize #{hash} to a TaskSpec"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ VERSION = "0.0.1"
5
+ end