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
data/lib/cmdx/task.rb CHANGED
@@ -1,418 +1,518 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Represents a task that can be executed within the CMDx framework.
5
- # Tasks define attributes, callbacks, and execution logic that can be
6
- # chained together to form workflows.
4
+ # Base class for all units of work. Subclasses override `#work` and
5
+ # declare their contract via `required`, `optional`, `output`, `callbacks`,
6
+ # `retry_on`, `deprecation`, and `settings`. Invoked via {.execute} (safe)
7
+ # or {.execute!} (strict, raises on failure).
8
+ #
9
+ # Inheritance: every registry accessor (middlewares, callbacks, coercions,
10
+ # validators, executors, mergers, telemetry, inputs, outputs) lazily clones from the
11
+ # superclass's registry (or the global configuration at the top of the
12
+ # hierarchy), so subclasses extend rather than replace.
13
+ #
14
+ # @see Runtime
15
+ # @see Workflow
7
16
  class Task
8
17
 
9
- extend Forwardable
18
+ class << self
10
19
 
11
- # Returns the hash of processed attribute values for this task.
12
- #
13
- # @return [Hash{Symbol => Object}] Hash of attribute names to their values
14
- #
15
- # @example
16
- # task.attributes # => { user_id: 42, user_name: "John" }
17
- #
18
- # @rbs @attributes: Hash[Symbol, untyped]
19
- attr_reader :attributes
20
+ # Declares exceptions to retry on. Builds on the superclass's `Retry`.
21
+ # Passing no exceptions returns the current (possibly inherited) Retry.
22
+ #
23
+ # @param exceptions [Array<Class>]
24
+ # @param options [Hash{Symbol => Object}] see {Retry#initialize}
25
+ # @option options [Integer] :limit (see {Retry#initialize})
26
+ # @option options [Float] :delay (see {Retry#initialize})
27
+ # @option options [Float] :max_delay (see {Retry#initialize})
28
+ # @option options [Symbol, Proc, #call] :jitter (see {Retry#initialize})
29
+ # @option options [Symbol, Proc, #call] :if gate `(task, error, attempt)` for retries
30
+ # @option options [Symbol, Proc, #call] :unless gate `(task, error, attempt)` for retries
31
+ # @yield [attempt, delay] optional custom jitter block
32
+ # @return [Retry]
33
+ def retry_on(*exceptions, **options, &)
34
+ @retry_on ||=
35
+ if superclass.respond_to?(:retry_on)
36
+ superclass.retry_on.build(exceptions, options, &)
37
+ else
38
+ Retry.new(exceptions, options, &)
39
+ end
40
+
41
+ return @retry_on if exceptions.empty?
42
+
43
+ @retry_on = @retry_on.build(exceptions, options, &)
44
+ end
20
45
 
21
- # Returns the collection of validation and execution errors.
22
- #
23
- # @return [Errors] The errors collection
24
- #
25
- # @example
26
- # task.errors.to_h # => { email: ["must be valid"] }
27
- #
28
- # @rbs @errors: Errors
29
- attr_reader :errors
46
+ # Reads or extends this class's {Settings}. Inherits from the superclass.
47
+ #
48
+ # @param options [Hash{Symbol => Object}] merged onto the current settings
49
+ # @option options [Logger] :logger (see {Settings#initialize})
50
+ # @option options [#call] :log_formatter (see {Settings#initialize})
51
+ # @option options [Integer] :log_level (see {Settings#initialize})
52
+ # @option options [#call] :backtrace_cleaner (see {Settings#initialize})
53
+ # @option options [Array<Symbol>] :log_exclusions (see {Settings#initialize})
54
+ # @option options [Array<Symbol, String>] :tags (see {Settings#initialize})
55
+ # @option options [Boolean] :strict_context (see {Settings#initialize})
56
+ # @return [Settings]
57
+ def settings(options = EMPTY_HASH)
58
+ @settings ||=
59
+ if superclass.respond_to?(:settings)
60
+ superclass.settings.build(options)
61
+ else
62
+ Settings.new(options)
63
+ end
64
+
65
+ return @settings if options.empty?
66
+
67
+ @settings = @settings.build(options)
68
+ end
30
69
 
