cmdx 1.20.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 +131 -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 -225
  23. data/lib/cmdx/context.rb +263 -242
  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 +252 -473
  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 -196
  64. data/lib/cmdx/signal.rb +165 -0
  65. data/lib/cmdx/task.rb +443 -336
  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 +74 -82
  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 +128 -52
  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 +9 -6
  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 -374
  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 -62
  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 -53
  112. data/lib/locales/ar.yml +0 -53
  113. data/lib/locales/az.yml +0 -53
  114. data/lib/locales/be.yml +0 -53
  115. data/lib/locales/bg.yml +0 -53
  116. data/lib/locales/bn.yml +0 -53
  117. data/lib/locales/bs.yml +0 -53
  118. data/lib/locales/ca.yml +0 -53
  119. data/lib/locales/cnr.yml +0 -53
  120. data/lib/locales/cs.yml +0 -53
  121. data/lib/locales/cy.yml +0 -53
  122. data/lib/locales/da.yml +0 -53
  123. data/lib/locales/de.yml +0 -53
  124. data/lib/locales/dz.yml +0 -53
  125. data/lib/locales/el.yml +0 -53
  126. data/lib/locales/eo.yml +0 -53
  127. data/lib/locales/es.yml +0 -53
  128. data/lib/locales/et.yml +0 -53
  129. data/lib/locales/eu.yml +0 -53
  130. data/lib/locales/fa.yml +0 -53
  131. data/lib/locales/fi.yml +0 -53
  132. data/lib/locales/fr.yml +0 -53
  133. data/lib/locales/fy.yml +0 -53
  134. data/lib/locales/gd.yml +0 -53
  135. data/lib/locales/gl.yml +0 -53
  136. data/lib/locales/he.yml +0 -53
  137. data/lib/locales/hi.yml +0 -53
  138. data/lib/locales/hr.yml +0 -53
  139. data/lib/locales/hu.yml +0 -53
  140. data/lib/locales/hy.yml +0 -53
  141. data/lib/locales/id.yml +0 -53
  142. data/lib/locales/is.yml +0 -53
  143. data/lib/locales/it.yml +0 -53
  144. data/lib/locales/ja.yml +0 -53
  145. data/lib/locales/ka.yml +0 -53
  146. data/lib/locales/kk.yml +0 -53
  147. data/lib/locales/km.yml +0 -53
  148. data/lib/locales/kn.yml +0 -53
  149. data/lib/locales/ko.yml +0 -53
  150. data/lib/locales/lb.yml +0 -53
  151. data/lib/locales/lo.yml +0 -53
  152. data/lib/locales/lt.yml +0 -53
  153. data/lib/locales/lv.yml +0 -53
  154. data/lib/locales/mg.yml +0 -53
  155. data/lib/locales/mk.yml +0 -53
  156. data/lib/locales/ml.yml +0 -53
  157. data/lib/locales/mn.yml +0 -53
  158. data/lib/locales/mr-IN.yml +0 -53
  159. data/lib/locales/ms.yml +0 -53
  160. data/lib/locales/nb.yml +0 -53
  161. data/lib/locales/ne.yml +0 -53
  162. data/lib/locales/nl.yml +0 -53
  163. data/lib/locales/nn.yml +0 -53
  164. data/lib/locales/oc.yml +0 -53
  165. data/lib/locales/or.yml +0 -53
  166. data/lib/locales/pa.yml +0 -53
  167. data/lib/locales/pl.yml +0 -53
  168. data/lib/locales/pt.yml +0 -53
  169. data/lib/locales/rm.yml +0 -53
  170. data/lib/locales/ro.yml +0 -53
  171. data/lib/locales/ru.yml +0 -53
  172. data/lib/locales/sc.yml +0 -53
  173. data/lib/locales/sk.yml +0 -53
  174. data/lib/locales/sl.yml +0 -53
  175. data/lib/locales/sq.yml +0 -53
  176. data/lib/locales/sr.yml +0 -53
  177. data/lib/locales/st.yml +0 -53
  178. data/lib/locales/sv.yml +0 -53
  179. data/lib/locales/sw.yml +0 -53
  180. data/lib/locales/ta.yml +0 -53
  181. data/lib/locales/te.yml +0 -53
  182. data/lib/locales/th.yml +0 -53
  183. data/lib/locales/tl.yml +0 -53
  184. data/lib/locales/tr.yml +0 -53
  185. data/lib/locales/tt.yml +0 -53
  186. data/lib/locales/ug.yml +0 -53
  187. data/lib/locales/uk.yml +0 -53
  188. data/lib/locales/ur.yml +0 -53
  189. data/lib/locales/uz.yml +0 -53
  190. data/lib/locales/vi.yml +0 -53
  191. data/lib/locales/wo.yml +0 -53
  192. data/lib/locales/zh-CN.yml +0 -53
  193. data/lib/locales/zh-HK.yml +0 -53
  194. data/lib/locales/zh-TW.yml +0 -53
  195. data/lib/locales/zh-YUE.yml +0 -53
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ class Deprecators
5
+ # Writes a `warn`-level entry to the task's logger noting the deprecation.
6
+ # Execution proceeds; useful for gradual migration where you want
7
+ # observability without breaking callers.
8
+ #
9
+ # @api private
10
+ module Log
11
+
12
+ extend self
13
+
14
+ # @param task [Task]
15
+ # @return [void]
16
+ def call(task)
17
+ task.logger.warn { "DEPRECATED: #{task.class} - migrate to a replacement or discontinue use" }
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ class Deprecators
5
+ # Emits a Ruby warning to stderr via `Kernel.warn`. Visible during
6
+ # development and testing without polluting structured production logs.
7
+ #
8
+ # @api private
9
+ module Warn
10
+
11
+ extend self
12
+
13
+ # @param task [Task]
14
+ # @return [void]
15
+ def call(task)
16
+ Kernel.warn("[#{task.class}] DEPRECATED: migrate to a replacement or discontinue use")
17
+ end
18
+
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Registry of named deprecation actions consulted by `Deprecation#execute`
5
+ # to dispatch a task class's deprecation. Ships with built-ins for
6
+ # `:log`, `:warn`, and `:error`. A deprecator is any callable accepting
7
+ # `call(task)`; the return value is discarded.
8
+ class Deprecators
9
+
10
+ attr_reader :registry
11
+
12
+ def initialize
13
+ @registry = {
14
+ log: Deprecators::Log,
15
+ warn: Deprecators::Warn,
16
+ error: Deprecators::Error
17
+ }
18
+ end
19
+
20
+ # @param source [Deprecators] registry to duplicate
21
+ # @return [void]
22
+ def initialize_copy(source)
23
+ @registry = source.registry.dup
24
+ end
25
+
26
+ # Registers a named deprecator, overwriting any existing entry.
27
+ #
28
+ # @param name [Symbol]
29
+ # @param callable [#call, nil] pass either this or a block
30
+ # @param block [#call, nil] deprecator callable when `callable` is omitted
31
+ # @yield deprecator body — `call(task)`
32
+ # @return [Deprecators] self for chaining
33
+ # @raise [ArgumentError] when both `callable` and a block are given, or
34
+ # when the resolved deprecator isn't callable
35
+ def register(name, callable = nil, &block)
36
+ deprecator = callable || block
37
+
38
+ if callable && block
39
+ raise ArgumentError, "provide either a callable or a block, not both"
40
+ elsif !deprecator.respond_to?(:call)
41
+ raise ArgumentError, "deprecator must respond to #call"
42
+ end
43
+
44
+ registry[name.to_sym] = deprecator
45
+ self
46
+ end
47
+
48
+ # @param name [Symbol]
49
+ # @return [Deprecators] self for chaining
50
+ def deregister(name)
51
+ registry.delete(name.to_sym)
52
+ self
53
+ end
54
+
55
+ # @param name [Symbol]
56
+ # @return [Boolean] whether a deprecator is registered under `name`
57
+ def key?(name)
58
+ registry.key?(name.to_sym)
59
+ end
60
+
61
+ # @param name [Symbol]
62
+ # @return [#call] the registered deprecator
63
+ # @raise [ArgumentError] when `name` isn't registered
64
+ def lookup(name)
65
+ registry[name] || begin
66
+ raise ArgumentError, "unknown deprecator: #{name.inspect}"
67
+ end
68
+ end
69
+
70
+ # Resolves a `deprecation` declaration's value to a concrete callable.
71
+ # Accepts a Symbol (registry lookup) or any object responding to `#call`.
72
+ # `nil` resolves to `nil` so callers can short-circuit.
73
+ #
74
+ # @param spec [Symbol, #call, nil]
75
+ # @return [#call, nil]
76
+ # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
77
+ def resolve(spec)
78
+ case spec
79
+ when NilClass
80
+ nil
81
+ when Symbol
82
+ lookup(spec)
83
+ else
84
+ return spec if spec.respond_to?(:call)
85
+
86
+ raise ArgumentError, "unknown deprecator: #{spec.inspect}"
87
+ end
88
+ end
89
+
90
+ # @return [Boolean]
91
+ def empty?
92
+ registry.empty?
93
+ end
94
+
95
+ # @return [Integer]
96
+ def size
97
+ registry.size
98
+ end
99
+
100
+ end
101
+ end
data/lib/cmdx/errors.rb CHANGED
@@ -1,117 +1,183 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Collection of validation and execution errors organized by attribute.
5
- # Provides methods to add, query, and format error messages for different
6
- # attributes in a task or workflow execution.
4
+ # Per-task container of validation / coercion / output errors. Each key maps
5
+ # to a deduplicating Set of messages. A non-empty Errors forces Runtime to
6
+ # throw a failed signal (`signal_errors!`). Frozen on teardown by Runtime.
7
7
  class Errors
