cmdx 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/.cursor/prompts/docs.md +3 -3
  4. data/.cursor/prompts/llms.md +1 -3
  5. data/.cursor/prompts/yardoc.md +1 -0
  6. data/.irbrc +14 -2
  7. data/CHANGELOG.md +64 -45
  8. data/LLM.md +159 -53
  9. data/README.md +26 -83
  10. data/docs/.DS_Store +0 -0
  11. data/docs/assets/favicon.ico +0 -0
  12. data/docs/assets/favicon.svg +1 -0
  13. data/docs/attributes/coercions.md +12 -24
  14. data/docs/attributes/defaults.md +3 -16
  15. data/docs/attributes/definitions.md +16 -30
  16. data/docs/attributes/naming.md +3 -13
  17. data/docs/attributes/transformations.md +63 -0
  18. data/docs/attributes/validations.md +14 -33
  19. data/docs/basics/chain.md +14 -23
  20. data/docs/basics/context.md +13 -22
  21. data/docs/basics/execution.md +8 -26
  22. data/docs/basics/setup.md +8 -19
  23. data/docs/callbacks.md +19 -32
  24. data/docs/deprecation.md +8 -25
  25. data/docs/getting_started.md +109 -76
  26. data/docs/index.md +132 -0
  27. data/docs/internationalization.md +6 -18
  28. data/docs/interruptions/exceptions.md +10 -16
  29. data/docs/interruptions/faults.md +8 -25
  30. data/docs/interruptions/halt.md +12 -27
  31. data/docs/logging.md +7 -17
  32. data/docs/middlewares.md +13 -29
  33. data/docs/outcomes/result.md +21 -38
  34. data/docs/outcomes/states.md +8 -22
  35. data/docs/outcomes/statuses.md +10 -21
  36. data/docs/stylesheets/extra.css +42 -0
  37. data/docs/tips_and_tricks.md +7 -46
  38. data/docs/workflows.md +23 -38
  39. data/examples/active_record_query_tagging.md +46 -0
  40. data/examples/paper_trail_whatdunnit.md +39 -0
  41. data/lib/cmdx/attribute.rb +88 -6
  42. data/lib/cmdx/attribute_registry.rb +20 -0
  43. data/lib/cmdx/attribute_value.rb +56 -10
  44. data/lib/cmdx/callback_registry.rb +31 -2
  45. data/lib/cmdx/chain.rb +34 -1
  46. data/lib/cmdx/coercion_registry.rb +18 -0
  47. data/lib/cmdx/coercions/array.rb +2 -0
  48. data/lib/cmdx/coercions/big_decimal.rb +3 -0
  49. data/lib/cmdx/coercions/boolean.rb +5 -0
  50. data/lib/cmdx/coercions/complex.rb +2 -0
  51. data/lib/cmdx/coercions/date.rb +4 -0
  52. data/lib/cmdx/coercions/date_time.rb +5 -0
  53. data/lib/cmdx/coercions/float.rb +2 -0
  54. data/lib/cmdx/coercions/hash.rb +4 -0
  55. data/lib/cmdx/coercions/integer.rb +2 -0
  56. data/lib/cmdx/coercions/rational.rb +2 -0
  57. data/lib/cmdx/coercions/string.rb +2 -0
  58. data/lib/cmdx/coercions/symbol.rb +2 -0
  59. data/lib/cmdx/coercions/time.rb +5 -0
  60. data/lib/cmdx/configuration.rb +119 -3
  61. data/lib/cmdx/context.rb +36 -0
  62. data/lib/cmdx/deprecator.rb +6 -3
  63. data/lib/cmdx/errors.rb +22 -0
  64. data/lib/cmdx/executor.rb +136 -7
  65. data/lib/cmdx/faults.rb +14 -0
  66. data/lib/cmdx/identifier.rb +2 -0
  67. data/lib/cmdx/locale.rb +3 -0
  68. data/lib/cmdx/log_formatters/json.rb +2 -0
  69. data/lib/cmdx/log_formatters/key_value.rb +2 -0
  70. data/lib/cmdx/log_formatters/line.rb +2 -0
  71. data/lib/cmdx/log_formatters/logstash.rb +2 -0
  72. data/lib/cmdx/log_formatters/raw.rb +2 -0
  73. data/lib/cmdx/middleware_registry.rb +20 -0
  74. data/lib/cmdx/middlewares/correlate.rb +11 -0
  75. data/lib/cmdx/middlewares/runtime.rb +4 -0
  76. data/lib/cmdx/middlewares/timeout.rb +4 -0
  77. data/lib/cmdx/pipeline.rb +24 -5
  78. data/lib/cmdx/railtie.rb +13 -0
  79. data/lib/cmdx/result.rb +133 -2
  80. data/lib/cmdx/task.rb +103 -8
  81. data/lib/cmdx/utils/call.rb +2 -0
  82. data/lib/cmdx/utils/condition.rb +3 -0
  83. data/lib/cmdx/utils/format.rb +5 -0
  84. data/lib/cmdx/validator_registry.rb +18 -0
  85. data/lib/cmdx/validators/exclusion.rb +2 -0
  86. data/lib/cmdx/validators/format.rb +2 -0
  87. data/lib/cmdx/validators/inclusion.rb +2 -0
  88. data/lib/cmdx/validators/length.rb +14 -0
  89. data/lib/cmdx/validators/numeric.rb +14 -0
  90. data/lib/cmdx/validators/presence.rb +2 -0
  91. data/lib/cmdx/version.rb +4 -1
  92. data/lib/cmdx/workflow.rb +10 -0
  93. data/lib/cmdx.rb +9 -0
  94. data/lib/generators/cmdx/locale_generator.rb +0 -1
  95. data/lib/generators/cmdx/templates/install.rb +9 -0
  96. data/mkdocs.yml +122 -0
  97. data/src/cmdx-dark-logo.png +0 -0
  98. data/src/cmdx-favicon.svg +1 -0
  99. data/src/cmdx-light-logo.png +0 -0
  100. data/src/cmdx-logo.svg +1 -0
  101. metadata +14 -3
  102. data/lib/cmdx/freezer.rb +0 -51
  103. data/src/cmdx-logo.png +0 -0