31
- # Returns the unique identifier for this task instance.
32
- #
33
- # @return [String] The task identifier
34
- #
35
- # @example
36
- # task.id # => "abc123xyz"
37
- #
38
- # @rbs @id: String
39
- attr_reader :id
70
+ # @return [Middlewares] cloned from superclass/configuration on first call
71
+ def middlewares
72
+ @middlewares ||=
73
+ if superclass.respond_to?(:middlewares)
74
+ superclass.middlewares.dup
75
+ else
76
+ CMDx.configuration.middlewares.dup
77
+ end
78
+ end
40
79
 
41
- # Returns the execution context for this task.
42
- #
43
- # @return [Context] The context instance
44
- #
45
- # @example
46
- # task.context[:user_id] # => 42
47
- #
48
- # @rbs @context: Context
49
- attr_reader :context
50
- alias ctx context
80
+ # @return [Callbacks] cloned from superclass/configuration on first call
81
+ def callbacks
82
+ @callbacks ||=
83
+ if superclass.respond_to?(:callbacks)
84
+ superclass.callbacks.dup
85
+ else
86
+ CMDx.configuration.callbacks.dup
87
+ end
88
+ end
51
89
 
52
- # Returns the execution result for this task.
53
- #
54
- # @return [Result] The result instance
55
- #
56
- # @example
57
- # task.result.status # => "success"
58
- #
59
- # @rbs @result: Result
60
- attr_reader :result
61
- alias res result
90
+ Callbacks::EVENTS.each do |event|
91
+ define_method(event) do |callable = nil, **options, &block|
92
+ register(:callback, event, callable, **options, &block)
93
+ end
94
+ end
62
95
 
63
- # Returns the execution chain containing all task results.
64
- #
65
- # @return [Chain] The chain instance
66
- #
67
- # @example
68
- # task.chain.results.size # => 3
69
- #
70
- # @rbs @chain: Chain
71
- attr_reader :chain
96
+ # @return [Telemetry] cloned from superclass/configuration on first call
97
+ def telemetry
98
+ @telemetry ||=
99
+ if superclass.respond_to?(:telemetry)
100
+ superclass.telemetry.dup
101
+ else
102
+ CMDx.configuration.telemetry.dup
103
+ end
104
+ end
72
105
 
73
- def_delegators :result, :success!, :skip!, :fail!, :throw!
74
- def_delegators :chain, :dry_run?
106
+ # @return [Coercions] cloned from superclass/configuration on first call
107
+ def coercions
108
+ @coercions ||=
109
+ if superclass.respond_to?(:coercions)
110
+ superclass.coercions.dup
111
+ else
112
+ CMDx.configuration.coercions.dup
113
+ end
114
+ end
75
115
 
76
- # @param context [Hash, Context, nil] The initial context for the task
77
- #
78
- # @return [Task] A new task instance
79
- #
80
- # @raise [DeprecationError] If the task class is deprecated
81
- #
82
- # @example
83
- # task = MyTask.new
84
- # task = MyTask.new(name: "example", priority: :high)
85
- # task = MyTask.new(Context.build(name: "example"))
86
- #
87
- # @rbs (untyped context) -> void
88
- def initialize(context = nil)
89
- Deprecator.restrict(self)
116
+ # @return [Validators] cloned from superclass/configuration on first call
117
+ def validators
118
+ @validators ||=
119
+ if superclass.respond_to?(:validators)
120
+ superclass.validators.dup
121
+ else
122
+ CMDx.configuration.validators.dup
123
+ end
124
+ end
90
125
 
91
- @id = Identifier.generate
92
- @context = Context.build(context)
93
- @errors = Errors.new
94
- @result = Result.new(self)
95
- @chain = Chain.build(@result, dry_run: @context.delete(:dry_run))
126
+ # @return [Executors] cloned from superclass/configuration on first call
127
+ def executors
128
+ @executors ||=
129
+ if superclass.respond_to?(:executors)
130
+ superclass.executors.dup
131
+ else
132
+ CMDx.configuration.executors.dup
133
+ end
134
+ end
96
135
 
97
- @attributes = {}
98
- end
136
+ # @return [Mergers] cloned from superclass/configuration on first call
137
+ def mergers
138
+ @mergers ||=
139
+ if superclass.respond_to?(:mergers)
140
+ superclass.mergers.dup
141
+ else
142
+ CMDx.configuration.mergers.dup
143
+ end
144
+ end
99
145
 