8
8
 
9
- extend Forwardable
9
+ include Enumerable
10
10
 
11
- # Returns the internal hash of error messages by attribute.
12
- #
13
- # @return [Hash{Symbol => Set<String>}] Hash mapping attribute names to error message sets
14
- #
15
- # @example
16
- # errors.messages # => { email: #<Set: ["must be valid", "is required"]> }
17
- #
18
- # @rbs @messages: Hash[Symbol, Set[String]]
19
11
  attr_reader :messages
20
12
 
21
- def_delegators :messages, :any?, :clear, :empty?, :size
22
-
23
- # Initialize a new error collection.
24
- #
25
- # @rbs () -> void
26
13
  def initialize
27
14
  @messages = {}
28
15
  end
29
16
 
30
- # Add an error message for a specific attribute.
31
- #
32
- # @param attribute [Symbol] The attribute name associated with the error
33
- # @param message [String] The error message to add
34
- #
35
- # @example
36
- # errors = CMDx::Errors.new
37
- # errors.add(:email, "must be valid format")
38
- # errors.add(:email, "cannot be blank")
17
+ # Adds `message` under `key`. Duplicate messages are silently dropped.
39
18
  #
40
- # @rbs (Symbol attribute, String message) -> void
41
- def add(attribute, message)
42
- return if message.empty?
19
+ # @param key [Symbol]
20
+ # @param message [String]
21
+ # @return [Set<String>] the set of messages now stored under `key`
22
+ def add(key, message)
23
+ (messages[key] ||= Set.new) << message
24
+ end
25
+ alias []= add
43
26
 
