cmdx 1.21.0 → 2.0.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.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -1
  3. data/README.md +37 -24
  4. data/lib/cmdx/.DS_Store +0 -0
  5. data/lib/cmdx/callbacks.rb +179 -0
  6. data/lib/cmdx/chain.rb +78 -175
  7. data/lib/cmdx/coercions/array.rb +19 -33
  8. data/lib/cmdx/coercions/big_decimal.rb +12 -29
  9. data/lib/cmdx/coercions/boolean.rb +25 -45
  10. data/lib/cmdx/coercions/coerce.rb +32 -0
  11. data/lib/cmdx/coercions/complex.rb +12 -27
  12. data/lib/cmdx/coercions/date.rb +29 -33
  13. data/lib/cmdx/coercions/date_time.rb +29 -33
  14. data/lib/cmdx/coercions/float.rb +8 -29
  15. data/lib/cmdx/coercions/hash.rb +17 -43
  16. data/lib/cmdx/coercions/integer.rb +8 -32
  17. data/lib/cmdx/coercions/rational.rb +12 -33
  18. data/lib/cmdx/coercions/string.rb +6 -24
  19. data/lib/cmdx/coercions/symbol.rb +12 -26
  20. data/lib/cmdx/coercions/time.rb +31 -35
  21. data/lib/cmdx/coercions.rb +174 -0
  22. data/lib/cmdx/configuration.rb +45 -237
  23. data/lib/cmdx/context.rb +264 -243
  24. data/lib/cmdx/deprecation.rb +67 -0
  25. data/lib/cmdx/deprecators/error.rb +22 -0
  26. data/lib/cmdx/deprecators/log.rb +22 -0
  27. data/lib/cmdx/deprecators/warn.rb +21 -0
  28. data/lib/cmdx/deprecators.rb +101 -0
  29. data/lib/cmdx/errors.rb +145 -79
  30. data/lib/cmdx/executors/fiber.rb +42 -0
  31. data/lib/cmdx/executors/thread.rb +36 -0
  32. data/lib/cmdx/executors.rb +95 -0
  33. data/lib/cmdx/fault.rb +85 -78
  34. data/lib/cmdx/i18n_proxy.rb +104 -0
  35. data/lib/cmdx/input.rb +294 -0
  36. data/lib/cmdx/inputs.rb +218 -0
  37. data/lib/cmdx/log_formatters/json.rb +9 -20
  38. data/lib/cmdx/log_formatters/key_value.rb +10 -21
  39. data/lib/cmdx/log_formatters/line.rb +7 -19
  40. data/lib/cmdx/log_formatters/logstash.rb +8 -21
  41. data/lib/cmdx/log_formatters/raw.rb +8 -20
  42. data/lib/cmdx/logger_proxy.rb +30 -0
  43. data/lib/cmdx/mergers/deep_merge.rb +23 -0
  44. data/lib/cmdx/mergers/last_write_wins.rb +23 -0
  45. data/lib/cmdx/mergers/no_merge.rb +20 -0
  46. data/lib/cmdx/mergers.rb +95 -0
  47. data/lib/cmdx/middlewares.rb +128 -0
  48. data/lib/cmdx/output.rb +115 -0
  49. data/lib/cmdx/outputs.rb +66 -0
  50. data/lib/cmdx/pipeline.rb +144 -131
  51. data/lib/cmdx/railtie.rb +10 -36
  52. data/lib/cmdx/result.rb +247 -524
  53. data/lib/cmdx/retriers/bounded_random.rb +24 -0
  54. data/lib/cmdx/retriers/decorrelated_jitter.rb +28 -0
  55. data/lib/cmdx/retriers/exponential.rb +23 -0
  56. data/lib/cmdx/retriers/fibonacci.rb +39 -0
  57. data/lib/cmdx/retriers/full_random.rb +23 -0
  58. data/lib/cmdx/retriers/half_random.rb +24 -0
  59. data/lib/cmdx/retriers/linear.rb +23 -0
  60. data/lib/cmdx/retriers.rb +106 -0
  61. data/lib/cmdx/retry.rb +117 -138
  62. data/lib/cmdx/runtime.rb +251 -0
  63. data/lib/cmdx/settings.rb +68 -200
  64. data/lib/cmdx/signal.rb +165 -0
  65. data/lib/cmdx/task.rb +443 -343
  66. data/lib/cmdx/telemetry.rb +108 -0
  67. data/lib/cmdx/util.rb +73 -0
  68. data/lib/cmdx/validators/absence.rb +10 -39
  69. data/lib/cmdx/validators/exclusion.rb +33 -52
  70. data/lib/cmdx/validators/format.rb +19 -49
  71. data/lib/cmdx/validators/inclusion.rb +33 -54
  72. data/lib/cmdx/validators/length.rb +125 -127
  73. data/lib/cmdx/validators/numeric.rb +123 -123
  74. data/lib/cmdx/validators/presence.rb +10 -39
  75. data/lib/cmdx/validators/validate.rb +31 -0
  76. data/lib/cmdx/validators.rb +161 -0
  77. data/lib/cmdx/version.rb +2 -4
  78. data/lib/cmdx/workflow.rb +71 -96
  79. data/lib/cmdx.rb +111 -42
  80. data/lib/generators/cmdx/install_generator.rb +7 -17
  81. data/lib/generators/cmdx/task_generator.rb +12 -29
  82. data/lib/generators/cmdx/templates/install.rb +120 -48
  83. data/lib/generators/cmdx/templates/task.rb.tt +1 -1
  84. data/lib/generators/cmdx/templates/workflow.rb.tt +1 -2
  85. data/lib/generators/cmdx/workflow_generator.rb +12 -29
  86. data/lib/locales/en.yml +8 -7
  87. data/mkdocs.yml +25 -23
  88. metadata +39 -138
  89. data/lib/cmdx/attribute.rb +0 -440
  90. data/lib/cmdx/attribute_registry.rb +0 -185
  91. data/lib/cmdx/attribute_value.rb +0 -252
  92. data/lib/cmdx/callback_registry.rb +0 -169
  93. data/lib/cmdx/coercion_registry.rb +0 -138
  94. data/lib/cmdx/deprecator.rb +0 -77
  95. data/lib/cmdx/exception.rb +0 -46
  96. data/lib/cmdx/executor.rb +0 -378
  97. data/lib/cmdx/identifier.rb +0 -30
  98. data/lib/cmdx/locale.rb +0 -78
  99. data/lib/cmdx/middleware_registry.rb +0 -148
  100. data/lib/cmdx/middlewares/correlate.rb +0 -140
  101. data/lib/cmdx/middlewares/runtime.rb +0 -77
  102. data/lib/cmdx/middlewares/timeout.rb +0 -78
  103. data/lib/cmdx/parallelizer.rb +0 -100
  104. data/lib/cmdx/utils/call.rb +0 -53
  105. data/lib/cmdx/utils/condition.rb +0 -71
  106. data/lib/cmdx/utils/format.rb +0 -82
  107. data/lib/cmdx/utils/normalize.rb +0 -52
  108. data/lib/cmdx/utils/wrap.rb +0 -38
  109. data/lib/cmdx/validator_registry.rb +0 -143
  110. data/lib/generators/cmdx/locale_generator.rb +0 -39
  111. data/lib/locales/af.yml +0 -55
  112. data/lib/locales/ar.yml +0 -55
  113. data/lib/locales/az.yml +0 -55
  114. data/lib/locales/be.yml +0 -55
  115. data/lib/locales/bg.yml +0 -55
  116. data/lib/locales/bn.yml +0 -55
  117. data/lib/locales/bs.yml +0 -55
  118. data/lib/locales/ca.yml +0 -55
  119. data/lib/locales/cnr.yml +0 -55
  120. data/lib/locales/cs.yml +0 -55
  121. data/lib/locales/cy.yml +0 -55
  122. data/lib/locales/da.yml +0 -55
  123. data/lib/locales/de.yml +0 -55
  124. data/lib/locales/dz.yml +0 -55
  125. data/lib/locales/el.yml +0 -55
  126. data/lib/locales/eo.yml +0 -55
  127. data/lib/locales/es.yml +0 -55
  128. data/lib/locales/et.yml +0 -55
  129. data/lib/locales/eu.yml +0 -55
  130. data/lib/locales/fa.yml +0 -55
  131. data/lib/locales/fi.yml +0 -55
  132. data/lib/locales/fr.yml +0 -55
  133. data/lib/locales/fy.yml +0 -55
  134. data/lib/locales/gd.yml +0 -55
  135. data/lib/locales/gl.yml +0 -55
  136. data/lib/locales/he.yml +0 -55
  137. data/lib/locales/hi.yml +0 -55
  138. data/lib/locales/hr.yml +0 -55
  139. data/lib/locales/hu.yml +0 -55
  140. data/lib/locales/hy.yml +0 -55
  141. data/lib/locales/id.yml +0 -55
  142. data/lib/locales/is.yml +0 -55
  143. data/lib/locales/it.yml +0 -55
  144. data/lib/locales/ja.yml +0 -55
  145. data/lib/locales/ka.yml +0 -55
  146. data/lib/locales/kk.yml +0 -55
  147. data/lib/locales/km.yml +0 -55
  148. data/lib/locales/kn.yml +0 -55
  149. data/lib/locales/ko.yml +0 -55
  150. data/lib/locales/lb.yml +0 -55
  151. data/lib/locales/lo.yml +0 -55
  152. data/lib/locales/lt.yml +0 -55
  153. data/lib/locales/lv.yml +0 -55
  154. data/lib/locales/mg.yml +0 -55
  155. data/lib/locales/mk.yml +0 -55
  156. data/lib/locales/ml.yml +0 -55
  157. data/lib/locales/mn.yml +0 -55
  158. data/lib/locales/mr-IN.yml +0 -55
  159. data/lib/locales/ms.yml +0 -55
  160. data/lib/locales/nb.yml +0 -55
  161. data/lib/locales/ne.yml +0 -55
  162. data/lib/locales/nl.yml +0 -55
  163. data/lib/locales/nn.yml +0 -55
  164. data/lib/locales/oc.yml +0 -55
  165. data/lib/locales/or.yml +0 -55
  166. data/lib/locales/pa.yml +0 -55
  167. data/lib/locales/pl.yml +0 -55
  168. data/lib/locales/pt.yml +0 -55
  169. data/lib/locales/rm.yml +0 -55
  170. data/lib/locales/ro.yml +0 -55
  171. data/lib/locales/ru.yml +0 -55
  172. data/lib/locales/sc.yml +0 -55
  173. data/lib/locales/sk.yml +0 -55
  174. data/lib/locales/sl.yml +0 -55
  175. data/lib/locales/sq.yml +0 -55
  176. data/lib/locales/sr.yml +0 -55
  177. data/lib/locales/st.yml +0 -55
  178. data/lib/locales/sv.yml +0 -55
  179. data/lib/locales/sw.yml +0 -55
  180. data/lib/locales/ta.yml +0 -55
  181. data/lib/locales/te.yml +0 -55
  182. data/lib/locales/th.yml +0 -55
  183. data/lib/locales/tl.yml +0 -55
  184. data/lib/locales/tr.yml +0 -55
  185. data/lib/locales/tt.yml +0 -55
  186. data/lib/locales/ug.yml +0 -55
  187. data/lib/locales/uk.yml +0 -55
  188. data/lib/locales/ur.yml +0 -55
  189. data/lib/locales/uz.yml +0 -55
  190. data/lib/locales/vi.yml +0 -55
  191. data/lib/locales/wo.yml +0 -55
  192. data/lib/locales/zh-CN.yml +0 -55
  193. data/lib/locales/zh-HK.yml +0 -55
  194. data/lib/locales/zh-TW.yml +0 -55
  195. data/lib/locales/zh-YUE.yml +0 -55
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Ordered list of middlewares wrapping a task's lifecycle. Each middleware
5
+ # is a callable with the signature `call(task) { next_link.call }`; Runtime
6
+ # builds a nested chain and requires each middleware to yield to the next.
7
+ class Middlewares
8
+
9
+ attr_reader :registry
10
+
11
+ def initialize
12
+ @registry = []
13
+ end
14
+
15
+ # @param source [Middlewares] registry to duplicate
16
+ # @return [void]
17
+ def initialize_copy(source)
18
+ @registry = source.registry.dup
19
+ end
20
+
21
+ # Inserts a middleware. With no `:at`, appends. With `:at`, inserts at
22
+ # the given (clamped) index — supports negative indexing. `:if`/`:unless`
23
+ # gates evaluated against the task at process time.
24
+ #
25
+ # @param callable [#call, nil] provide either this or a block
26
+ # @param block [#call, nil] middleware callable when `callable` is omitted
27
+ # @param options [Hash{Symbol => Object}]
28
+ # @option options [Symbol, Proc, #call] :if gate that must evaluate truthy
29
+ # @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy
30
+ # @option options [Integer] :at insertion index (see implementation)
31
+ # @return [Middlewares] self for chaining
32
+ # @raise [ArgumentError] when both or neither of `callable`/block are given,
33
+ # when the callable doesn't respond to `#call`, or when `:at` isn't an Integer
34
+ # @yield the middleware body, receiving `(task)` and `next_link` via block
35
+ def register(callable = nil, **options, &block)
36
+ middleware = callable || block
37
+ at = options.delete(:at)
38
+
39
+ if callable && block
40
+ raise ArgumentError, "provide either a callable or a block, not both"
41
+ elsif !middleware.respond_to?(:call)
42
+ raise ArgumentError, "middleware must respond to #call"
43
+ elsif !at.nil? && !at.is_a?(Integer)
44
+ raise ArgumentError, "at must be an Integer"
45
+ end
46
+
47
+ entry = [middleware, options.freeze]
48
+
49
+ if at.nil?
50
+ registry << entry
51
+ else
52
+ at = [at.clamp(-registry.size - 1, registry.size), registry.size].min
53
+ registry.insert(at, entry)
54
+ end
55
+
56
+ self
57
+ end
58
+
59
+ # Removes a middleware by reference or by index.
60
+ #
61
+ # @param middleware [#call, nil] the exact middleware to remove
62
+ # @param at [Integer, nil] index to remove
63
+ # @return [Middlewares] self for chaining
64
+ # @raise [ArgumentError] when neither or both of `middleware`/`:at` are given,
65
+ # or when `:at` isn't an Integer
66
+ def deregister(middleware = nil, at: nil)
67
+ if at.nil? && middleware.nil?
68
+ raise ArgumentError, "provide either a middleware or an at: index"
69
+ elsif !at.nil? && !middleware.nil?
70
+ raise ArgumentError, "provide either a middleware or an at: index, not both"
71
+ elsif !at.nil? && !at.is_a?(Integer)
72
+ raise ArgumentError, "at must be an Integer"
73
+ end
74
+
75
+ if at.nil?
76
+ registry.reject! { |mw, _opts| mw == middleware }
77
+ else
78
+ registry.delete_at(at)
79
+ end
80
+
81
+ self
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def empty?
86
+ registry.empty?
87
+ end
88
+
89
+ # @return [Integer]
90
+ def size
91
+ registry.size
92
+ end
93
+
94
+ # Walks the middleware chain around `task`'s lifecycle. The final link
95
+ # yields to `block`, which is expected to run the actual lifecycle.
96
+ #
97
+ # @param task [Task]
98
+ # @yield the innermost link — the task's lifecycle body
99
+ # @return [void]
100
+ # @raise [MiddlewareError] when a middleware forgets to yield to `next_link`,
101
+ # which would otherwise silently skip the task
102
+ def process(task)
103
+ processed = false
104
+ count = registry.size
105
+
106
+ chain = lambda do |i|
107
+ if i == count
108
+ processed = true
109
+ yield
110
+ else
111
+ mw, opts = registry[i]
112
+
113
+ if Util.satisfied?(opts[:if], opts[:unless], task)
114
+ mw.call(task) { chain.call(i + 1) }
115
+ else
116
+ chain.call(i + 1)
117
+ end
118
+ end
119
+ end
120
+ chain.call(0)
121
+
122
+ processed || begin
123
+ raise MiddlewareError, "middleware did not yield the next_link"
124
+ end
125
+ end
126
+
127
+ end
128
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # A single declared output. Runtime calls {#verify} after `work` to enforce
5
+ # presence on `task.context[name]` (every declared output is implicitly
6
+ # required) and to apply `:default`. `:if`/`:unless` gate verification entirely.
7
+ class Output
8
+
9
+ attr_reader :name
10
+
11
+ # @param name [Symbol, String] output key (symbolized)
12
+ # @param options [Hash{Symbol => Object}] declaration options
13
+ # @option options [String] :description (also accepts `:desc`)
14
+ # @option options [Symbol, Proc, #call] :if
15
+ # @option options [Symbol, Proc, #call] :unless
16
+ # @option options [Object, Symbol, Proc, #call] :default
17
+ def initialize(name, **options)
18
+ @name = name.to_sym
19
+ @options = options.freeze
20
+ end
21
+
22
+ # @return [String, nil]
23
+ def description
24
+ @options[:description] || @options[:desc]
25
+ end
26
+
27
+ # @return [Object, Symbol, Proc, #call, nil]
28
+ def default
29
+ @options[:default]
30
+ end
31
+
32
+ # @return [Symbol, Proc, #call, nil]
33
+ def condition_if
34
+ @options[:if]
35
+ end
36
+
37
+ # @return [Symbol, Proc, #call, nil]
38
+ def condition_unless
39
+ @options[:unless]
40
+ end
41
+
42
+ # @return [Hash{Symbol => Object}] serialized schema for `outputs_schema`
43
+ def to_h
44
+ {
45
+ name:,
46
+ description:,
47
+ options: @options
48
+ }
49
+ end
50
+
51
+ # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
52
+ # callers (e.g. Rails).
53
+ #
54
+ # @return [Hash{Symbol => Object}]
55
+ def as_json(*)
56
+ to_h
57
+ end
58
+
59
+ # Serializes the output schema to a JSON string. Non-primitive entries in
60
+ # `:options` (Procs, arbitrary callables) emit via their stdlib `to_json`
61
+ # defaults.
62
+ #
63
+ # @param args [Array] forwarded to `Hash#to_json`
64
+ # @return [String]
65
+ def to_json(*args)
66
+ to_h.to_json(*args)
67
+ end
68
+
69
+ # Enforces the output contract against `task.context[name]` after `work` runs.
70
+ #
71
+ # Steps, in order:
72
+ # 1. Skips entirely when `:if`/`:unless` excludes it.
73
+ # 2. Reads the value from `task.context` and falls back to `:default` when nil.
74
+ # 3. Adds a `cmdx.outputs.missing` error when neither the key nor a default
75
+ # supplied a value (every declared output is implicitly required).
76
+ # 4. Writes the resolved value back to `task.context[name]`.
77
+ #
78
+ # @param task [Task] the running task whose context is inspected and mutated
79
+ # @return [void]
80
+ def verify(task)
81
+ return unless Util.satisfied?(condition_if, condition_unless, task)
82
+
83
+ key_provided = task.context.key?(name)
84
+ value = task.context[name]
85
+ value = apply_default(task) if value.nil?
86
+
87
+ if !key_provided && value.nil?
88
+ task.errors.add(name, I18nProxy.t("cmdx.outputs.missing"))
89
+ return
90
+ end
91
+
92
+ task.context[name] = value
93
+ end
94
+
95
+ private
96
+
97
+ # @param task [Task]
98
+ # @return [Object, nil]
99
+ def apply_default(task)
100
+ return if default.nil?
101
+
102
+ case default
103
+ when Symbol
104
+ task.send(default)
105
+ when Proc
106
+ task.instance_exec(&default)
107
+ else
108
+ return default unless default.respond_to?(:call)
109
+
110
+ default.call(task)
111
+ end
112
+ end
113
+
114
+ end
115
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Registry of declared task outputs. Runtime verifies each output after
5
+ # `work` completes: presence and defaults run against values the task wrote
6
+ # to context.
7
+ class Outputs
8
+
9
+ attr_reader :registry
10
+
11
+ def initialize
12
+ @registry = {}
13
+ end
14
+
15
+ # @param source [Outputs] registry to duplicate
16
+ # @return [void]
17
+ def initialize_copy(source)
18
+ @registry = source.registry.dup
19
+ end
20
+
21
+ # Declares one or more output keys. All share the same `options`.
22
+ #
23
+ # @param keys [Array<Symbol>]
24
+ # @param options [Hash{Symbol => Object}] passed through to {Output#initialize}
25
+ # @option options [String] :description (also accepts `:desc`)
26
+ # @option options [Symbol, Proc, #call] :if
27
+ # @option options [Symbol, Proc, #call] :unless
28
+ # @option options [Object, Symbol, Proc, #call] :default
29
+ # @return [Outputs] self for chaining
30
+ def register(*keys, **options)
31
+ keys.each do |key|
32
+ output = Output.new(key, **options)
33
+ registry[output.name] = output
34
+ end
35
+
36
+ self
37
+ end
38
+
39
+ # @param keys [Array<Symbol>]
40
+ # @return [Outputs] self for chaining
41
+ def deregister(*keys)
42
+ keys.each { |key| registry.delete(key.to_sym) }
43
+ self
44
+ end
45
+
46
+ # @return [Boolean]
47
+ def empty?
48
+ registry.empty?
49
+ end
50
+
51
+ # @return [Integer]
52
+ def size
53
+ registry.size
54
+ end
55
+
56
+ # Verifies every declared output against `task.context`. Adds any failures
57
+ # to `task.errors`.
58
+ #
59
+ # @param task [Task]
60
+ # @return [void]
61
+ def verify(task)
62
+ registry.each_value { |output| output.verify(task) }
63
+ end
64
+
65
+ end
66
+ end
data/lib/cmdx/pipeline.rb CHANGED
@@ -1,168 +1,181 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Executes workflows by processing task groups with conditional logic and breakpoint handling.
5
- # The Pipeline class manages the execution flow of workflow tasks, evaluating conditions
6
- # and handling breakpoints that can interrupt execution at specific task statuses.
4
+ # Runs a Workflow's declared task groups. Each group selects a strategy
5
+ # (`:sequential` by default, or `:parallel`). A group failure halts the
6
+ # pipeline by echoing the failed result's signal through `throw!`, which
7
+ # bubbles up through Runtime as the workflow's own failure.
8
+ #
9
+ # Groups may opt into batch semantics with `continue_on_failure: true`,
10
+ # in which case every task in the group runs to completion and all
11
+ # failures are aggregated into the workflow's `errors` (keyed as
12
+ # `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"`
13
+ # for bare `fail!` reasons) before the pipeline halts on the first
14
+ # failure (declaration order).
15
+ #
16
+ # @see Workflow
7
17
  class Pipeline