100
- class << self
146
+ # @return [Retriers] cloned from superclass/configuration on first call
147
+ def retriers
148
+ @retriers ||=
149
+ if superclass.respond_to?(:retriers)
150
+ superclass.retriers.dup
151
+ else
152
+ CMDx.configuration.retriers.dup
153
+ end
154
+ end
101
155
 
102
- # Returns the cached task type string for this class.
103
- #
104
- # @return [String] "Workflow" or "Task"
105
- #
106
- # @rbs () -> String
107
- def type
108
- @type ||= include?(Workflow) ? "Workflow" : "Task"
156
+ # @return [Deprecators] cloned from superclass/configuration on first call
157
+ def deprecators
158
+ @deprecators ||=
159
+ if superclass.respond_to?(:deprecators)
160
+ superclass.deprecators.dup
161
+ else
162
+ CMDx.configuration.deprecators.dup
163
+ end
109
164
  end
110
165
 
111
- # Returns (and lazily creates) the task-level Settings object.
112
- # On first access, inherits from the superclass settings or
113
- # the global Configuration. Optional overrides are applied once.
114
- #
115
- # @param overrides [Hash] Configuration overrides applied on first access
116
- # @option overrides [Object] :* Any configuration override key-value pairs
117
- #
118
- # @return [Settings] The settings instance for this task class
166
+ # Dispatches to the appropriate registry's `register` method.
119
167
  #
120
- # @example
121
- # class MyTask < Task
122
- # settings deprecate: true, tags: [:experimental]
123
- # end
124
- #
125
- # @rbs (**untyped overrides) -> Settings
126
- def settings(**overrides)
127
- @settings ||= begin
128
- parent = superclass.settings if superclass.respond_to?(:settings)
129
- Settings.new(parent:, **overrides)
168
+ # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output]
169
+ # @return [Object] the registry's self
170
+ # @raise [ArgumentError] when `type` is unknown
171
+ def register(type, ...)
172
+ case type
173
+ when :middleware
174
+ middlewares.register(...)
175
+ when :callback
176
+ callbacks.register(...)
177
+ when :coercion
178
+ coercions.register(...)
179
+ when :validator
180
+ validators.register(...)
181
+ when :executor
182
+ executors.register(...)
183
+ when :merger
184
+ mergers.register(...)
185
+ when :retrier
186
+ retriers.register(...)
187
+ when :deprecator
188
+ deprecators.register(...)
189
+ when :input
190
+ inputs.register(self, ...)
191
+ when :output
192
+ outputs.register(...)
193
+ else raise ArgumentError, "unknown registry type: #{type.inspect}"
130
194
  end
131
195
  end
132
196
 
133
- # @param type [Symbol] The type of registry to register with
134
- # @param object [Object] The object to register
135
- #
136
- # @raise [RuntimeError] If the registry type is unknown
197
+ # Dispatches to the appropriate registry's `deregister` method.
137
198
  #
138
- # @example
139
- # register(:attribute, MyAttribute.new)
140
- # register(:callback, :before, -> { puts "before" })
141
- #
142
- # @rbs (Symbol type, untyped object, *untyped) -> void
143
- def register(type, object, ...)
199
+ # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output]
200
+ # @return [Object] the registry's self
201
+ # @raise [ArgumentError] when `type` is unknown
202
+ def deregister(type, ...)
144
203
  case type
145
- when :attribute
146
- settings.attributes.register(object)
147
- settings.attributes.define_readers_on!(self, Utils::Wrap.array(object))
148
- when :callback then settings.callbacks.register(object, ...)
149
- when :middleware then settings.middlewares.register(object, ...)
150
- when :validator then settings.validators.register(object, ...)
151
- when :coercion then settings.coercions.register(object, ...)
152
- else raise "unknown registry type #{type.inspect}"
204
+ when :middleware
205
+ middlewares.deregister(...)
206
+ when :callback
207
+ callbacks.deregister(...)
208
+ when :coercion
209
+ coercions.deregister(...)
210
+ when :validator
211
+ validators.deregister(...)
212
+ when :executor
213
+ executors.deregister(...)
214
+ when :merger
215
+ mergers.deregister(...)
216
+ when :retrier
217
+ retriers.deregister(...)
218
+ when :deprecator
219
+ deprecators.deregister(...)
220
+ when :input
221
+ inputs.deregister(self, ...)
222
+ when :output
223
+ outputs.deregister(...)
224
+ else raise ArgumentError, "unknown registry type: #{type.inspect}"
153
225
  end