44
- messages[attribute] ||= Set.new
45
- messages[attribute] << message
27
+ # Copies every message from `other` into self. Existing messages are
28
+ # preserved and duplicates (same key + message) are silently dropped by
29
+ # the underlying Set. Accepts any object that responds to `#to_hash`
30
+ # returning `Hash{Symbol => Enumerable<String>}` — typically another
31
+ # {Errors} instance.
32
+ #
33
+ # @param other [Errors, #to_hash]
34
+ # @return [void]
35
+ # @example Combine validation errors from a nested task
36
+ # parent.errors.merge!(child.result.errors)
37
+ def merge!(other)
38
+ other.to_hash.each do |key, messages|
39
+ messages.each { |message| add(key, message) }
40
+ end
46
41
  end
47
42
 
48
- # Check if there are any errors for a specific attribute.
49
- #
50
- # @param attribute [Symbol] The attribute name to check for errors
51
- #
52
- # @return [Boolean] true if the attribute has errors, false otherwise
53
- #
54
- # @example
55
- # errors.for?(:email) # => true
56
- # errors.for?(:name) # => false
57
- #
58
- # @rbs (Symbol attribute) -> bool
59
- def for?(attribute)
60
- set = messages[attribute]
61
- !set.nil? && !set.empty?
43
+ # @param key [Symbol]
44
+ # @return [Array<String>] messages for `key`, or a frozen empty array
45
+ def [](key)
46
+ messages[key]&.to_a || EMPTY_ARRAY
62
47
  end