8
18
 
9
- # @rbs SEQUENTIAL_REGEXP: Regexp
10
- SEQUENTIAL_REGEXP = /\Asequential\z/
11
- private_constant :SEQUENTIAL_REGEXP
19
+ class << self
12
20
 
13
- # @rbs PARALLEL_REGEXP: Regexp
14
- PARALLEL_REGEXP = /\Aparallel\z/
15
- private_constant :PARALLEL_REGEXP
21
+ # @param workflow [Task] workflow instance whose class includes {Workflow}
22
+ # @return [void]
23
+ def execute(workflow)
24
+ new(workflow).execute
25
+ end
16
26
 
17
- # Returns the workflow being executed by this pipeline.
18
- #
19
- # @return [Workflow] The workflow instance
20
- #
21
- # @example
22
- # pipeline.workflow.context[:status] # => "processing"
23
- #
24
- # @rbs @workflow: Workflow
25
- attr_reader :workflow
27
+ end
26
28
 
27
- # @param workflow [Workflow] The workflow to execute
28
- #
29
- # @return [Pipeline] A new pipeline instance
30
- #
31
- # @example
32
- # pipeline = Pipeline.new(my_workflow)
33
- #
34
- # @rbs (Workflow workflow) -> void
29
+ # @param workflow [Task] workflow instance whose class includes {Workflow}
35
30
  def initialize(workflow)