154
226
  end
155
227
 
156
- # @param type [Symbol] The type of registry to deregister from
157
- # @param object [Object] The object to deregister
158
- #
159
- # @raise [RuntimeError] If the registry type is unknown
160
- #
161
- # @example
162
- # deregister(:attribute, :name)
163
- # deregister(:callback, :before, MyCallback)
164
- #
165
- # @rbs (Symbol type, untyped object, *untyped) -> void
166
- def deregister(type, object, ...)
167
- case type
168
- when :attribute
169
- settings.attributes.undefine_readers_on!(self, object)
170
- settings.attributes.deregister(object)
171
- when :callback then settings.callbacks.deregister(object, ...)
172
- when :middleware then settings.middlewares.deregister(object, ...)
173
- when :validator then settings.validators.deregister(object, ...)
174
- when :coercion then settings.coercions.deregister(object, ...)
175
- else raise "unknown registry type #{type.inspect}"
228
+ # Reads, sets, or inherits the task class's {Deprecation}. With a
229
+ # `value` or block, replaces any current deprecation. Otherwise returns
230
+ # the locally defined one, or the superclass's.
231
+ #
232
+ # @param value [:log, :warn, :error, Symbol, Proc, #call, nil]
233
+ # @param block [#call, nil] optional block used as the deprecation callable
234
+ # @param options [Hash{Symbol => Object}] `:if`/`:unless` conditions (see {Deprecation#initialize})
235
+ # @option options [Symbol, Proc, #call] :if (see {Deprecation#initialize})
236
+ # @option options [Symbol, Proc, #call] :unless (see {Deprecation#initialize})
237
+ # @return [Deprecation, nil]
238
+ # @yield optional block used as the deprecation callable
239
+ def deprecation(value = nil, **options, &block)
240
+ if value || block
241
+ @deprecation = Deprecation.new(value || block, options)
242
+ elsif defined?(@deprecation)
243
+ @deprecation
244
+ elsif superclass.respond_to?(:deprecation)
245
+ superclass.deprecation
176
246
  end
177
247
  end
178
248
 
179
- # @example
180
- # attributes :name, :email
181
- # attributes :age, type: Integer, default: 18
182
- #
183
- # @rbs (*untyped) -> void
184
- def attributes(...)
185
- register(:attribute, Attribute.build(...))
249
+ # Reads, or declares more, inputs. With no names, returns the registry;
250
+ # with names, registers them and defines accessors.
251
+ #
252
+ # @param names [Array<Symbol>]
253
+ # @param options [Hash{Symbol => Object}] see {Input#initialize}
254
+ # @option options [String] :description (also accepts `:desc`)
255
+ # @option options [Symbol] :as overrides the accessor name
256
+ # @option options [Boolean, String] :prefix prefix for the accessor name
257
+ # @option options [Boolean, String] :suffix suffix for the accessor name
258
+ # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from
259
+ # @option options [Object, Symbol, Proc, #call] :default
260
+ # @option options [Symbol, Proc, #call] :transform mutator applied after coercion
261
+ # @option options [Symbol, Proc, #call] :if
262
+ # @option options [Symbol, Proc, #call] :unless
263
+ # @option options [Boolean] :required
264
+ # @option options [Object] :coerce (see {Coercions#extract})
265
+ # @option options [Object] :validate (see {Validators#extract})
266
+ # @yield nested-input DSL block (see {Inputs::ChildBuilder})
267
+ # @return [Inputs]
268
+ def inputs(*names, **options, &)
269
+ @inputs ||=
270
+ if superclass.respond_to?(:inputs)
271
+ superclass.inputs.dup
272
+ else
273
+ Inputs.new
274
+ end
275
+
276
+ return @inputs if names.empty?
277
+
278
+ @inputs.register(self, *names, **options, &)
279
+ end
280
+ alias input inputs
281
+
282
+ # Declares optional inputs (shorthand for `inputs ..., required: false`).
283
+ #
284
+ # @param names [Array<Symbol>]
285
+ # @param options [Hash{Symbol => Object}] see {Input#initialize}
286
+ # @option options [String] :description (also accepts `:desc`)
287
+ # @option options [Symbol] :as overrides the accessor name
288
+ # @option options [Boolean, String] :prefix prefix for the accessor name
289
+ # @option options [Boolean, String] :suffix suffix for the accessor name
290
+ # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from
291
+ # @option options [Object, Symbol, Proc, #call] :default
292
+ # @option options [Symbol, Proc, #call] :transform mutator applied after coercion
293
+ # @option options [Symbol, Proc, #call] :if
294
+ # @option options [Symbol, Proc, #call] :unless
295
+ # @option options [Object] :coerce (see {Coercions#extract})
296
+ # @option options [Object] :validate (see {Validators#extract})
297
+ # @yield nested-input DSL block (see {Inputs::ChildBuilder})
298
+ def optional(*names, **options, &)
299
+ register(:input, *names, required: false, **options, &)
186
300
  end