@@ -3,13 +3,112 @@
3
3
  module CMDx
4
4
 
5
5
  # Configuration class that manages global settings for CMDx including middlewares,
6
- # callbacks, coercions, validators, breakpoints, and logging.
6
+ # callbacks, coercions, validators, breakpoints, backtraces, and logging.
7
7
  class Configuration
8
8
 
9
+ # @rbs DEFAULT_BREAKPOINTS: Array[String]
9
10
  DEFAULT_BREAKPOINTS = %w[failed].freeze
10
11
 
11
- attr_accessor :middlewares, :callbacks, :coercions, :validators,
12
- :task_breakpoints, :workflow_breakpoints, :logger
12
+ # Returns the middleware registry for task execution.
13
+ #
14
+ # @return [MiddlewareRegistry] The middleware registry
15
+ #
16
+ # @example
17
+ # config.middlewares.register(CustomMiddleware)
18
+ #
19
+ # @rbs @middlewares: MiddlewareRegistry
20
+ attr_accessor :middlewares
21
+
22
+ # Returns the callback registry for task lifecycle hooks.
23
+ #
24
+ # @return [CallbackRegistry] The callback registry
25
+ #
26
+ # @example
27
+ # config.callbacks.register(:before_execution, :log_start)
28
+ #
29
+ # @rbs @callbacks: CallbackRegistry
30
+ attr_accessor :callbacks
31
+
32
+ # Returns the coercion registry for type conversions.
33
+ #
34
+ # @return [CoercionRegistry] The coercion registry
35
+ #
36
+ # @example
37
+ # config.coercions.register(:custom, CustomCoercion)
38
+ #
39
+ # @rbs @coercions: CoercionRegistry
40
+ attr_accessor :coercions
41
+
42
+ # Returns the validator registry for attribute validation.
43
+ #
44
+ # @return [ValidatorRegistry] The validator registry
45
+ #
46
+ # @example
47
+ # config.validators.register(:email, EmailValidator)
48
+ #
49
+ # @rbs @validators: ValidatorRegistry
50
+ attr_accessor :validators
51
+
52
+ # Returns the breakpoint statuses for task execution interruption.
53
+ #
54
+ # @return [Array<String>] Array of status names that trigger breakpoints
55
+ #
56
+ # @example
57
+ # config.task_breakpoints = ["failed", "skipped"]
58
+ #
59
+ # @rbs @task_breakpoints: Array[String]
60
+ attr_accessor :task_breakpoints
61
+
62
+ # Returns the breakpoint statuses for workflow execution interruption.
63
+ #
64
+ # @return [Array<String>] Array of status names that trigger breakpoints
65
+ #
66
+ # @example
67
+ # config.workflow_breakpoints = ["failed", "skipped"]
68
+ #
69
+ # @rbs @task_breakpoints: Array[String]
70
+ # @rbs @workflow_breakpoints: Array[String]
71
+ attr_accessor :workflow_breakpoints
72
+
73
+ # Returns the logger instance for CMDx operations.
74
+ #
75
+ # @return [Logger] The logger instance
76
+ #
77
+ # @example
78
+ # config.logger.level = Logger::DEBUG
79
+ #
80
+ # @rbs @logger: Logger
81
+ attr_accessor :logger
82
+
83
+ # Returns whether to log backtraces for failed tasks.
84
+ #
85
+ # @return [Boolean] true if backtraces should be logged
86
+ #
87
+ # @example
88
+ # config.backtrace = true
89
+ #
90
+ # @rbs @backtrace: bool
91
+ attr_accessor :backtrace
92
+
93
+ # Returns the proc used to clean backtraces before logging.
94
+ #
95
+ # @return [Proc, nil] The backtrace cleaner proc, or nil if not set
96
+ #
97
+ # @example
98
+ # config.backtrace_cleaner = ->(bt) { bt.first(5) }
99
+ #
100
+ # @rbs @backtrace_cleaner: (Proc | nil)
101
+ attr_accessor :backtrace_cleaner
102
+
103
+ # Returns the proc called when exceptions occur during execution.
104
+ #
105
+ # @return [Proc, nil] The exception handler proc, or nil if not set
106
+ #
107
+ # @example
108
+ # config.exception_handler = ->(task, error) { Sentry.capture_exception(error) }
109
+ #
110
+ # @rbs @exception_handler: (Proc | nil)
111
+ attr_accessor :exception_handler
13
112
 