63
48
 
64
- # Convert errors to a hash format with arrays of full messages.
65
- #
66
- # @return [Hash{Symbol => Array<String>}] Hash with attribute keys and message arrays
67
- #
68
- # @example
69
- # errors.full_messages # => { email: ["email must be valid format", "email cannot be blank"] }
70
- #
71
- # @rbs () -> Hash[Symbol, Array[String]]
49
+ # @param key [Symbol]
50
+ # @param message [String]
51
+ # @return [Boolean] true when `message` is recorded under `key`
52
+ def added?(key, message)
53
+ !!messages[key]&.include?(message)
54
+ end
55
+
56
+ # @param key [Symbol]
57
+ # @return [Boolean]
58
+ def key?(key)
59
+ messages.key?(key)
60
+ end
61
+ alias for? key?
62
+
63
+ # @return [Array<Symbol>] keys with at least one message
64
+ def keys
65
+ messages.keys
66
+ end
67
+
68
+ # @return [Boolean]
69
+ def empty?
70
+ messages.empty?
71
+ end
72
+
73
+ # @return [Integer] number of keyed entries
74
+ def size
75
+ messages.size
76
+ end
77
+
78
+ # @return [Integer] total messages across all keys
79
+ def count
80
+ messages.each_value.sum(&:size)
81
+ end
82
+
83
+ # @yield [key, set] each `[key, Set<String>]` pair
84
+ # @return [Errors, Enumerator]
85
+ def each(&)
86
+ messages.each(&)
87
+ end
88
+
89
+ # @yield [Symbol]
90
+ # @return [Errors, Enumerator]
91
+ def each_key(&)
92
+ messages.each_key(&)
93
+ end
94
+
95
+ # @yield [Set<String>]
96
+ # @return [Errors, Enumerator]
97
+ def each_value(&)
98
+ messages.each_value(&)
99
+ end
100
+
101
+ # @param key [Symbol]
102
+ # @return [Set<String>, nil] the removed set, or nil when absent
103
+ def delete(key)
104
+ messages.delete(key)
105
+ end
106
+
107
+ # @return [Hash{Symbol => Set<String>}] empties the container
108
+ def clear
109
+ messages.clear
110
+ end
111
+
112
+ # @return [Hash{Symbol => Array<String>}] messages prefixed with their key
113
+ # (e.g. `{ name: ["name is required"] }`)
72
114
  def full_messages
73
- messages.each_with_object({}) do |(attribute, messages), hash|
74
- hash[attribute] = messages.map { |message| "#{attribute} #{message}" }
115
+ messages.each_with_object({}) do |(key, set), hash|
116
+ hash[key] = set.map { |message| "#{key} #{message}" }
75
117
  end
76
118
  end
77
119
 
78
- # Convert errors to a hash format with arrays of messages.
79
- #
80
- # @return [Hash{Symbol => Array<String>}] Hash with attribute keys and message arrays
81
- #
82
- # @example
83
- # errors.to_h # => { email: ["must be valid format", "cannot be blank"] }
84
- #
85
- # @rbs () -> Hash[Symbol, Array[String]]
120
+ # @return [Hash{Symbol => Array<String>}] raw messages as arrays
86
121
  def to_h
87
122
  messages.transform_values(&:to_a)
88
123
  end