187
- alias attribute attributes
188
301
 
189
- # @example
190
- # optional :description, :notes
191
- # optional :priority, type: Symbol, default: :normal
192
- #
193
- # @rbs (*untyped) -> void
194
- def optional(...)
195
- register(:attribute, Attribute.optional(...))
302
+ # Declares required inputs (shorthand for `inputs ..., required: true`).
303
+ #
304
+ # @param names [Array<Symbol>]
305
+ # @param options [Hash{Symbol => Object}] see {Input#initialize}
306
+ # @option options [String] :description (also accepts `:desc`)
307
+ # @option options [Symbol] :as overrides the accessor name
308
+ # @option options [Boolean, String] :prefix prefix for the accessor name
309
+ # @option options [Boolean, String] :suffix suffix for the accessor name
310
+ # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from
311
+ # @option options [Object, Symbol, Proc, #call] :default
312
+ # @option options [Symbol, Proc, #call] :transform mutator applied after coercion
313
+ # @option options [Symbol, Proc, #call] :if
314
+ # @option options [Symbol, Proc, #call] :unless
315
+ # @option options [Object] :coerce (see {Coercions#extract})
316
+ # @option options [Object] :validate (see {Validators#extract})
317
+ # @yield nested-input DSL block (see {Inputs::ChildBuilder})
318
+ def required(*names, **options, &)
319
+ register(:input, *names, required: true, **options, &)
196
320
  end
197
321
 
198
- # @example
199
- # required :name, :email
200
- # required :age, type: Integer, min: 0
201
- #
202
- # @rbs (*untyped) -> void
203
- def required(...)
204
- register(:attribute, Attribute.required(...))
322
+ # @return [Hash{Symbol => Hash}] serialized input definitions
323
+ def inputs_schema
324
+ inputs.registry.transform_values(&:to_h)
205
325
  end
206
326
 
207
- # @param names [Array<Symbol>] Names of attributes to remove
208
- #
209
- # @example
210
- # remove_attributes :old_field, :deprecated_field
211
- #
212
- # @rbs (*Symbol names) -> void
213
- def remove_attributes(*names)
214
- deregister(:attribute, names)
327
+ # Reads, or declares more, outputs. With no keys, returns the registry.
328
+ #
329
+ # @param keys [Array<Symbol>]
330
+ # @param options [Hash{Symbol => Object}] see {Output#initialize}
331
+ # @option options [String] :description (also accepts `:desc`)
332
+ # @option options [Symbol, Proc, #call] :if
333
+ # @option options [Symbol, Proc, #call] :unless
334
+ # @option options [Object, Symbol, Proc, #call] :default
335
+ # @return [Outputs]
336
+ def outputs(*keys, **options)
337
+ @outputs ||=
338
+ if superclass.respond_to?(:outputs)
339
+ superclass.outputs.dup
340
+ else
341
+ Outputs.new
342
+ end
343
+
344
+ return @outputs if keys.empty?
345
+
346
+ @outputs.register(*keys, **options)
215
347
  end
216
- alias remove_attribute remove_attributes
348
+ alias output outputs
217
349
 
218
- # Declares expected context returns that must be set after task execution.
219
- # If any declared return is missing from the context after {#work} completes
220
- # successfully, the task will fail with a validation error.
221
- #
222
- # @param names [Array<Symbol, String>] Names of expected return keys in the context
223
- #
224
- # @example
225
- # returns :user, :token
226
- #
227
- # @rbs (*untyped names) -> void
228
- def returns(*names)
229
- settings.returns |= names.map(&:to_sym)
350
+ # @return [Hash{Symbol => Hash}] serialized output definitions
351
+ def outputs_schema
352
+ outputs.registry.transform_values(&:to_h)
230
353
  end