14
113
  # Initializes a new Configuration instance with default values.
15
114
  #
@@ -22,6 +121,8 @@ module CMDx
22
121
  # config = Configuration.new
23
122
  # config.middlewares.class # => MiddlewareRegistry
24
123
  # config.task_breakpoints # => ["failed"]
124
+ #
125
+ # @rbs () -> void
25
126
  def initialize
26
127
  @middlewares = MiddlewareRegistry.new
27
128
  @callbacks = CallbackRegistry.new
@@ -31,6 +132,10 @@ module CMDx
31
132
  @task_breakpoints = DEFAULT_BREAKPOINTS
32
133
  @workflow_breakpoints = DEFAULT_BREAKPOINTS
33
134
 
135
+ @backtrace = false
136
+ @backtrace_cleaner = nil
137
+ @exception_handler = nil
138
+
34
139
  @logger = Logger.new(
35
140
  $stdout,
36
141
  progname: "cmdx",
@@ -47,6 +152,8 @@ module CMDx
47
152
  # config = Configuration.new
48
153
  # config.to_h
49
154
  # # => { middlewares: #<MiddlewareRegistry>, callbacks: #<CallbackRegistry>, ... }
155
+ #
156
+ # @rbs () -> Hash[Symbol, untyped]
50
157
  def to_h
51
158
  {
52
159
  middlewares: @middlewares,
@@ -55,6 +162,9 @@ module CMDx
55
162
  validators: @validators,
56
163
  task_breakpoints: @task_breakpoints,
57
164
  workflow_breakpoints: @workflow_breakpoints,
165
+ backtrace: @backtrace,
166
+ backtrace_cleaner: @backtrace_cleaner,
167
+ exception_handler: @exception_handler,
58
168
  logger: @logger
59
169
  }
60
170
  end
@@ -70,6 +180,8 @@ module CMDx
70
180
  # @example
71
181
  # config = CMDx.configuration
72
182
  # config.middlewares # => #<MiddlewareRegistry>
183
+ #
184
+ # @rbs () -> Configuration
73
185
  def configuration
74
186
  return @configuration if @configuration
75
187
 
@@ -91,6 +203,8 @@ module CMDx
91
203
  # config.task_breakpoints = ["failed", "skipped"]
92
204
  # config.logger.level = Logger::DEBUG
93
205
  # end
206
+ #
207
+ # @rbs () { (Configuration) -> void } -> Configuration
94
208
  def configure
95
209
  raise ArgumentError, "block required" unless block_given?
96
210
 
@@ -106,6 +220,8 @@ module CMDx
106
220
  # @example
107
221
  # CMDx.reset_configuration!
108
222
  # # Configuration is now reset to defaults
223
+ #
224
+ # @rbs () -> Configuration
109
225
  def reset_configuration!
110
226
  @configuration = Configuration.new
111
227
  end
data/lib/cmdx/context.rb CHANGED
@@ -11,6 +11,14 @@ module CMDx
11
11
 
12
12
  extend Forwardable
13
13
 
14
+ # Returns the internal hash storing context data.
15
+ #
16
+ # @return [Hash{Symbol => Object}] The internal hash table
17
+ #
18
+ # @example
19
+ # context.table # => { name: "John", age: 30 }
20
+ #
21
+ # @rbs @table: Hash[Symbol, untyped]
14
22
  attr_reader :table
15
23
  alias to_h table
16
24
 
@@ -28,6 +36,8 @@ module CMDx
28
36
  # @example
29
37
  # context = Context.new(name: "John", age: 30)
30
38
  # context[:name] # => "John"
39
+ #
40
+ # @rbs (untyped args) -> void
31
41
  def initialize(args = {})
32
42
  @table =
33
43
  if args.respond_to?(:to_hash)
@@ -50,6 +60,8 @@ module CMDx
50
60
  # existing = Context.new(name: "John")
51
61
  # built = Context.build(existing) # reuses existing context
52
62
  # built.object_id == existing.object_id # => true
63
+ #
64
+ # @rbs (untyped context) -> Context
53
65
  def self.build(context = {})
54
66
  if context.is_a?(self) && !context.frozen?
55
67
  context
@@ -70,6 +82,8 @@ module CMDx
70
82
  # context = Context.new(name: "John")
71
83
  # context[:name] # => "John"
72
84
  # context["name"] # => "John" (automatically converted to symbol)
85
+ #
86
+ # @rbs ((String | Symbol) key) -> untyped
73
87
  def [](key)
74
88
  table[key.to_sym]
75
89
  end
@@ -85,6 +99,8 @@ module CMDx
85
99
  # context = Context.new
86
100
  # context.store(:name, "John")
87
101
  # context[:name] # => "John"
102
+ #
103
+ # @rbs ((String | Symbol) key, untyped value) -> untyped
88
104
  def store(key, value)
89
105
  table[key.to_sym] = value
90
106
  end
@@ -104,6 +120,8 @@ module CMDx
104
120
  # context.fetch(:name) # => "John"
105
121
  # context.fetch(:age, 25) # => 25
106
122
  # context.fetch(:city) { |key| "Unknown #{key}" } # => "Unknown city"
123
+ #
124
+ # @rbs ((String | Symbol) key, *untyped) ?{ ((String | Symbol)) -> untyped } -> untyped
107
125
  def fetch(key, ...)
108
126
  table.fetch(key.to_sym, ...)
109
127
  end
@@ -122,6 +140,8 @@ module CMDx
122
140
  # context.fetch_or_store(:name, "Default") # => "John" (existing value)
123
141
  # context.fetch_or_store(:age, 25) # => 25 (stored and returned)
124
142
  # context.fetch_or_store(:city) { |key| "Unknown #{key}" } # => "Unknown city" (stored and returned)
143
+ #
144
+ # @rbs ((String | Symbol) key, ?untyped value) ?{ () -> untyped } -> untyped
125
145
  def fetch_or_store(key, value = nil)
126
146
  table.fetch(key.to_sym) do
127
147
  table[key.to_sym] = block_given? ? yield : value
@@ -139,6 +159,8 @@ module CMDx
139
159
  # context = Context.new(name: "John")
140
160
  # context.merge!(age: 30, city: "NYC")
141
161
  # context.to_h # => {name: "John", age: 30, city: "NYC"}
162
+ #
163
+ # @rbs (?untyped args) -> self
142
164
  def merge!(args = {})
143
165
  args.to_h.each { |key, value| self[key.to_sym] = value }
144
166
  self
@@ -156,6 +178,8 @@ module CMDx
156
178
  # context = Context.new(name: "John", age: 30)
157
179
  # context.delete!(:age) # => 30
158
180
  # context.delete!(:city) { |key| "Key #{key} not found" } # => "Key city not found"
181
+ #
182
+ # @rbs ((String | Symbol) key) ?{ ((String | Symbol)) -> untyped } -> untyped
159
183
  def delete!(key, &)
160
184
  table.delete(key.to_sym, &)
161
185
  end
@@ -170,6 +194,8 @@ module CMDx
170
194
  # context1 = Context.new(name: "John")
171
195
  # context2 = Context.new(name: "John")
172
196
  # context1 == context2 # => true
197
+ #
198
+ # @rbs (untyped other) -> bool
173
199
  def eql?(other)
174
200
  other.is_a?(self.class) && (to_h == other.to_h)
175
201
  end
@@ -185,6 +211,8 @@ module CMDx
185
211
  # context = Context.new(name: "John")
186
212
  # context.key?(:name) # => true
187
213
  # context.key?(:age) # => false
214
+ #
215
+ # @rbs ((String | Symbol) key) -> bool
188
216
  def key?(key)
189
217
  table.key?(key.to_sym)
190
218
  end
@@ -200,6 +228,8 @@ module CMDx
200
228
  # context = Context.new(user: {profile: {name: "John"}})
201
229
  # context.dig(:user, :profile, :name) # => "John"
202
230
  # context.dig(:user, :profile, :age) # => nil
231
+ #
232
+ # @rbs ((String | Symbol) key, *(String | Symbol) keys) -> untyped
203
233
  def dig(key, *keys)
204
234
  table.dig(key.to_sym, *keys)
205
235
  end
@@ -211,6 +241,8 @@ module CMDx
211
241
  # @example
212
242
  # context = Context.new(name: "John", age: 30)
213
243
  # context.to_s # => "name: John, age: 30"
244
+ #
245
+ # @rbs () -> String
214
246
  def to_s
215
247
  Utils::Format.to_str(to_h)
216
248
  end
@@ -227,6 +259,8 @@ module CMDx
227
259
  # @yield [Object] optional block
228
260
  #
229
261
  # @return [Object] the result of the method call
262
+ #
263
+ # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped
230
264
  def method_missing(method_name, *args, **_kwargs, &)
231
265
  fetch(method_name) do
232
266
  store(method_name[0..-2], args.first) if method_name.end_with?("=")
@@ -244,6 +278,8 @@ module CMDx
244
278
  # context = Context.new(name: "John")
245
279
  # context.respond_to?(:name) # => true
246
280
  # context.respond_to?(:age) # => false
281
+ #
282
+ # @rbs (Symbol method_name, ?bool include_private) -> bool
247
283
  def respond_to_missing?(method_name, include_private = false)
248
284
  key?(method_name) || super
249
285
  end
@@ -10,6 +10,7 @@ module CMDx
10
10
 
11
11
  extend self
12
12
 
13
+ # @rbs EVAL: Proc
13
14
  EVAL = proc do |target, callable|
14
15
  case callable
15
16
  when /raise|log|warn/ then callable
@@ -44,15 +45,17 @@ module CMDx
44
45
  # settings(deprecate: :warn)
45
46
  # end
46
47
  #
47
- # MyTask.new # => [MyTask] DEPRECATED: migrate to replacement or discontinue use
48
+ # MyTask.new # => [MyTask] DEPRECATED: migrate to a replacement or discontinue use
49
+ #
50
+ # @rbs (Task task) -> void
48
51
  def restrict(task)
49
52
  type = EVAL.call(task, task.class.settings[:deprecate])
50
53
 
51
54
  case type
52
55
  when NilClass, FalseClass # Do nothing
53
56
  when TrueClass, /raise/ then raise DeprecationError, "#{task.class.name} usage prohibited"
54
- when /log/ then task.logger.warn { "DEPRECATED: migrate to replacement or discontinue use" }
55
- when /warn/ then warn("[#{task.class.name}] DEPRECATED: migrate to replacement or discontinue use", category: :deprecated)
57
+ when /log/ then task.logger.warn { "DEPRECATED: migrate to a replacement or discontinue use" }
58
+ when /warn/ then warn("[#{task.class.name}] DEPRECATED: migrate to a replacement or discontinue use", category: :deprecated)
56
59
  else raise "unknown deprecation type #{type.inspect}"
57
60
  end
58
61
  end
data/lib/cmdx/errors.rb CHANGED
@@ -8,11 +8,21 @@ module CMDx
8
8
 
9
9
  extend Forwardable
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]]
11
19
  attr_reader :messages