36
31
  @workflow = workflow
32
+ @executed = []
37
33
  end
38
34
 
39
- # Executes a workflow using a new pipeline instance.
35
+ # Iterates every group in the workflow's pipeline, respecting
36
+ # `:if`/`:unless` and the `:strategy` key. Any group that produces a
37
+ # failed result halts execution by throwing through the workflow.
40
38
  #
41
- # @param workflow [Workflow] The workflow to execute
39
+ # On halt, every previously executed task instance whose result is
40
+ # `success?` is sent `#rollback` (when defined) in reverse execution
41
+ # order, providing saga-style compensation. Each compensated result
42
+ # has its `:rolled_back` option flipped to `true`. Skipped tasks are
43
+ # excluded; the failing task itself is rolled back by {Runtime} and
44
+ # is not re-invoked here. Exceptions raised inside a compensator
45
+ # propagate — handling them is the developer's responsibility.
42
46
  #
43
47
  # @return [void]
44
- #
45
- # @example
46
- # Pipeline.execute(my_workflow)
47
- #
48
- # @rbs (Workflow workflow) -> void
49
- def self.execute(workflow)
50
- new(workflow).execute
51
- end
52
-
53
- # Executes the workflow by processing all task groups in sequence.
54
- # Each group is evaluated against its conditions, and breakpoints are checked
55
- # after each task execution to determine if workflow should continue or halt.
56
- #
57
- # @return [void]
58
- #
59
- # @example
60
- # pipeline = Pipeline.new(my_workflow)
61
- # pipeline.execute
62
- #
63
- # @rbs () -> void
48
+ # @raise [ArgumentError] for an unknown strategy
49
+ # @raise [StandardError] anything raised by a task's `#rollback`
64
50
  def execute