89
124
 
90
- # Convert errors to a hash format with optional full messages.
91
- #
92
- # @param full [Boolean] Whether to include full messages with attribute names
93
- # @return [Hash{Symbol => Array<String>}] Hash with attribute keys and message arrays
94
- #
95
- # @example
96
- # errors.to_hash # => { email: ["must be valid format", "cannot be blank"] }
97
- # errors.to_hash(true) # => { email: ["email must be valid format", "email cannot be blank"] }
98
- #
99
- # @rbs (?bool full) -> Hash[Symbol, Array[String]]
125
+ # @param full [Boolean] when true return {#full_messages}, otherwise {#to_h}
126
+ # @return [Hash{Symbol => Array<String>}]
100
127
  def to_hash(full = false)
101
128
  full ? full_messages : to_h
102
129
  end
103
130
 
104
- # Convert errors to a human-readable string format.
105
- #
106
- # @return [String] Formatted error messages joined with periods
131
+ # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json`
132
+ # callers (e.g. Rails).
107
133
  #
108
- # @example
109
- # errors.to_s # => "email must be valid format. email cannot be blank"
134
+ # @return [Hash{Symbol => Array<String>}]
135
+ def as_json(*)
136
+ to_h
137
+ end
138
+
139
+ # Serializes the error messages to a JSON string. Symbol keys are
140
+ # emitted as strings by the `json` stdlib.
110
141
  #
111
- # @rbs () -> String
142
+ # @param args [Array] forwarded to `Hash#to_json`
143
+ # @return [String]
144
+ def to_json(*args)
145
+ to_h.to_json(*args)
146
+ end
147
+
148
+ # @return [String] all full messages joined with `". "`, suitable as a
149
+ # fail reason
112
150
  def to_s
113
151
  full_messages.values.flatten.join(". ")
114
152
  end
115
153
 
154
+ # Pattern-matching support for `case errors in {...}`.
155
+ #
156
+ # @param keys [Array<Symbol>, nil] restrict the returned hash to these keys
157
+ # @return [Hash{Symbol => Array<String>}]
158
+ #
159
+ # @example
160
+ # case task.errors
161
+ # in { name: [_, *] } then handle_name_errors(task)
162
+ # end
163
+ def deconstruct_keys(keys)
164
+ keys.nil? ? to_h : to_h.slice(*keys)
165
+ end
166
+
167
+ # Pattern-matching support for `case errors in [...]`.
168
+ #
169
+ # @return [Array<Array(Symbol, Array<String>)>]
170
+ def deconstruct
171
+ to_h.to_a
172
+ end
173
+
174
+ # Freezes the container and every message set. Called by Runtime teardown.
175
+ #
176
+ # @return [Errors] self
177
+ def freeze
178
+ messages.each_value(&:freeze).freeze
179
+ super
180
+ end
181
+
116
182
  end