12
20
 
13
21
  def_delegators :messages, :empty?
14
22
 
15
23
  # Initialize a new error collection.
24
+ #
25
+ # @rbs () -> void
16
26
  def initialize
17
27
  @messages = {}
18
28
  end
@@ -26,6 +36,8 @@ module CMDx
26
36
  # errors = CMDx::Errors.new
27
37
  # errors.add(:email, "must be valid format")
28
38
  # errors.add(:email, "cannot be blank")
39
+ #
40
+ # @rbs (Symbol attribute, String message) -> void
29
41
  def add(attribute, message)
30
42
  return if message.empty?
31
43
 
@@ -42,6 +54,8 @@ module CMDx
42
54
  # @example
43
55
  # errors.for?(:email) # => true
44
56
  # errors.for?(:name) # => false
57
+ #
58
+ # @rbs (Symbol attribute) -> bool
45
59
  def for?(attribute)
46
60
  return false unless messages.key?(attribute)
47
61
 
@@ -54,6 +68,8 @@ module CMDx
54
68
  #
55
69
  # @example
56
70
  # errors.full_messages # => { email: ["email must be valid format", "email cannot be blank"] }
71
+ #
72
+ # @rbs () -> Hash[Symbol, Array[String]]
57
73
  def full_messages
58
74
  messages.each_with_object({}) do |(attribute, messages), hash|