65
- default_breakpoints = Utils::Normalize.statuses(
66
- workflow.class.settings.breakpoints ||
67
- workflow.class.settings.workflow_breakpoints
68
- )
69
-
70
- workflow.class.pipeline.each do |group|
71
- next unless Utils::Condition.evaluate(workflow, group.options)
72
-
73
- breakpoints =
74
- if group.options.key?(:breakpoints)
75
- Utils::Normalize.statuses(group.options[:breakpoints])
51
+ @workflow.class.pipeline.each do |group|
52
+ next unless Util.satisfied?(group.options[:if], group.options[:unless], @workflow)
53
+
54
+ halt =
55
+ case strategy = group.options[:strategy]
56
+ when :sequential, NilClass
57
+ run_sequential(group)
58
+ when :parallel
59
+ run_parallel(group)
76
60
  else
77
- default_breakpoints
61
+ raise ArgumentError, "invalid strategy: #{strategy.inspect}"
78
62
  end
79
63
 
80
- execute_group_tasks(group, breakpoints)
64
+ next unless halt
65
+
66
+ rollback_executed!
67
+ @workflow.send(:throw!, halt)
81
68
  end
82
69
  end
83
70
 
84
71
  private
85
72
 
86
- # Executes a group of tasks using the specified execution strategy.
87
- #
88
- # @param group [CMDx::Group] The task group to execute
89
- # @param breakpoints [Array<Symbol>] Status values that trigger execution breaks
90
- # @option group.options [Symbol, String] :strategy Execution strategy (:sequential, :parallel, or nil for default)
91
- #
92
- # @return [void]
93
- #
94
- # @example
95
- # execute_group_tasks(group, ["failed", "skipped"])
96
- #
97
- # @rbs (untyped group, Array[String] breakpoints) -> void
98
- def execute_group_tasks(group, breakpoints)
99
- case strategy = group.options[:strategy]
100
- when NilClass, SEQUENTIAL_REGEXP then execute_tasks_in_sequence(group, breakpoints)
101
- when PARALLEL_REGEXP then execute_tasks_in_parallel(group, breakpoints)
102
- else raise "unknown execution strategy #{strategy.inspect}"
73
+ # @param group [Workflow::ExecutionGroup]
74
+ # @return [Result, nil] failed result to halt on, or nil when the group succeeds
75
+ def run_sequential(group)
76
+ continue = group.options[:continue_on_failure]
77
+ failures = group.tasks.each_with_object([]) do |task_class, bucket|
78
+ instance = task_class.new(@workflow.context)
79
+ result = instance.execute(strict: false)
80
+ @executed << [instance, result]
81
+ next unless result.failed?
82
+
83
+ bucket << result
84
+ break bucket unless continue
103
85
  end
