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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Cycle < Simple
5
+ def connect(*specs)
6
+ raise TaskSpecError, "cycles can only connect to a single task spec" unless specs.size == 1
7
+
8
+ spec = specs.first
9
+
10
+ raise TaskSpecError, "cycles can only connect to previous task specs" unless child_of?(spec)
11
+
12
+ super
13
+ end
14
+
15
+ def on_complete(task)
16
+ super
17
+
18
+ task.children.each do |child_task|
19
+ next if task == child_task
20
+
21
+ reset_task(child_task)
22
+ end
23
+
24
+ task.reset!
25
+ end
26
+
27
+ def to_hash_tree
28
+ [to_hash]
29
+ end
30
+
31
+ def each_child(task); end
32
+
33
+ # def each_parent(task); end
34
+
35
+ private
36
+
37
+ def predict(task, next_tasks)
38
+ return unless task.children.empty?
39
+
40
+ workflow = task.workflow
41
+
42
+ return unless workflow
43
+
44
+ tasks = workflow.each.each_with_object([]) do |wtask, wtasks| # rubocop:disable Style/RedundantEach
45
+ next unless next_tasks.include?(wtask.task_spec)
46
+
47
+ wtasks << wtask
48
+
49
+ break if wtasks.size == next_tasks.size
50
+ end
51
+
52
+ task.connect(*tasks)
53
+ end
54
+
55
+ def reset_task(task)
56
+ task.reset!
57
+
58
+ task.task_spec.each_child(task) do |child_task|
59
+ next unless child_of?(child_task.task_spec)
60
+
61
+ next if self == child_task.task_spec
62
+
63
+ reset_task(child_task)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wolflow"
4
+
5
+ module Wolflow
6
+ module DSL
7
+ def self.load!
8
+ WorkflowSpec.prepend(WorkflowSpecDSL)
9
+ Simple.prepend(SimpleDSL)
10
+ MultiChoice.prepend(ChoiceDSL)
11
+ Wolflow.extend(WolflowDSL)
12
+ end
13
+
14
+ module WolflowDSL
15
+ def spec(id, &blk)
16
+ wf = WorkflowSpec.new(id: id)
17
+
18
+ blk.call(wf) if blk
19
+
20
+ wf
21
+ end
22
+ end
23
+
24
+ module WorkflowSpecDSL
25
+ def connect(*ids, &)
26
+ return super unless ids.all?(String)
27
+
28
+ specs = ids.map { |id| Simple.new(id: id) }
29
+
30
+ super(*specs, &)
31
+ end
32
+ end
33
+
34
+ module SimpleDSL
35
+ def initialize(...)
36
+ @on_complete_callbacks = []
37
+ super
38
+ end
39
+
40
+ def on_perform(&blk)
41
+ @on_complete_callbacks << blk
42
+
43
+ self
44
+ end
45
+
46
+ def on_complete(task)
47
+ @on_complete_callbacks.each do |blk|
48
+ blk.call(task)
49
+ end
50
+
51
+ super
52
+ end
53
+
54
+ def connect(*ids, &)
55
+ return super unless ids.all?(String)
56
+
57
+ specs = ids.map { |id| Simple.new(id: id, workflow_spec: @workflow_spec) }
58
+
59
+ super(*specs, &)
60
+ end
61
+
62
+ def choose(*ids, **, &)
63
+ return super unless ids.all?(String) && ids.size > 1
64
+
65
+ if_id, *else_ids = ids
66
+
67
+ else_specs = else_ids.map { |else_id| Simple.new(id: else_id, workflow_spec: @workflow_spec) }
68
+
69
+ super(*else_specs, id: if_id, &)
70
+ end
71
+
72
+ def join(id, *task_specs)
73
+ synch = Synchronization.new(id: id, workflow_spec: @workflow_spec)
74
+
75
+ synch.join(self, *task_specs)
76
+
77
+ synch
78
+ end
79
+ end
80
+
81
+ module ChoiceDSL
82
+ def connect(*ids, cond)
83
+ return super unless ids.all?(String)
84
+
85
+ specs = ids.map { |id| Simple.new(id: id, workflow_spec: @workflow_spec) }
86
+
87
+ super(cond, *specs)
88
+ end
89
+ end
90
+ end
91
+
92
+ DSL.load!
93
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Error < StandardError; end
5
+
6
+ class WorkflowSpecError < Error; end
7
+
8
+ class TaskSpecError < Error; end
9
+
10
+ class WorkflowError < Error; end
11
+
12
+ class TaskError < Error; end
13
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class ExclusiveChoice < MultiChoice
5
+ attr_reader :else_tasks
6
+
7
+ def initialize(else_tasks: [], **kwargs)
8
+ super(**kwargs)
9
+ @else_tasks = Array(else_tasks)
10
+
11
+ @else_tasks.each do |task_spec|
12
+ task_spec.prev_tasks << self
13
+
14
+ @workflow_spec ||= task_spec.workflow_spec
15
+ end
16
+ end
17
+
18
+ def next_tasks
19
+ [*super, *@else_tasks]
20
+ end
21
+
22
+ def on_complete(task)
23
+ task.mark_as_complete!
24
+
25
+ @condition_next_tasks.each do |cond, next_tasks|
26
+ next unless cond.call(task)
27
+
28
+ predict(task, next_tasks)
29
+ return # rubocop:disable Lint/NonLocalExitFromIterator
30
+ end
31
+ predict(task, @else_tasks)
32
+ end
33
+
34
+ def connect_else(*task_specs)
35
+ task_specs.each do |task_spec|
36
+ task_spec.prev_tasks << self
37
+ @else_tasks << task_spec
38
+
39
+ @workflow_spec ||= task_spec.workflow_spec
40
+ end
41
+
42
+ return task_specs.first if task_specs.size <= 1
43
+
44
+ task_specs
45
+ end
46
+
47
+ def precedes?(task_spec)
48
+ @else_tasks.include?(task_spec) || super
49
+ end
50
+
51
+ def to_hash
52
+ super.merge(
53
+ else_tasks: @else_tasks.map(&:id)
54
+ )
55
+ end
56
+
57
+ def to_hash_tree
58
+ [*super, *@else_tasks.flat_map(&:to_hash_tree)]
59
+ end
60
+
61
+ def inspect
62
+ "<#{self.class}:#{hash} " \
63
+ "@id=#{@id} " \
64
+ "@condition_next_tasks=#{@condition_next_tasks.map { |k, t| [k, t.map(&:id)] }} " \
65
+ "@else_tasks=#{@else_tasks.map(&:id)}>"
66
+ end
67
+
68
+ def workflow_spec=(workflow_spec)
69
+ return if workflow_spec == @workflow_spec
70
+
71
+ super
72
+
73
+ @else_tasks.each do |task_spec|
74
+ task_spec.workflow_spec = workflow_spec
75
+ end
76
+ end
77
+
78
+ # private-ish
79
+ def connects_with(tasks)
80
+ return unless @connects_to
81
+
82
+ connect_else(*@connects_to.fetch(:else_tasks).map { |id| tasks[id] })
83
+ super
84
+ end
85
+
86
+ class << self
87
+ def from_hash(hash)
88
+ case hash
89
+ in {
90
+ condition_next_tasks: [[{ type: String, **}, [*, String, *]]] => cond_tasks,
91
+ else_tasks: [*, String, *] => else_tasks,
92
+ **args
93
+ }
94
+ spec = super(**args)
95
+ spec.connects_to = {
96
+ condition_next_tasks: cond_tasks,
97
+ else_tasks: else_tasks
98
+ }
99
+ spec
100
+ else
101
+ super
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Wolflow
6
+ module StringExtensions
7
+ refine ::String do
8
+ # https://github.com/jeremyevans/sequel/blob/58dd8d6e17d86cbf8809467c339be59aad54d5bf/lib/sequel/extensions/inflector.rb#L254
9
+ def underscore
10
+ gsub("::", "/").gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
11
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
12
+ end
13
+
14
+ # https://github.com/jeremyevans/sequel/blob/58dd8d6e17d86cbf8809467c339be59aad54d5bf/lib/sequel/extensions/inflector.rb#L170
15
+ def demodulize
16
+ gsub(/^.*::/, "")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class MultiChoice < TaskSpec
5
+ attr_reader :condition_next_tasks
6
+
7
+ def initialize(condition_next_tasks: [], **kwargs)
8
+ super(**kwargs)
9
+ @condition_next_tasks = condition_next_tasks
10
+ end
11
+
12
+ def next_tasks
13
+ @condition_next_tasks.flat_map { |_, tasks| tasks }
14
+ end
15
+
16
+ def on_complete(task)
17
+ task.mark_as_complete!
18
+
19
+ succ_next_tasks = @condition_next_tasks.filter_map do |cond, next_tasks|
20
+ next_tasks if cond.call(task)
21
+ end.flatten
22
+ predict(task, succ_next_tasks)
23
+ end
24
+
25
+ def connect(condition, *task_specs)
26
+ raise ArgumentError, "condition must response to #call" unless condition.respond_to?(:call)
27
+
28
+ connect_cond(condition, task_specs)
29
+
30
+ if block_given?
31
+ yield(*task_specs)
32
+ return self
33
+ end
34
+
35
+ return task_specs.first if task_specs.size <= 1
36
+
37
+ task_specs
38
+ end
39
+
40
+ def build_next_tasks(task, **); end
41
+
42
+ def precedes?(task_spec)
43
+ @condition_next_tasks.one? { |_, tspecs| tspecs.include?(task_spec) }
44
+ end
45
+
46
+ def to_hash
47
+ {
48
+ id: @id,
49
+ name: @name,
50
+ condition_next_tasks: @condition_next_tasks.map do |condition, task_specs|
51
+ condition_hash = Hash.try_convert(condition)
52
+ raise Error, "can't convert #{condition} to a hash" unless condition_hash
53
+
54
+ [condition_hash, task_specs.map(&:id)]
55
+ end
56
+ }
57
+ end
58
+
59
+ def to_hash_tree
60
+ [to_hash, *@condition_next_tasks.flat_map { |_, ts| ts.flat_map(&:to_hash_tree) }]
61
+ end
62
+
63
+ def inspect
64
+ "#<#{self.class}:#{hash} " \
65
+ "@id=#{@id} " \
66
+ "@condition_next_tasks=#{@condition_next_tasks.map { |k, t| [k, t.map(&:id)] }}>"
67
+ end
68
+
69
+ def workflow_spec=(workflow_spec)
70
+ return if workflow_spec == @workflow_spec
71
+
72
+ super
73
+
74
+ @condition_next_tasks.each do |_, task_specs| # rubocop:disable Style/HashEachMethods
75
+ task_specs.each do |task_spec|
76
+ task_spec.workflow_spec = workflow_spec
77
+ end
78
+ end
79
+ end
80
+
81
+ # private-ish
82
+ def connects_with(tasks)
83
+ return unless @connects_to
84
+
85
+ @connects_to.fetch(:condition_next_tasks).each do |cond, ids|
86
+ cond = Operators.from_hash(cond)
87
+ connect(cond, *ids.map { |id| tasks[id] })
88
+ end
89
+
90
+ @connects_to = nil
91
+ end
92
+
93
+ private
94
+
95
+ def connect_cond(condition, task_specs)
96
+ @condition_next_tasks << [condition, task_specs]
97
+
98
+ task_specs.each { |task_spec| task_spec.prev_tasks << self }
99
+ end
100
+
101
+ def predict(task, next_tasks)
102
+ if task.children.empty?
103
+ super
104
+ else
105
+ task.children.delete_if { |child_task| !next_tasks.include?(child_task.task_spec) }
106
+ end
107
+ end
108
+
109
+ class << self
110
+ def from_hash(hash)
111
+ case hash
112
+ in {
113
+ condition_next_tasks: [[{ type: String, **}, [*, String, *]]] => cond_tasks,
114
+ **args
115
+ }
116
+ spec = super(**args)
117
+ spec.connects_to = {
118
+ condition_next_tasks: cond_tasks
119
+ }
120
+ spec
121
+ else
122
+ super
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class MultiMerge < 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 super if task.parents.all?(&:completed?)
13
+
14
+ # build repeat tree
15
+ task.copy_task_tree(task.parents.size)
16
+
17
+ super
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ module Operators
5
+ class Attribute < Base
6
+ def initialize(name:, **args)
7
+ @name = name
8
+ super(**args)
9
+ end
10
+
11
+ def call(task)
12
+ task.data.fetch(@name)
13
+ end
14
+
15
+ def to_hash
16
+ hs = super
17
+ hs[:name] = @name
18
+ hs
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ module Operators
5
+ @op_types = {}
6
+
7
+ class << self
8
+ attr_reader :op_types
9
+
10
+ def from_hash(hash)
11
+ case hash
12
+ in { type: String => type, **args }
13
+ operator_class = Operators.op_types.fetch(type)
14
+ args = operator_class.from_hash(args)
15
+ operator_class.new(**args)
16
+ else
17
+ raise TaskSpecError, "can't deserialize #{hash} to an operator"
18
+ end
19
+ end
20
+ end
21
+
22
+ class Base
23
+ using StringExtensions unless ::String.method_defined?(:underscore)
24
+
25
+ def initialize(type: self.class.op_type)
26
+ @type = type
27
+ end
28
+
29
+ def to_hash
30
+ {
31
+ type: @type
32
+ }
33
+ end
34
+
35
+ class << self
36
+ attr_reader :op_type
37
+
38
+ def inherited(subclass)
39
+ super
40
+
41
+ return unless self == Base
42
+
43
+ name = subclass.name
44
+
45
+ return unless name # anon class
46
+
47
+ tag = name.demodulize.underscore
48
+
49
+ raise Error, "operation type for #{tag} already exists" if Operators.op_types.key?(tag)
50
+
51
+ subclass.instance_variable_set(:@op_type, tag)
52
+ subclass.instance_variable_get(:@op_type).freeze
53
+
54
+ Operators.op_types[tag] = subclass
55
+ end
56
+
57
+ def from_hash(hash)
58
+ hash
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ require_relative "literal"
66
+ require_relative "attribute"
67
+ require_relative "operation"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ module Operators
5
+ class Literal < Base
6
+ def initialize(value:, **args)
7
+ @value = value
8
+ super(**args)
9
+ end
10
+
11
+ def call(*)
12
+ @value
13
+ end
14
+
15
+ def to_hash
16
+ hs = super
17
+ hs[:value] = @value
18
+ hs
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ module Operators
5
+ class Operation < Base
6
+ def initialize(op:, members:, **args)
7
+ @op = op
8
+ @members = Array(members)
9
+ super(**args)
10
+ end
11
+
12
+ def call(task)
13
+ reducer, *members = @members
14
+
15
+ return unless reducer
16
+
17
+ reducer = reducer.call(task)
18
+
19
+ members.reduce(reducer) do |acc, member|
20
+ acc.__send__(@op, member.call(task))
21
+ end
22
+ end
23
+
24
+ def to_hash
25
+ hs = super
26
+ hs[:op] = @op
27
+ hs[:members] = @members.map(&:to_hash)
28
+ hs
29
+ end
30
+
31
+ def self.from_hash(hash)
32
+ case hash
33
+ in { op: String, members: [*, Hash, *] => members }
34
+ members.map! { |m| Operators.from_hash(m) }
35
+ super(hash.merge(members: members))
36
+ else
37
+ super
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wolflow
4
+ class Recursion < Simple
5
+ def on_complete(task)
6
+ return if task.completed?
7
+
8
+ return unless task.children.empty?
9
+
10
+ super
11
+ end
12
+
13
+ def predict(task, next_tasks)
14
+ build_next_tasks(task, next_tasks: next_tasks)
15
+ end
16
+
17
+ def build_next_tasks(task, **)
18
+ super if task.completed?
19
+ end
20
+ end
21
+ end