59
75
  hash[attribute] = messages.map { |message| "#{attribute} #{message}" }
@@ -66,6 +82,8 @@ module CMDx
66
82
  #
67
83
  # @example
68
84
  # errors.to_h # => { email: ["must be valid format", "cannot be blank"] }
85
+ #
86
+ # @rbs () -> Hash[Symbol, Array[String]]
69
87
  def to_h
70
88
  messages.transform_values(&:to_a)
71
89
  end
@@ -78,6 +96,8 @@ module CMDx
78
96
  # @example
79
97
  # errors.to_hash # => { email: ["must be valid format", "cannot be blank"] }
80
98
  # errors.to_hash(true) # => { email: ["email must be valid format", "email cannot be blank"] }
99
+ #
100
+ # @rbs (?bool full) -> Hash[Symbol, Array[String]]
81
101
  def to_hash(full = false)
82
102
  full ? full_messages : to_h
83
103
  end
@@ -88,6 +108,8 @@ module CMDx
88
108
  #
89
109
  # @example
90
110
  # errors.to_s # => "email must be valid format. email cannot be blank"
111
+ #
112
+ # @rbs () -> String
91
113
  def to_s
92
114
  full_messages.values.flatten.join(". ")
93
115
  end
data/lib/cmdx/executor.rb CHANGED
@@ -8,6 +8,14 @@ module CMDx
8
8
  # and proper error handling for different types of failures.