86
+
87
+ aggregate(failures, continue:)
104
88
  end
105
89
 
106
- # Executes tasks sequentially within a group, checking breakpoints after each task.
107
- # If a task result status matches a breakpoint, the workflow is interrupted.
108
- #
109
- # @param group [ExecutionGroup] The group of tasks to execute
110
- # @param breakpoints [Array<String>] Breakpoint statuses that trigger workflow interruption
111
- #
112
- # @return [void]
113
- #
114
- # @raise [HaltError] When a task result status matches a breakpoint
115
- #
116
- # @example
117
- # execute_tasks_in_sequence(group, ["failed", "skipped"])
118
- #
119
- # @rbs (untyped group, Array[String] breakpoints) -> void
120
- def execute_tasks_in_sequence(group, breakpoints)
121
- group.tasks.each do |task|
122
- task_result = task.execute(workflow.context)
123
- next unless breakpoints.include?(task_result.status)
90
+ # @param group [Workflow::ExecutionGroup]
91
+ # @return [Result, nil] failed result to halt on, or nil when the group succeeds
92
+ def run_parallel(group)
93
+ tasks = group.tasks
94
+ chain = Chain.current
95
+ size = group.options[:pool_size] || tasks.size
96
+ continue = group.options[:continue_on_failure]
97
+ entries = Array.new(tasks.size)
98
+ mutex = Mutex.new
99
+ seen_fail = false
100
+ cancelled = false
101
+
102
+ jobs = tasks.each_with_index.to_a
103
+
104
+ on_job = lambda do |(task_class, index)|
105
+ mutex.synchronize { return if cancelled }
106
+
107
+ Fiber[Chain::STORAGE_KEY] ||= chain
108
+ ctx_copy = @workflow.context.deep_dup
109
+ instance = task_class.new(ctx_copy)
110
+ result = instance.execute(strict: false)
111
+
112
+ mutex.synchronize do
113
+ entries[index] = [instance, result]
114
+
115
+ if result.failed? && !continue && !seen_fail
116
+ seen_fail = true
117
+ cancelled = true
118
+ end
119
+ end
120
+ end
121
+
122
+ executor = @workflow.class.executors.resolve(group.options[:executor])
123
+ merger = @workflow.class.mergers.resolve(group.options[:merger])
124
+
125
+ executor.call(jobs:, concurrency: size, on_job:)
124
126
 
