wolflow 0.0.1

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