9
9
  class Executor
10
10
 
11
+ # Returns the task being executed.
12
+ #
13
+ # @return [Task] The task instance
14
+ #
15
+ # @example
16
+ # executor.task.id # => "abc123"
17
+ #
18
+ # @rbs @task: Task
11
19
  attr_reader :task
12
20
 
13
21
  # @param task [CMDx::Task] The task to execute
@@ -16,6 +24,8 @@ module CMDx
16
24
  #
17
25
  # @example
18
26
  # executor = CMDx::Executor.new(my_task)
27
+ #
28
+ # @rbs (Task task) -> void
19
29
  def initialize(task)
20
30
  @task = task
21
31
  end
@@ -32,6 +42,8 @@ module CMDx
32
42
  # @example
33
43
  # CMDx::Executor.execute(my_task)
34
44
  # CMDx::Executor.execute(my_task, raise: true)
45
+ #
46
+ # @rbs (Task task, raise: bool) -> Result
35
47
  def self.execute(task, raise: false)
36
48
  instance = new(task)
37
49
  raise ? instance.execute! : instance.execute
@@ -44,16 +56,20 @@ module CMDx
44
56
  # @example
45
57
  # executor = CMDx::Executor.new(my_task)
46
58
  # result = executor.execute
59
+ #
60
+ # @rbs () -> Result
47
61
  def execute