117
183
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ class Executors
5
+ # Fiber-scheduler backed executor. Spawns one fiber per job, bounded by
6
+ # `concurrency` via a `SizedQueue` semaphore. Requires a Fiber scheduler to
7
+ # be installed on the current thread (e.g. inside `Async { ... }` from the
8
+ # `async` gem). `pool_size` caps in-flight fibers.
9
+ #
10
+ # @api private
11
+ module Fiber
12
+
13
+ extend self
14
+
15
+ # @param jobs [Array]
16
+ # @param concurrency [Integer] max in-flight fibers
17
+ # @param on_job [#call]
18
+ # @return [void]
19
+ # @raise [RuntimeError] when no `Fiber.scheduler` is installed
20
+ def call(jobs:, concurrency:, on_job:)
21
+ raise "executor: :fibers requires Fiber.scheduler; run the workflow inside a scheduler block (e.g. Async { ... })" unless ::Fiber.scheduler
22
+
23
+ slots = SizedQueue.new(concurrency)
24
+ concurrency.times { slots << :slot }
25
+ done = Queue.new
26
+
27
+ jobs.each do |job|
28
+ slots.pop
29
+ ::Fiber.schedule do
30
+ on_job.call(job)
31
+ ensure
32
+ slots << :slot
33
+ done << true
34
+ end
35
+ end
36
+
37
+ jobs.size.times { done.pop }
38
+ end
39
+
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ class Executors
5
+ # Default executor. Uses a fixed-size `Thread` pool drained via a `Queue`;
6
+ # sentinel `nil`s terminate workers. Workers inherit the parent's chain via
7
+ # fiber-local storage.
8
+ #
9
+ # @api private
10
+ module Thread
11
+
12
+ extend self
13
+
14
+ # @param jobs [Array] opaque job objects forwarded to `on_job`
15
+ # @param concurrency [Integer] worker count
16
+ # @param on_job [#call] unary callable invoked per job
17
+ # @return [void]
18
+ def call(jobs:, concurrency:, on_job:)
19
+ queue = Queue.new
20
+ jobs.each { |job| queue << job }
21
+ concurrency.times { queue << nil }
22
+
23
+ workers = Array.new(concurrency) do
24
+ ::Thread.new do
25
+ while (job = queue.pop)
26
+ on_job.call(job)
27
+ end
28
+ end
29
+ end
30
+
31
+ workers.each(&:join)
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Registry of named executors used by `:parallel` workflow groups to
5
+ # dispatch tasks concurrently. Ships with built-ins for `:threads` and
6
+ # `:fibers`. Executors are any callable accepting
7
+ # `call(jobs:, concurrency:, on_job:)` and must invoke `on_job.call(job)`
8
+ # for each job, blocking until every job is done.
9
+ class Executors
10
+
11
+ attr_reader :registry
12
+
13
+ def initialize
14
+ @registry = {
15
+ threads: Executors::Thread,
16
+ fibers: Executors::Fiber
17
+ }
18
+ end
19
+
20
+ # @param source [Executors] registry to duplicate
21
+ # @return [void]
22
+ def initialize_copy(source)
23
+ @registry = source.registry.dup
24
+ end
25
+
26
+ # Registers a named executor, overwriting any existing entry.
27
+ #
28
+ # @param name [Symbol]
29
+ # @param callable [#call, nil] pass either this or a block
30
+ # @param block [#call, nil] executor callable when `callable` is omitted
31
+ # @yield executor body — `call(jobs:, concurrency:, on_job:)`
32
+ # @return [Executors] self for chaining
33
+ # @raise [ArgumentError] when both `callable` and a block are given, or
34
+ # when the resolved executor isn't callable
35
+ def register(name, callable = nil, &block)
36
+ executor = callable || block
37
+
38
+ if callable && block
39
+ raise ArgumentError, "provide either a callable or a block, not both"
40
+ elsif !executor.respond_to?(:call)
41
+ raise ArgumentError, "executor must respond to #call"
42
+ end
43
+
44
+ registry[name.to_sym] = executor
45
+ self
46
+ end
47
+
48
+ # @param name [Symbol]
49
+ # @return [Executors] self for chaining
50
+ def deregister(name)
51
+ registry.delete(name.to_sym)
52
+ self
53
+ end
54
+
55
+ # @param name [Symbol]
56
+ # @return [#call] the registered executor
57
+ # @raise [ArgumentError] when `name` isn't registered
58
+ def lookup(name)
59
+ registry[name] || begin
60
+ raise ArgumentError, "unknown executor: #{name.inspect}"
61
+ end
62
+ end
63
+
64
+ # Resolves a declaration's `:executor` option to a concrete callable.
65
+ # Accepts `nil` (default `:threads`), a Symbol (registry lookup), or any
66
+ # object responding to `#call`.
67
+ #
68
+ # @param spec [Symbol, #call, nil]
69
+ # @return [#call]
70
+ # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
71
+ def resolve(spec)
72
+ case spec
73
+ when NilClass
74
+ lookup(:threads)
75
+ when Symbol
76
+ lookup(spec)
77
+ else
78
+ return spec if spec.respond_to?(:call)
79
+
80
+ raise ArgumentError, "unknown executor: #{spec.inspect}"
81
+ end
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
+ end
95
+ end