231
354
 
232
- # Removes declared returns from the task.
233
- #
234
- # @param names [Array<Symbol>] Names of returns to remove
235
- #
236
- # @example
237
- # remove_returns :old_return
238
- #
239
- # @rbs (*Symbol names) -> void
240
- def remove_returns(*names)
241
- settings.returns -= names.map(&:to_sym)
355
+ # @return [String] `"Workflow"` when the class includes {Workflow}, else `"Task"`
356
+ def type
357
+ @type ||= include?(Workflow) ? "Workflow" : "Task"
242
358
  end
243
- alias remove_return remove_returns
244
359
 
245
- # @return [Hash] Hash of attribute names to their configurations
360
+ # Executes the task. Never raises on failure; inspect the returned
361
+ # {Result} instead.
246
362
  #
247
- # @example
248
- # MyTask.attributes_schema #=> {
249
- # user_id: { name: :user_id, method_name: :user_id, required: true, types: [:integer], options: {}, children: [] },
250
- # email: { name: :email, method_name: :email, required: false, types: [:string], options: { default: nil }, children: [] },
251
- # profile: { name: :profile, method_name: :profile, required: false, types: [:hash], options: {}, children: [
252
- # { name: :bio, method_name: :bio, required: false, types: [:string], options: {}, children: [] },
253
- # { name: :name, method_name: :name, required: true, types: [:string], options: {}, children: [] }
254
- # ] }
255
- # }
363
+ # @param context [Hash, Context, #context, #to_h]
364
+ # @yieldparam result [Result]
365
+ # @return [Result, Object] the yielded block's value when a block is given
366
+ def execute(context = EMPTY_HASH, &)
367
+ new(context).execute(strict: false, &)
368
+ end
369
+ alias call execute
370
+
371
+ # Strict execution. Raises {Fault} (or the underlying exception) on
372
+ # failure; otherwise identical to {.execute}.
256
373
  #
257
- # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]]
258
- def attributes_schema
259
- Utils::Wrap.array(settings.attributes).to_h do |attr|
260
- [attr.method_name, attr.to_h]
261
- end
374
+ # @param context [Hash, Context, #context, #to_h]
375
+ # @yieldparam result [Result]
376
+ # @return [Result, Object]
377
+ # @raise [Fault, StandardError] on task failure
378
+ def execute!(context = EMPTY_HASH, &)
379
+ new(context).execute(strict: true, &)
262
380
  end
381
+ alias call! execute!
382
+
383
+ private
384
+
385
+ # @param input [Input] defines `##{input.accessor_name}` when not already taken
386
+ # @return [void]
387
+ # @raise [DefinitionError] when the accessor name collides
388
+ def define_input_reader(input)
389
+ accessor = input.accessor_name
263
390
 
264
- CallbackRegistry::TYPES.each do |callback|
265
- # @param callables [Array] Callable objects to register as callbacks
266
- # @param options [Hash] Options for the callback registration
267
- # @option options [Symbol] :priority Priority of the callback
268
- # @option options [Boolean] :async Whether the callback should run asynchronously
269
- # @param block [Proc] Block to register as a callback
270
- #
271
- # @example
272
- # before { puts "before execution" }
273
- # after :cleanup, priority: :high
274
- # around ->(task) { task.logger.info("starting") }
275
- #
276
- # @rbs (*untyped callables, **untyped options) ?{ () -> void } -> void
277
- define_method(callback) do |*callables, **options, &block|
278
- register(:callback, callback, *callables, **options, &block)
391
+ if method_defined?(accessor) || private_method_defined?(accessor)
392
+ raise DefinitionError,
393
+ "cannot define input #{accessor.inspect}: ##{accessor} is already defined on #{self}"
279
394
  end
280
- end
281
395
 
282
- # @param args [Array] Arguments to pass to the task constructor
283
- # @param kwargs [Hash] Keyword arguments to pass to the task constructor
284
- # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor
285
- #
286
- # @return [Result] The execution result
287
- #
288
- # @example
289
- # result = MyTask.execute(name: "example")
290
- #
291
- # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result
292
- def execute(*args, **kwargs)
293
- task = new(*args, **kwargs)
294
- task.execute(raise: false)
295
- block_given? ? yield(task.result) : task.result
396
+ define_method(accessor) { instance_variable_get(input.ivar_name) }
397
+ input.children.each { |child| define_input_reader(child) }
296
398
  end