48
62
  task.class.settings[:middlewares].call!(task) do
49
- pre_execution!
63
+ pre_execution! unless @pre_execution
50
64
  execution!
51
65
  rescue UndefinedMethodError => e
52
66
  raise(e) # No need to clear the Chain since exception is not being re-raised
53
67
  rescue Fault => e
54
68
  task.result.throw!(e.result, halt: false, cause: e)
55
69
  rescue StandardError => e
70
+ retry if retry_execution?(e)
56
71
  task.result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
72
+ task.class.settings[:exception_handler]&.call(task, e)
57
73
  ensure
58
74
  task.result.executed!
59
75
  post_execution!
@@ -71,9 +87,11 @@ module CMDx
71
87
  # @example
72
88
  # executor = CMDx::Executor.new(my_task)
73
89
  # result = executor.execute!
90
+ #
91
+ # @rbs () -> Result
74
92
  def execute!
75
93
  task.class.settings[:middlewares].call!(task) do
76
- pre_execution!
94
+ pre_execution! unless @pre_execution
77
95
  execution!
78
96
  rescue UndefinedMethodError => e
79
97
  raise_exception(e)
@@ -81,6 +99,7 @@ module CMDx
81
99
  task.result.throw!(e.result, halt: false, cause: e)
82
100
  halt_execution?(e) ? raise_exception(e) : post_execution!
83
101
  rescue StandardError => e
102
+ retry if retry_execution?(e)
84
103
  task.result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
85
104
  raise_exception(e)
86
105
  else
@@ -101,6 +120,8 @@ module CMDx
101
120
  #
102
121
  # @example
103
122
  # halt_execution?(fault_exception)
123
+ #
124
+ # @rbs (Exception exception) -> bool
104
125
  def halt_execution?(exception)
105
126
  breakpoints = task.class.settings[:breakpoints] || task.class.settings[:task_breakpoints]
106
127
  breakpoints = Array(breakpoints).map(&:to_s).uniq
@@ -108,6 +129,40 @@ module CMDx
108
129
  breakpoints.include?(exception.result.status)
109
130
  end
110
131
 
132
+ # Determines if execution should be retried based on retry configuration.
133
+ #
134
+ # @param exception [Exception] The exception that occurred
135
+ #
136
+ # @return [Boolean] Whether execution should be retried
137
+ #
138
+ # @example
139
+ # retry_execution?(standard_error)
140
+ #
141
+ # @rbs (Exception exception) -> bool
142
+ def retry_execution?(exception)
143
+ available_retries = (task.class.settings[:retries] || 0).to_i
144
+ return false unless available_retries.positive?
145
+
146
+ current_retries = (task.result.metadata[:retries] ||= 0).to_i
147
+ remaining_retries = available_retries - current_retries
148
+ return false unless remaining_retries.positive?
149
+
150
+ exceptions = Array(task.class.settings[:retry_on] || StandardError)
151
+ return false unless exceptions.any? { |e| exception.class <= e }
152
+
153
+ task.result.metadata[:retries] += 1
154
+
155
+ task.logger.warn do
156
+ reason = "[#{exception.class}] #{exception.message}"
157
+ task.to_h.merge!(reason:, remaining_retries:)
158
+ end
159
+
160
+ jitter = task.class.settings[:retry_jitter].to_f * current_retries
161
+ sleep(jitter) if jitter.positive?
162
+
163
+ true
164
+ end
165
+
111
166
  # Raises an exception and clears the chain.
112
167
  #
113
168
  # @param exception [Exception] The exception to raise
@@ -116,8 +171,11 @@ module CMDx
116
171
  #
117
172
  # @example
118
173
  # raise_exception(standard_error)