125
- workflow.throw!(task_result)
127
+ failures = []
128
+ entries.each do |entry|
129
+ next if entry.nil?
130
+
131
+ @executed << entry
132
+ _instance, result = entry
133
+
134
+ if result.failed?
135
+ failures << result
136
+ else
137
+ merger.call(@workflow.context, result)
138
+ end
126
139
  end
140
+
141
+ aggregate(failures, continue:)
127
142
  end
128
143
 
129
- # Each task receives a snapshot of the workflow context to prevent
130
- # unsynchronized concurrent writes to a shared Hash. Snapshots are
131
- # merged back into the workflow context after all tasks complete.
132
- #
133
- # @param group [CMDx::Group] The task group to execute in parallel
134
- # @param breakpoints [Array<String>] Status values that trigger execution breaks
135
- # @option group.options [Integer] :pool_size Number of concurrent threads (defaults to task count)
136
- #
137
- # @return [void]
138
- #
139
- # @raise [Fault] When a task result status matches a breakpoint
140
- #
141
- # @example
142
- # execute_tasks_in_parallel(group, ["failed"])
143
- #
144
- # @rbs (untyped group, Array[String] breakpoints) -> void
145
- def execute_tasks_in_parallel(group, breakpoints)
146
- contexts = group.tasks.map { Context.new(workflow.context.to_h) }
147
- ctx_pairs = group.tasks.zip(contexts)
148
- pool_size = group.options.fetch(:pool_size, ctx_pairs.size)
149
-
150
- results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context|
151
- Chain.current = workflow.chain
152
- task.execute(context)
153
- end
144
+ def rollback_executed!
145
+ @executed.reverse_each do |instance, result|
146
+ next unless result.success?
147
+ next unless instance.respond_to?(:rollback)
148
+
149
+ instance.rollback
154
150
 