297
399
 
298
- # @param args [Array] Arguments to pass to the task constructor
299
- # @param kwargs [Hash] Keyword arguments to pass to the task constructor
300
- # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor
301
- #
302
- # @return [Result] The execution result
303
- #
304
- # @raise [ExecutionError] If the task execution fails
305
- #
306
- # @example
307
- # result = MyTask.execute!(name: "example")
308
- #
309
- # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result
310
- def execute!(*args, **kwargs)
311
- task = new(*args, **kwargs)
312
- task.execute(raise: true)
313
- block_given? ? yield(task.result) : task.result
400
+ # @param input [Input] removes `##{input.accessor_name}` if defined on this class
401
+ # @return [void]
402
+ def undefine_input_reader(input)
403
+ accessor = input.accessor_name
404
+ undef_method(accessor) if method_defined?(accessor)
405
+ input.children.each { |child| undefine_input_reader(child) }
314
406
  end
315
407
 
316
408
  end
317
409
 
318
- # @param raise [Boolean] Whether to raise exceptions on failure
319
- #
320
- # @return [Result] The execution result
321
- #
322
- # @example
323
- # result = task.execute
324
- # result = task.execute(raise: true)
410
+ attr_reader :tid, :context, :errors, :metadata
411
+ alias ctx context
412
+
413
+ # @param context [Hash, Context, #context, #to_h]
414
+ # @note The built {Context} inherits `strict` mode from
415
+ # {Settings#strict_context} (falling back to
416
+ # {Configuration#strict_context}), so dynamic reads for unknown keys
417
+ # raise `NoMethodError` instead of returning `nil`.
418
+ def initialize(context = EMPTY_HASH)
419
+ @metadata = {}
420
+ @tid = SecureRandom.uuid_v7
421
+ @errors = Errors.new
422
+ @context = Context.build(context).tap do |c|
423
+ c.strict = self.class.settings.strict_context
424
+ end
425
+ end
426
+
427
+ # Executes this task instance through {Runtime}.
325
428
  #
326
- # @rbs (raise: bool) ?{ (Result) -> void } -> Result
327
- def execute(raise: false)
328
- Executor.execute(self, raise:)
429
+ # @param strict [Boolean] when `true`, re-raises {Fault}/exceptions on failure;
430
+ # when `false`, swallows them and returns the {Result}
431
+ # @yieldparam result [Result]
432
+ # @return [Result, Object] the yielded block's value when a block is given,
433
+ # otherwise the {Result}
434
+ # @raise [Fault, StandardError] only when `strict: true` and the task fails
435
+ def execute(strict: false)
436
+ result = Runtime.execute(self, strict:)
329
437
  block_given? ? yield(result) : result
330
438
  end
439
+ alias call execute
331
440
 
332
- # @raise [UndefinedMethodError] Always raised as this method must be overridden
333
- #
334
- # @example
335
- # class MyTask < Task
336
- # def work
337
- # # Custom work logic here
338
- # puts "Performing work..."
339
- # end
340
- # end
441
+ # @return [Logger] a logger tailored to this task's settings
442
+ def logger
443
+ @logger ||= LoggerProxy.logger(self)
444
+ end
445
+
446
+ # The task's core logic. Subclasses must override.
341
447
  #
342
- # @rbs () -> void
448
+ # @abstract
449
+ # @return [void]
450
+ # @raise [ImplementationError] when the subclass doesn't override
343
451
  def work
344
- raise UndefinedMethodError, "undefined method #{self.class.name}#work"
452
+ raise ImplementationError, "undefined method #{self.class}#work"
345
453
  end
346
454
 
347
- # Returns a logger for this task. When a custom log_level or
348
- # log_formatter is configured, the shared logger is duplicated
349
- # so the original instance is never mutated.
350
- #
351
- # @return [Logger] The logger instance for this task
352
- #
353
- # @example
354
- # logger.info "Starting task execution"
355
- # logger.error "Task failed", error: exception
455
+ private
456
+
457
+ # Signals a successful halt.
356
458
  #