174
+ #
175
+ # @rbs (Exception exception) -> void
119
176
  def raise_exception(exception)
120
177
  Chain.clear
178
+
121
179
  raise(exception)
122
180
  end
123
181
 
@@ -129,14 +187,27 @@ module CMDx
129
187
  #
130
188
  # @example
131
189
  # invoke_callbacks(:before_execution)
190
+ #
191
+ # @rbs (Symbol type) -> void
132
192
  def invoke_callbacks(type)
133
193
  task.class.settings[:callbacks].invoke(type, task)
134
194
  end
135
195
 
136
196
  private
137
197
 
198
+ # Lazy loaded repeator instance to handle retries.
199
+ #
200
+ # @rbs () -> untyped
201
+ def repeator
202
+ @repeator ||= Repeator.new(task)
203
+ end
204
+
138
205
  # Performs pre-execution tasks including validation and attribute verification.
206
+ #
207
+ # @rbs () -> void
139
208
  def pre_execution!
209
+ @pre_execution = true
210
+
140
211
  invoke_callbacks(:before_validation)
141
212
 
142
213
  task.class.settings[:attributes].define_and_verify(task)
@@ -152,6 +223,8 @@ module CMDx
152
223
  end
153
224
 
154
225
  # Executes the main task logic.
226
+ #
227
+ # @rbs () -> void
155
228
  def execution!
156
229
  invoke_callbacks(:before_execution)
157
230
 
@@ -160,6 +233,8 @@ module CMDx
160
233
  end
161
234
 
162
235
  # Performs post-execution tasks including callback invocation.
236
+ #
237
+ # @rbs () -> void
163
238
  def post_execution!
164
239
  invoke_callbacks(:"on_#{task.result.state}")
165
240
  invoke_callbacks(:on_executed) if task.result.executed?
@@ -170,14 +245,68 @@ module CMDx
170
245
  end
171
246
 
172
247
  # Finalizes execution by freezing the task and logging results.
248
+ #
249
+ # @rbs () -> Result
173
250
  def finalize_execution!
174
- task.logger.tap do |logger|
175
- logger.with_level(:info) do
176
- logger.info { task.result.to_h }
177
- end
251
+ log_execution!
252
+ log_backtrace! if task.class.settings[:backtrace]
253
+
254
+ freeze_execution!
255
+ clear_chain!
256
+ end
257
+
258
+ # Logs the execution result at the configured log level.
259
+ #
260
+ # @rbs () -> void
261
+ def log_execution!
262
+ task.logger.info { task.result.to_h }
263
+ end
264
+
265
+ # Logs the backtrace of the exception if the task failed.
266
+ #
267
+ # @rbs () -> void
268
+ def log_backtrace!
269
+ return unless task.result.failed?
270
+
271
+ exception = task.result.caused_failure.cause
272
+ return if exception.is_a?(Fault)
273
+
274
+ task.logger.error do
275
+ "[#{exception.class}] #{exception.message}\n" <<
276
+ if (cleaner = task.class.settings[:backtrace_cleaner])
277
+ cleaner.call(exception.backtrace).join("\n\t")
278
+ else
279
+ exception.full_message(highlight: false)
280
+ end
178
281
  end
282
+ end
283
+
284
+ # Freezes the task and its associated objects to prevent modifications.
285
+ #
286
+ # @rbs () -> void
287
+ def freeze_execution!
288
+ # Stubbing on frozen objects is not allowed in most test environments.
289
+ skip_freezing = ENV.fetch("SKIP_CMDX_FREEZING", false)
290
+ return if Coercions::Boolean.call(skip_freezing)
291
+
292
+ task.freeze
293
+ task.result.freeze
179
294
 
180
- Freezer.immute(task)
295
+ # Freezing the context and chain can only be done
296
+ # once the outer-most task has completed.
297
+ return unless task.result.index.zero?
298
+
299
+ task.context.freeze
300
+ task.chain.freeze
301
+ end
302
+
303
+ # Clears the chain if the task is the outermost (top-level) task.
304
+ #
305
+ # @rbs () -> void
306
+ def clear_chain!
307
+ return unless task.result.index.zero?
308
+
309
+ Chain.clear
181
310
  end
182
311
 
183
312
  end