155
- contexts.each { |ctx| workflow.context.merge!(ctx) }
151
+ old_opts = result.instance_variable_get(:@options)
152
+ new_opts = old_opts.merge(rolled_back: true).freeze
153
+ result.instance_variable_set(:@options, new_opts)
154
+ end
155
+ end
156
156
 
157
- faulted = results.select { |r| breakpoints.include?(r.status) }
158
- return if faulted.empty?
157
+ # @param failures [Array<Result>]
158
+ # @param continue [Boolean] when true, merges failures into the workflow's errors
159
+ # @return [Result, nil] first failure (echoed upstream), or nil when `failures` is empty
160
+ def aggregate(failures, continue:)
161
+ return if failures.empty?
162
+ return failures.first unless continue
163
+
164
+ failures.each do |result|
165
+ prefix = result.task.name
166
+
167
+ if result.errors.empty?
168
+ message = I18nProxy.tr(result.reason)
169
+ @workflow.errors.add(:"#{prefix}.#{result.status}", message)
170
+ else
171
+ result.errors.each do |key, messages|
172
+ namespaced = :"#{prefix}.#{key}"
173
+ messages.each { |message| @workflow.errors.add(namespaced, message) }
174
+ end
175
+ end
176
+ end
159
177
 
160
- workflow.public_send(
161
- :"#{faulted.last.status}!",
162
- Locale.t("cmdx.reasons.unspecified"),
163
- source: :parallel,
164
- faults: faulted.map(&:to_h)
165
- )
178
+ failures.first
166
179
  end