357
- # @rbs () -> Logger
358
- def logger
359
- @logger ||= begin
360
- settings = self.class.settings
361
- log_instance = settings.logger || CMDx.configuration.logger
362
-
363
- if settings.log_level || settings.log_formatter
364
- log_instance = log_instance.dup
365
- log_instance.level = settings.log_level if settings.log_level
366
- log_instance.formatter = settings.log_formatter if settings.log_formatter
367
- end
459
+ # @param reason [String, nil]
460
+ # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing
461
+ # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
462
+ # @return [void] throws `Signal::TAG`; never returns
463
+ # @raise [FrozenError] when the task has already been frozen (post-execution)
464
+ # @note Must be called from inside `work` (inside Runtime's `catch(:cmdx_signal)`).
465
+ def success!(reason = nil, **sigdata)
466
+ raise FrozenError, "cannot throw signals" if frozen?
368
467
 
369
- log_instance
370
- end
468
+ metadata.merge!(sigdata) unless sigdata.empty?
469
+ throw(Signal::TAG, Signal.success(reason, metadata:))
371
470
  end
372
471
 
373
- # @option return [Integer] :index The result index
374
- # @option return [String] :chain_id The chain identifier
375
- # @option return [String] :type The task type ("Task" or "Workflow")
376
- # @option return [Array<Symbol>] :tags The task tags
377
- # @option return [String] :class The task class name
378
- # @option return [String] :id The task identifier
379
- # @option return [Hash] :context The task context (when dump_context is true)
380
- #
381
- # @return [Hash] A hash representation of the task
472
+ # Signals a skip (interrupted + skipped).
382
473
  #
383
- # @example
384
- # task_hash = task.to_h
385
- # puts "Task type: #{task_hash[:type]}"
386
- # puts "Task tags: #{task_hash[:tags].join(', ')}"
387
- #
388
- # @rbs () -> Hash[Symbol, untyped]
389
- def to_h
390
- {
391
- index: result.index,
392
- chain_id: chain.id,
393
- type: self.class.type,
394
- class: self.class.name,
395
- id:,
396
- dry_run: dry_run?,
397
- tags: self.class.settings.tags
398
- }.tap do |hash|
399
- if self.class.settings.dump_context
400
- # Large context can make dumps and logs very noisy,
401
- # so only include it if explicitly enabled
402
- hash[:context] = context.to_h
403
- end
404
- end
474
+ # @param reason [String, nil]
475
+ # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing
476
+ # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
477
+ # @return [void] throws `Signal::TAG`; never returns
478
+ # @raise [FrozenError]
479
+ def skip!(reason = nil, **sigdata)
480
+ raise FrozenError, "cannot throw signals" if frozen?
481
+
482
+ metadata.merge!(sigdata) unless sigdata.empty?
483
+ throw(Signal::TAG, Signal.skipped(reason, metadata:))
405
484
  end
406
485
 
407
- # @return [String] A string representation of the task
486
+ # Signals a failure. Captures current call frames as the signal
487
+ # backtrace for Fault propagation.
408
488
  #
409
- # @example
410
- # puts task.to_s
411
- # # Output: "Task[MyTask] tags: [:important] id: abc123"
489
+ # @param reason [String, nil]
490
+ # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing
491
+ # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
492
+ # @return [void] throws `Signal::TAG`; never returns
493
+ # @raise [FrozenError]
494
+ def fail!(reason = nil, **sigdata)
495
+ raise FrozenError, "cannot throw signals" if frozen?
496
+
497
+ metadata.merge!(sigdata) unless sigdata.empty?
498
+ throw(Signal::TAG, Signal.failed(reason, metadata:, backtrace: caller_locations(1)))
499
+ end
500
+
501
+ # Re-throws a failed peer Result's signal through this task. No-op when
502
+ # `other` didn't fail.
412
503
  #
413
- # @rbs () -> String
414
- def to_s
415
- Utils::Format.to_str(to_h)
504
+ # @param other [Result]
505
+ # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before echoing
506
+ # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
507
+ # @return [void]
508
+ # @raise [FrozenError]
509
+ def throw!(other, **sigdata)
510
+ raise FrozenError, "cannot throw signals" if frozen?
511
+
512
+ return unless other.failed?
513
+
514
+ metadata.merge!(sigdata) unless sigdata.empty?
515
+ throw(Signal::TAG, Signal.echoed(other, metadata:, backtrace: caller_locations(1)))
416
516
  end
417
517
 
418
518
  end