167
180
 
168
181
  end
data/lib/cmdx/railtie.rb CHANGED
@@ -1,47 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Rails integration class that automatically configures CMDx when the Rails
5
- # application initializes. Handles locale configuration and I18n setup.
4
+ # Rails integration. Loaded only when `Rails::Railtie` is defined. Wires the
5
+ # app's `I18n.load_path` so CMDx locale files for each available locale are
6
+ # available, and points the CMDx logger and backtrace cleaner at Rails.
6
7
  class Railtie < Rails::Railtie
7
8
 
8
9
  railtie_name :cmdx
9
10
 
10
- # Configures CMDx locales during Rails application initialization.
11
- #
12
- # Iterates through available locales from the Rails configuration and loads
13
- # corresponding CMDx locale files. Reloads the I18n system to ensure
14
- # all locales are properly registered.
15
- #
16
- # @param app [Rails::Application] the Rails application instance
17
- #
18
- # @raise [LoadError] if locale files cannot be loaded
19
- #
20
- # @example
21
- # # This initializer runs automatically when Rails starts
22
- # # It will load locales like en.yml, es.yml, fr.yml if they exist
23
- # # in the CMDx gem's locales directory
24
- #
25
- # @rbs (untyped app) -> void
26
- initializer("cmdx.configure_locales") do |app|
27
- Utils::Wrap.array(app.config.i18n.available_locales).each do |locale|
28
- path = CMDx.gem_path.join("lib/locales/#{locale}.yml")
29
- next unless File.file?(path)
11
+ initializer("cmdx.configure_rails") do |app|
12
+ available_locales = app.config.i18n.available_locales.join(",")
13
+ locale_path = File.expand_path("../locales/{#{available_locales}}.yml", __dir__)
14
+ ::I18n.load_path += Dir[locale_path]
30
15
 
31
- ::I18n.load_path << path
32
- end
33
-
34
- ::I18n.reload!
35
- end
36
-
37
- # Configures the backtrace cleaner for CMDx in a Rails environment.
38
- #
39
- # Sets the backtrace cleaner to the Rails backtrace cleaner.
40
- #
41
- # @rbs () -> void
42
- initializer("cmdx.backtrace_cleaner") do
43
- CMDx.configuration.backtrace_cleaner = lambda do |backtrace|
44
- Rails.backtrace_cleaner.clean(backtrace)
16
+ CMDx.configure do |config|
17
+ config.logger = Rails.logger
18
+ config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) }
45
19
  end
46
20
  end
47
21