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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1acff12a85519d3546d8c2c6b8d9a8fc6330e731826a9a4a359907631e7afa82
4
- data.tar.gz: 69c814dc115c70dc36ab02e7462d00a5b802490d39b7f4bc1fb2c9e5c666c55e
3
+ metadata.gz: b679253afacaaa6d2b1ef842026e3b535ee1b0378167bfa0ebfe5b8c93508e83
4
+ data.tar.gz: 1751b980c3af7d2e4950d6f1447d17883f94ba1e26ff1a550d90043fbb30b956
5
5
  SHA512:
6
- metadata.gz: f2bc1eb34789f888bffc84b9ebb65252fc23ea8d598465c9ff853670ab44c9e640988b2a528ed97ff53cc1774a26e35a87706a9056aa7fc55f6604e73540cb29
7
- data.tar.gz: 5c7af44bda7a2d7371c8f62183dde2195bf9b27382c65c379bc8bfd94321d7b3ab61ad41888a9a1dad57c4e606de843f4d479581cc2cea20aeb3fb8996afcd52
6
+ metadata.gz: b6998f0a2d795797ca412008afb29eb8c7b8240eeda91a6f88cccae9b77c3250b1b8616d67d661bb82aab6f8d8c2b7f32b9cbc5e099fe1989eb7efd3d927d457
7
+ data.tar.gz: a4240ac375f5488dc3b312f19d0f556f8f1e78ed84189288de3fea8d82a3eda1586094d2ef45722450c95426ec007f400d926c327f5c2ddb8ad25415f2246187
data/CHANGELOG.md CHANGED
@@ -4,7 +4,124 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [UNRELEASED]
7
+ ## [2.0.0] - UNRELEASED
8
+
9
+ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replaced by an explicit signal-based runtime, immutable results, fiber-local chains, and a slimmer registry surface. See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide.
10
+
11
+ ### Added
12
+ - Add `CMDx::I18nProxy.tr` helper that translates a `reason` through the i18n layer when a matching key is present and falls back to the literal reason (or the `cmdx.reasons.unspecified` default when nil). `Fault#initialize` and `Pipeline` failure aggregation now route `result.reason` through it, so failure messages can be localized via translation keys without breaking literal-string reasons.
13
+ - Add `xid` correlation id on `Chain`, `Result`, and `Telemetry::Event`, sourced once per root execution from `CMDx.configuration.correlation_id` (a callable). Useful for threading external ids like Rails `request_id` through every task in a chain so they can be filtered together in logs/telemetry.
14
+ - Add `Context#as_json` / `Context#to_json` — JSON serialization delegating to `#to_h`
15
+ - Add `Errors#as_json` / `Errors#to_json` — JSON serialization delegating to `#to_h`
16
+ - Add `Result#as_json` / `Result#to_json` — JSON serialization delegating to the memoized `#to_h`
17
+ - Add `Input#as_json` / `Input#to_json` — JSON serialization delegating to `#to_h`
18
+ - Add `Output#as_json` / `Output#to_json` — JSON serialization delegating to `#to_h`
19
+ - Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`)
20
+ - Add `Signal#ok?` / `Signal#ko?` predicates
21
+ - Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result`
22
+ - Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`
23
+ - Add `CMDx::Deprecation` for declarative class-level deprecation (`:log`, `:warn`, `:error`, Symbol, Proc, callable) with `:if` / `:unless` gating
24
+ - Add `CMDx::Input` / `CMDx::Inputs` (replaces `Attribute` / `AttributeRegistry` / `AttributeValue`) supporting `:source`, `:default`, `:transform`, `:as`, `:prefix` / `:suffix`, and nested children via DSL block
25
+ - Add `CMDx::Output` / `CMDx::Outputs` for first-class declared outputs verified against `task.context` after `work`. Every declared output is implicitly required — there is no `:required` option. Surface is intentionally minimal: `:default` (literal/Symbol/Proc/`#call(task)`-able fallback applied when the value is `nil`, satisfies the implicit required check), `:if` / `:unless` guards, and `:description`. Coercion, transformation, validation, and nested children are intentionally not supported on outputs — use inputs (or compute in `work`) when you need any of those
26
+ - Add `CMDx::Util` single conditional-evaluation module (`evaluate`, `if?`, `unless?`, `satisfied?`) consolidating the v1 `Utils::*` modules
27
+ - Add `CMDx::I18nProxy` translation façade that delegates to `I18n` when available, otherwise loads the bundled YAML and percent-interpolates with memoization
28
+ - Add `CMDx::LoggerProxy` returning a per-task logger, `dup`-ing the base only when the task overrides `log_level` or `log_formatter`
29
+ - Add `around_execution` callback that wraps `before_validation`, `Task#work`, any `#rollback`, and `after_execution` in a single hook. Symbol callbacks receive the continuation as their block (use `yield`); Procs/blocks/callables receive `(task, continuation)` and must invoke `continuation.call`. Multiple hooks nest in declaration order; failure to invoke the continuation raises `CMDx::CallbackError`. Sits inside middlewares but outside the state/status callbacks, so it's the right place for symmetric concerns (transactions, instrumentation, per-task logging context) without bolting them onto `middlewares.register`
30
+ - Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError`, `CallbackError`
31
+ - Add `Task#work` abstract method (raises `ImplementationError` when not defined)
32
+ - Add `Task#rollback` lifecycle hook, auto-invoked by Runtime on failed results when defined; surfaced via `Result#rolled_back?` and the `:task_rolled_back` event
33
+ - Add saga-style pipeline rollback: when a `Workflow` halts, `Pipeline` walks every previously executed task instance whose result is `success?` in reverse execution order and invokes `#rollback` (when defined), then flips that result's `rolled_back?` to `true`. Skipped tasks are excluded; the failing task is rolled back by Runtime and not re-invoked. Exceptions raised inside a compensator propagate — handling them is the developer's responsibility. Applies across groups, within `continue_on_failure: true` groups, and to `:parallel` groups (compensators see the per-task `deep_dup`'d context)
34
+ - Add `Task#success!` for signaling a successful halt, joining `skip!` / `fail!` / `throw!`
35
+ - Add `Task.execute` / `Task.execute!` as the execution entry points (aliased as `call` / `call!` for backward compatibility)
36
+ - Add `Task#execute(strict:)` instance method (aliased as `#call`)
37
+ - Add `Result#on(:success, :failed, ...)` chainable predicate-dispatch helper
38
+ - Add `Result#deconstruct` / `Result#deconstruct_keys` for pattern matching; `deconstruct` returns `#to_h.to_a` pairs and `deconstruct_keys(keys)` slices `#to_h` (`nil` returns the full hash)
39
+ - Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#index`, `Result#root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias
40
+ - Add `Signal#origin` / `Result#origin` — upstream `Result` a signal/result was echoed from (`nil` for locally originated failures); set by `Task#throw!`, `Pipeline` when propagating workflow failures, and `Runtime` when rescuing a `Fault` inside `work`
41
+ - Add `Chain#unshift`, `Chain#root`, `Chain#state`, `Chain#status`, `Chain#last`, `Chain#freeze`; Runtime `unshift`s the root result (so `chain.root` and `chain[0]` point to the outermost task) and freezes the chain on root teardown
42
+ - Add `Fault.for?(*tasks)`, `Fault.reason?(reason)`, and `Fault.matches?(&block)` anonymous matcher subclasses suitable for `rescue`
43
+ - Add `include Enumerable` to `Errors`, `Chain`, and `Context`, exposing `map`, `select`, `find`, `include?`, `to_a`, `any?`, `all?`, `group_by`, `partition`, etc.
44
+ - Add `Set`-backed deduping per key on `Errors`, plus `keys`, `each_key`, `each_value`, `count`, `delete`, `clear`, `full_messages`, `to_hash(full)`
45
+ - Add `Context#keys`, `values`, `empty?`, `size`, `delete`, `clear`, `eql?` / `==`, `hash`, `deep_dup`, `respond_to_missing?`, and `Context#merge` that accepts any context-like object
46
+ - Add `Coercions::Coerce` and `Validators::Validate` inline-callable handlers for `:coerce` / `:validate` hash entries; generic callables receive `(value, task)`, Symbol and Proc handlers still resolve against the task
47
+ - Add `Configuration#backtrace_cleaner` and `Configuration#telemetry`
48
+ - Add `Configuration#log_exclusions` (defaults to `[]`) and matching `Settings#log_exclusions` override — an array of `Result#to_h` keys to strip from the lifecycle log entry (e.g. `[:context, :metadata]`). When empty, `Runtime` logs the `Result` as before; otherwise it logs `result.to_h.except(*exclusions)`. Other consumers (telemetry, return values) see the full result
49
+ - Add `Configuration#strict_context` (defaults to `false`) and matching `Settings#strict_context` override, toggling `Context#strict`; when enabled, unknown dynamic reads (`ctx.missing`) raise `NoMethodError` instead of returning `nil` — `[]`, `fetch`, `dig`, `key?`, and `?` predicates stay lenient
50
+ - Add `CMDx.reset_configuration!` which clears global registry ivars on `Task` for clean test setup/teardown; subclasses that already cloned their registries are unaffected
51
+ - Add `:if` / `:unless` gates to `Callbacks#register` (Symbol, Proc, or any `#call`-able); per-event DSL helpers (`before_execution`, `on_success`, etc.) forward the options through
52
+ - Add `:if` / `:unless` gates to `Middlewares#register` (Symbol, Proc, or any `#call`-able); evaluated per task in `Middlewares#process` — skipped middlewares are bypassed and the chain continues
53
+ - Add `:if` / `:unless` gates to `Retry` / `Task.retry_on`; gate receives `(task, error, attempt)` and, when falsy, re-raises the exception instead of retrying (no further wait). Adds `Retry#condition_if` / `Retry#condition_unless` readers
54
+ - Add `Context#deconstruct` / `Context#deconstruct_keys` for pattern matching
55
+ - Add `Errors#deconstruct` / `Errors#deconstruct_keys` for pattern matching
56
+ - Add `:executor` option to parallel task groups (`Workflow.tasks ..., strategy: :parallel, executor: :threads | :fibers | #call`); `:threads` is the default and preserves current behavior, `:fibers` dispatches via `Fiber.schedule` bounded by `:pool_size` (requires a `Fiber.scheduler` such as the `async` gem's — raises `RuntimeError` when none is installed), and a user-supplied callable matching `call(jobs:, concurrency:, on_job:)` is accepted. Unknown symbols raise `ArgumentError`
57
+ - Add `:merger` option to parallel task groups controlling how successful sibling contexts fold back into the workflow context: `:last_write_wins` (default, matches previous behavior), `:deep_merge` (recursive over `Hash` values), `:no_merge` (workflow context left untouched), or a callable `call(workflow_context, result)`. Merging always walks successful results in declaration order. Unknown symbols raise `ArgumentError`
58
+ - Add `CMDx::Executors` registry (built-ins: `:threads` → `Executors::Thread`, `:fibers` → `Executors::Fiber`) and `CMDx::Mergers` registry (built-ins: `:last_write_wins`, `:deep_merge`, `:no_merge`) exposed on `Configuration#executors` / `#mergers` and per-task via `Task.executors` / `Task.mergers` (dup-on-inherit); `Task.register(:executor, ...)` and `Task.register(:merger, ...)` (plus matching `deregister`) let apps plug in custom dispatch/merge strategies resolvable by name from `:executor` / `:merger`
59
+ - Add `Context#deep_merge` — in-place recursive `Hash`-value merge; scalar-vs-hash collisions follow last-write-wins. Used by the `:deep_merge` parallel merge strategy but also available directly
60
+ - Add `:linear`, `:fibonacci`, and `:decorrelated_jitter` built-in retry jitter strategies on `Retry` / `Task.retry_on`. `:linear` sleeps `delay * (attempt + 1)`; `:fibonacci` sleeps `delay * fib(attempt + 1)`; `:decorrelated_jitter` is the AWS-recommended stateful strategy `next ∈ [delay, prev_sleep * 3]` (threaded across attempts inside a single `process` call, falls back to `delay` when no prior sleep). `Retry#wait` now accepts an optional `prev_delay` argument and returns the computed delay so callers can thread state
61
+ - Add `CMDx::Retriers` registry (built-ins: `:exponential`, `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, `:decorrelated_jitter`) exposed on `Configuration#retriers` and per-task via `Task.retriers` (dup-on-inherit). `Task.register(:retrier, ...)` / `deregister(:retrier, ...)` let apps plug in custom jitter strategies resolvable by name from `:jitter`. Strategies are pure callables matching `call(attempt, delay, prev_delay)`. Symbols not present in the registry still fall through to task instance methods, preserving existing `jitter: :method_name` semantics
62
+ - Add `CMDx::Deprecators` registry (built-ins: `:log`, `:warn`, `:error`) exposed on `Configuration#deprecators` and per-task via `Task.deprecators` (dup-on-inherit). `Task.register(:deprecator, ...)` / `deregister(:deprecator, ...)` let apps plug in custom deprecation actions resolvable by name from `Task.deprecation`. Actions are callables matching `call(task)`. Symbols not present in the registry still fall through to task instance methods, preserving existing `deprecation :method_name` semantics. The built-in `:log`, `:warn`, `:error` arms are now strategy modules under `lib/cmdx/deprecators/` instead of inline branches in `Deprecation#execute`
63
+
64
+ ### Changed
65
+ - **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases)
66
+ - **BREAKING**: `Result` is now frozen and read-only; all state lives in the embedded `Signal`, built once during `Runtime#finalize_result`
67
+ - **BREAKING**: Move `STATES` / `STATUSES` constants and the `initialized` / `executing` / `executed!` transitions from `Result` to `Signal::STATES` / `Signal::STATUSES` (only `complete` / `interrupted` and `success` / `skipped` / `failed` remain)
68
+ - **BREAKING**: Halt mechanism uses `catch(Signal::TAG)` + `throw` instead of mutating result state; `success!` / `skip!` / `fail!` / `throw!` are now private `Task` instance methods (no longer delegated through `Result`) and raise `FrozenError` when called after teardown
69
+ - **BREAKING**: `Chain` is now fiber-local (was thread-local), keyed on `Fiber[:cmdx_chain]`, with internal `Mutex` on `push` / `unshift`; root Runtime clears the chain on teardown
70
+ - **BREAKING**: `Result#chain` now returns the owning `Chain` object directly instead of its results array (use `result.chain.to_a` / `result.chain.results`, or iterate via `Chain`'s new Enumerable methods)
71
+ - **BREAKING**: Drive `Result#caused_failure` / `threw_failure` / `caused_failure?` / `thrown_failure?` off `Signal#origin` instead of `signal.cause`; `caused_failure` walks `origin` recursively to the originating leaf, `threw_failure` returns `origin || self`, `caused_failure?` is true when the result originated the failure chain, `thrown_failure?` is true when the result re-threw an upstream failure
72
+ - Generated input accessors are now plain instance methods backed by `@_input_<name>` ivars set during input resolution; outputs have no accessors and are read/written directly on `task.context`
73
+ - `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:continue_on_failure`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError`
74
+ - `Pipeline` gains a `:parallel` strategy with `:pool_size` (replacing the removed `Parallelizer`); parallel workers share the parent fiber's chain, each get a `deep_dup`-ed context, successful child contexts are merged back into the workflow's context, and the first failed result is echoed via `throw!` to halt the pipeline. By default pending tasks are cancelled on the first failure (in-flight tasks still finish and successful contexts still merge); opt into batch semantics with `:continue_on_failure => true` to run every task to completion and aggregate failures into the workflow's `errors` (keys namespaced as `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons). `:continue_on_failure` works for both `:sequential` and `:parallel` groups.
75
+ - `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.executors`, `Task.mergers`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace
76
+ - `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `log_exclusions`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration`
77
+ - `Context.build` accepts anything that responds to `#context` (e.g. another `Task`), unwraps repeatedly, and only re-wraps frozen contexts; symbolizes hash keys via `#to_hash` / `#to_h`
78
+ - `Retry` becomes a value object; `Task.retry_on` accumulates exceptions and options across the inheritance chain via `Retry#build`; supports built-in jitter strategies (`:exponential`, `:half_random`, `:full_random`, `:bounded_random`) plus Symbol / Proc / callable; retry wraps `work` only (input resolution and output verification run once, outside the retry loop)
79
+ - All registries (`Callbacks`, `Middlewares`, `Coercions`, `Validators`, `Telemetry`, `Inputs`, `Outputs`) implement `initialize_copy` for cheap copy-on-write inheritance; `register` / `deregister` validate types up-front and raise `ArgumentError` on misuse
80
+ - `Coercions#coerce` returns a `Coercions::Failure` sentinel with an i18n message recorded on `task.errors`; when multiple declared coercion rules match none (and none were inline), an aggregated `cmdx.coercions.into_any` message is reported instead of the per-rule messages
81
+ - `Validators#validate` records a message on `task.errors` for each failed rule (the individual built-in validators return `Validators::Failure`)
82
+ - Extend `Validators::Numeric` and `Validators::Length` with `:gt` / `:lt` (strict comparison, with `:gt_message` / `:lt_message` overrides and `cmdx.validators.{numeric,length}.{gt,lt}` i18n keys), plus `:gte` / `:lte` / `:eq` / `:not_eq` aliases that normalize to `:min` / `:max` / `:is` / `:is_not`
83
+ - `Fault#initialize` takes a single `Result`; `task`, `context`, and `chain` delegate to it; `Runtime` raises `Fault.new(@result.caused_failure)` so `fault.task` always points at the originating leaf (including in workflows and nested `execute!` chains)
84
+ - `Runtime` finalizes the `Result` before `raise_signal!` so the `Fault` it raises always carries a fully-built `Result`
85
+ - `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, tid: }` hash, or `nil` for locally originated failures)
86
+ - **BREAKING**: `Result#deconstruct` now returns `#to_h.to_a` (array of `[key, value]` pairs) instead of the fixed `[type, task, state, status, reason, metadata, cause, origin]` tuple — update any array-pattern matches to use find-patterns (`in [*, [:status, "failed"], *]`)
87
+ - `Result#deconstruct_keys` now honors its `keys` argument — `nil` returns the full `#to_h`, a key list slices it; previously it always returned the full hash
88
+ - `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly must map `.first` to recover the callable
89
+ - Slim the locale file: remove `attributes.undefined`, `coercions.unknown`, `faults.invalid`, `faults.unspecified`, `returns.*`; rename `returns.missing` → `outputs.missing`; add `nil_value` to `length` / `numeric` validator messages
90
+ - Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes
91
+ - Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `strict_context`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter`
92
+ - `Configuration#log_level` and `Configuration#log_formatter` now default to `nil` — treat them as optional overrides on top of `config.logger` (the default `Logger` still carries `Logger::INFO` + `LogFormatters::Line.new`). `LoggerProxy` only `dup`s the logger when a non-nil override differs from the logger's own level/formatter, so swapping `config.logger` no longer requires also clearing these fields
93
+
94
+ ### Removed
95
+ - **BREAKING**: Remove `Result::STATES = [INITIALIZED, EXECUTING, COMPLETE, INTERRUPTED]`, the `executed!` / `executing!` transitions, and the `executed?` / `initialized?` / `executing?` predicates
96
+ - **BREAKING**: Remove `Task#id`, `Task#result`, `Task#chain` direct accessors — read these off the `Result` returned by `execute`
97
+ - **BREAKING**: Remove `Result#threw_failure?` predicate (`result.thrown_failure?` remains, with semantics flipped — true when the result re-threw an upstream failure)
98
+ - **BREAKING**: Remove `Result#chain_id` — read it off the chain: `result.cid`
99
+ - **BREAKING**: `Result#to_h` no longer produces nested `caused_failure` / `threw_failure` hashes; failure references render as `{ task:, tid: }` and `to_s` formats them as `<TaskClass uuid>`
100
+ - Remove `CMDx::Executor` (replaced by `CMDx::Runtime`)
101
+ - Remove `CMDx::Attribute`, `CMDx::AttributeRegistry`, `CMDx::AttributeValue` (replaced by `Input` / `Inputs` and `Output` / `Outputs`)
102
+ - Remove `CMDx::Resolver` (value resolution is owned by `Input#resolve`)
103
+ - Remove `CMDx::Identifier` (Runtime / Chain use `SecureRandom.uuid_v7` directly)
104
+ - Remove `CMDx::Locale` (superseded by `I18nProxy`)
105
+ - Remove `CMDx::Deprecator` (superseded by `Deprecation` declared per task class)
106
+ - Remove `CMDx::Parallelizer` (parallelism now lives in `Pipeline#run_parallel`)
107
+ - Remove `CMDx::CallbackRegistry`, `CMDx::MiddlewareRegistry`, `CMDx::CoercionRegistry`, `CMDx::ValidatorRegistry` (replaced by the simpler `Callbacks`, `Middlewares`, `Coercions`, `Validators` plain classes)
108
+ - Remove `CMDx::Utils::Call`, `CMDx::Utils::Condition`, `CMDx::Utils::Format`, `CMDx::Utils::Normalize`, `CMDx::Utils::Wrap` (collapsed into `CMDx::Util`)
109
+ - Remove built-in `CMDx::Middlewares::Correlate`, `CMDx::Middlewares::Runtime`, `CMDx::Middlewares::Timeout` — register equivalents on `config.middlewares` if needed
110
+ - Remove `CMDx::Exception` file — `CMDx::Error` / `Exception` and friends are now defined in `lib/cmdx.rb`
111
+ - Remove Zeitwerk autoloading (replaced by explicit `require_relative` ordering in `lib/cmdx.rb`); drop `forwardable`, `pathname`, `timeout`, and `zeitwerk` requires
112
+ - Remove `CMDx.gem_path` top-level helper
113
+ - Remove `Configuration#task_breakpoints`, `Configuration#workflow_breakpoints`, `Configuration#freeze_results`, `Configuration#exception_handler`, and the `SKIP_CMDX_FREEZING` env var — failure halting is now intrinsic to Runtime via `Signal` and `execute!` strict mode
114
+ - Remove `Chain#dry_run?` and the `dry_run:` context flag
115
+
116
+ ### Migration notes
117
+
118
+ See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. At minimum:
119
+
120
+ - Rename `def call` → `def work`; `MyTask.call(ctx)` still works (aliased) but prefer `MyTask.execute(ctx)`
121
+ - Replace `register :attribute, ...` with `required :name, ...` / `optional :name, ...` / `output :name, ...`
122
+ - Replace `result.chain_id` with `result.cid`
123
+ - Replace `task.id` / `task.result` / `task.chain` with reads off the `Result` returned by `execute`
124
+ - Subscribe to lifecycle observability via `config.telemetry.subscribe(:task_executed) { |event| ... }` instead of the removed `Runtime` / `Correlate` middlewares
8
125
 
9
126
  ## [1.21.0] - 2026-04-09
10
127
 
data/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  [Request Feature](https://github.com/drexed/cmdx/issues) ·
15
15
  [AI Skills](https://github.com/drexed/cmdx/blob/main/skills) ·
16
16
  [llms.txt](https://drexed.github.io/cmdx/llms.txt) ·
17
- [llms-full.txt](https://drexed.github.io/cmdx/llms-full.txt) ·
17
+ [llms-full.txt](https://drexed.github.io/cmdx/llms-full.txt)
18
18
 
19
19
  <img alt="Version" src="https://img.shields.io/gem/v/cmdx">
20
20
  <img alt="Build" src="https://github.com/drexed/cmdx/actions/workflows/ci.yml/badge.svg">
@@ -26,14 +26,26 @@
26
26
  Say goodbye to messy service objects. CMDx helps you design business logic with clarity and consistency—build faster, debug easier, and ship with confidence.
27
27
 
28
28
  > [!NOTE]
29
- > [Documentation](https://drexed.github.io/cmdx/getting_started/) reflects the latest code on `main`. For version-specific documentation, please refer to the `docs/` directory within that version's tag.
29
+ > [Documentation](https://drexed.github.io/cmdx/getting_started/) reflects the latest code on `main`. For version-specific documentation, refer to the `docs/` directory within that version's tag.
30
+
31
+ ## What you get
32
+
33
+ - **Standardized task contract** — typed inputs, declared outputs, explicit halts
34
+ - **Type system** — 13 coercers, 7 validators, all pluggable
35
+ - **Built-in flow control** — `skip!` / `fail!` / `throw!` with structured metadata
36
+ - **Retries and faults** — declarative `retry_on` with configurable jitter
37
+ - **Middleware and callbacks** — wrap the lifecycle without touching `work`
38
+ - **Observability** — structured logs and telemetry, no extra instrumentation
39
+ - **Composable workflows** — chain tasks into larger processes
40
+
41
+ See the [feature comparison](https://drexed.github.io/cmdx/comparison/) for how CMDx stacks up against other service-object gems.
30
42
 
31
43
  ## Requirements
32
44
 
33
- - Ruby: MRI 3.1+ or JRuby 9.4+
34
- - Dependencies: None
45
+ - Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release
46
+ - Runtime dependencies: `bigdecimal` and `logger` (stdlib only — no ActiveSupport required)
35
47
 
36
- Rails support is built-in, but it's framework-agnostic at its core.
48
+ Rails support is built-in, but CMDx is framework-agnostic at its core.
37
49
 
38
50
  ## Installation
39
51
 
@@ -45,24 +57,23 @@ bundle add cmdx
45
57
 
46
58
  ## Quick Example
47
59
 
48
- Build powerful business logic in four simple steps:
60
+ CMDx organizes business logic around the **CERO** pattern (pronounced "zero"): **Compose**, **Execute**, **React**, **Observe**.
49
61
 
50
62
  ### 1. Compose
51
63
 
52
- ```ruby
53
- # Full-featured task example
54
- # See docs for minimum viable task examples
64
+ Declare inputs, outputs, retries, and callbacks, then implement `work`.
55
65
 
66
+ ```ruby
56
67
  class AnalyzeMetrics < CMDx::Task
57
- register :middleware, CMDx::Middlewares::Correlate, id: -> { Current.request_id }
68
+ retry_on Net::ReadTimeout, limit: 3, jitter: :exponential
58
69
 
59
70
  on_success :track_analysis_completion!
60
71
 
61
- required :dataset_id, type: :integer, numeric: { min: 1 }
72
+ required :dataset_id, coerce: :integer, numeric: { min: 1 }
62
73
 
63
74
  optional :analysis_type, default: "standard"
64
75
 
65
- returns :result, :analyzed_at
76
+ output :result, :analyzed_at
66
77
 
67
78
  def work
68
79
  if dataset.nil?
@@ -91,6 +102,8 @@ end
91
102
 
92
103
  ### 2. Execute
93
104
 
105
+ Every invocation returns a `Result`. Inputs are coerced and validated, exceptions are captured, outputs are verified, and the outcome is logged — automatically.
106
+
94
107
  ```ruby
95
108
  result = AnalyzeMetrics.execute(
96
109
  dataset_id: 123,
@@ -98,39 +111,39 @@ result = AnalyzeMetrics.execute(
98
111
  )
99
112
  ```
100
113
 
114
+ Use `execute!` instead when you want failures to raise a `Fault`.
115
+
101
116
  ### 3. React
102
117
 
118
+ Branch on the result's status and read values, reasons, or metadata from it.
119
+
103
120
  ```ruby
104
121
  if result.success?
105
122
  puts "Metrics analyzed at #{result.context.analyzed_at}"
106
123
  elsif result.skipped?
107
- puts "Skipping analyzation due to: #{result.reason}"
124
+ puts "Skipped: #{result.reason}"
108
125
  elsif result.failed?
109
- puts "Analyzation failed due to: #{result.reason} with code #{result.metadata[:code]}"
126
+ puts "Failed: #{result.reason} (code #{result.metadata[:code]})"
110
127
  end
111
128
  ```
112
129
 
113
130
  ### 4. Observe
114
131
 
132
+ Every execution emits a structured log line with the chain id, task identity, state, status, reason, metadata, and duration — enough to correlate nested tasks and reconstruct what happened.
133
+
115
134
  ```log
116
- I, [2022-07-17T18:42:37.000000 #3784] INFO -- CMDx:
117
- index=1 chain_id="018c2b95-23j4-2kj3-32kj-3n4jk3n4jknf" type="Task" class="SendAnalyzedEmail" state="complete" status="success" metadata={runtime: 347}
135
+ I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d2-..." index=1 root=false type="Task" task=SendAnalyzedEmail id="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=34.7 tags=[]
118
136
 
119
- I, [2022-07-17T18:43:15.000000 #3784] INFO -- CMDx:
120
- index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="AnalyzeMetrics" state="complete" status="success" metadata={runtime: 187}
137
+ I, [2026-04-19T18:43:15.000000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d2-..." index=0 root=true type="Task" task=AnalyzeMetrics id="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=187.4 tags=[]
121
138
  ```
122
139
 
123
- Ready to dive in? Check out the [Getting Started](https://drexed.github.io/cmdx/getting_started/) guide to learn more.
140
+ Ready to dive in? Check out the [Getting Started](https://drexed.github.io/cmdx/getting_started/) guide.
124
141
 
125
142
  ## Ecosystem
126
143
 
144
+ - [cmdx-i18n](https://github.com/drexed/cmdx-i18n) - 85+ translations
127
145
  - [cmdx-rspec](https://github.com/drexed/cmdx-rspec) - RSpec test matchers
128
146
 
129
- For backwards compatibility of certain functionality:
130
-
131
- - [cmdx-i18n](https://github.com/drexed/cmdx-i18n) - 85+ translations, `v1.5.0` - `v1.6.2`
132
- - [cmdx-parallel](https://github.com/drexed/cmdx-parallel) - Parallel workflow tasks, `v1.6.1` - `v1.6.2`
133
-
134
147
  ## Contributing
135
148
 
136
149
  Bug reports and pull requests are welcome at <https://github.com/drexed/cmdx>. We're committed to fostering a welcoming, collaborative community. Please follow our [code of conduct](CODE_OF_CONDUCT.md).
data/lib/cmdx/.DS_Store CHANGED
Binary file
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CMDx
4
+ # Registry of lifecycle callbacks invoked by Runtime. Callbacks can be
5
+ # method names (Symbols dispatched via `task.send`), blocks/Procs
6
+ # (`instance_exec`'d on the task), or arbitrary `#call` objects.
7
+ #
8
+ # Each registration may carry `:if` / `:unless` gates (Symbol, Proc, or
9
+ # any `#call`-able). Gates are evaluated against the task before the
10
+ # callback is invoked; non-passing gates skip the callback silently.
11
+ class Callbacks
12
+
13
+ # Callback event names Runtime dispatches.
14
+ EVENTS = Set[
15
+ :before_validation,
16
+ :before_execution,
17
+ :around_execution,
18
+ :after_execution,
19
+ :on_complete,
20
+ :on_interrupted,
21
+ :on_success,
22
+ :on_skipped,
23
+ :on_failed,
24
+ :on_ok,
25
+ :on_ko
26
+ ].freeze
27
+
28
+ attr_reader :registry
29
+
30
+ def initialize
31
+ @registry = {}
32
+ end
33
+
34
+ # @param source [Callbacks] registry to duplicate
35
+ # @return [void]
36
+ def initialize_copy(source)
37
+ @registry = source.registry.transform_values(&:dup)
38
+ end
39
+
40
+ # Adds a callback for `event`.
41
+ #
42
+ # @param event [Symbol] one of {EVENTS}
43
+ # @param callable [Symbol, #call, nil] method name or callable; pass either this or a block
44
+ # @param block [#call, nil] callback body when `callable` is omitted
45
+ # @param options [Hash{Symbol => Object}]
46
+ # @option options [Symbol, Proc, #call] :if gate that must evaluate truthy
47
+ # @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy
48
+ # @return [Callbacks] self for chaining
49
+ # @raise [ArgumentError] when both `callable` and a block are given, when the
50
+ # callback type is invalid, or when `event` is unknown
51
+ # @yield the callback body
52
+ def register(event, callable = nil, **options, &block)
53
+ callback = callable || block
54
+
55
+ if callable && block
56
+ raise ArgumentError, "provide either a callable or a block, not both"
57
+ elsif !callback.is_a?(Symbol) && !callback.respond_to?(:call)
58
+ raise ArgumentError, "callback must be a Symbol or respond to #call"
59
+ elsif !EVENTS.include?(event)
60
+ raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}"
61
+ end
62
+
63
+ (registry[event] ||= []) << [callback, options.freeze]
64
+ self
65
+ end
66
+
67
+ # Drops callbacks registered for `event`. With no `callable`, removes
68
+ # every callback for `event`. With a `callable`, removes only the
69
+ # entries whose callback matches `callable` by `==` (works for Symbol
70
+ # method names, classes/modules, and any callable held by reference).
71
+ # When the last entry for `event` is removed, the key itself is dropped.
72
+ #
73
+ # @param event [Symbol] one of {EVENTS}
74
+ # @param callable [Symbol, #call, nil] optional specific callback to remove
75
+ # @return [Callbacks] self for chaining
76
+ # @raise [ArgumentError] when `event` is unknown
77
+ def deregister(event, callable = nil)
78
+ raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" unless EVENTS.include?(event)
79
+
80
+ if callable.nil?
81
+ registry.delete(event)
82
+ elsif (entries = registry[event])
83
+ entries.reject! { |cb, _opts| cb == callable }
84
+ registry.delete(event) if entries.empty?
85
+ end
86
+
87
+ self
88
+ end
89
+
90
+ # @return [Boolean]
91
+ def empty?
92
+ registry.empty?
93
+ end
94
+
95
+ # @return [Integer] number of distinct events with callbacks
96
+ def size
97
+ registry.size
98
+ end
99
+
100
+ # @return [Integer] total callbacks across all events
101
+ def count
102
+ registry.each_value.sum(&:size)
103
+ end
104
+
105
+ # Fires each callback registered for `event` against `task`. Skips any
106
+ # callback whose `:if`/`:unless` gates fail.
107
+ #
108
+ # @param event [Symbol]
109
+ # @param task [Task]
110
+ # @return [void]
111
+ # @raise [ArgumentError] when a callback is neither a Symbol nor responds to `#call`
112
+ def process(event, task)
113
+ callbacks = registry[event]
114
+ return if callbacks.nil? || callbacks.empty?
115
+
116
+ callbacks.each do |callable, options|
117
+ next unless Util.satisfied?(options[:if], options[:unless], task)
118
+
119
+ invoke(callable, task)
120
+ end
121
+ end
122
+
123
+ # Wraps `block` with every callback registered for `event` as a nested
124
+ # chain (outer-first by declaration order). Each callback receives a
125
+ # continuation it must invoke exactly once: Symbol callbacks get it as
126
+ # their block (use `yield`); Procs/blocks are `instance_exec`'d on the
127
+ # task with `(task, continuation)`; arbitrary callables receive
128
+ # `(task, continuation)`. Gates skip individual links silently while
129
+ # still running the body.
130
+ #
131
+ # @param event [Symbol]
132
+ # @param task [Task]
133
+ # @param body [#call] inner continuation (Runtime lifecycle body)
134
+ # @yield the innermost link — the lifecycle body to wrap
135
+ # @return [void]
136
+ # @raise [CallbackError] when a callback fails to invoke its continuation
137
+ def around(event, task, &body)
138
+ callbacks = registry[event]
139
+ return yield if callbacks.nil? || callbacks.empty?
140
+
141
+ callbacks.reverse_each.reduce(body) do |succ, (callable, options)|
142
+ lambda do
143
+ next succ.call unless Util.satisfied?(options[:if], options[:unless], task)
144
+
145
+ called = false
146
+ cont = lambda do
147
+ called = true
148
+ succ.call
149
+ end
150
+
151
+ invoke(callable, task, cont, &cont)
152
+
153
+ called || raise(CallbackError, "#{event} callback did not invoke its continuation")
154
+ end
155
+ end.call
156
+ end
157
+
158
+ private
159
+
160
+ # @param callable [Symbol, Proc, #call]
161
+ # @param task [Task]
162
+ # @param extras [Array<Object>] extra args after `task` for continuation-style callbacks
163
+ # @return [Object] the callback's return value
164
+ # @raise [ArgumentError] when `callable` is invalid
165
+ def invoke(callable, task, *extras, &)
166
+ case callable
167
+ when Symbol
168
+ task.send(callable, &)
169
+ when Proc
170
+ task.instance_exec(task, *extras, &callable)
171
+ else
172
+ return callable.call(task, *extras) if callable.respond_to?(:call)
173
+
174
+ raise ArgumentError, "callback must be a Symbol, Proc, or respond to #call"
175
+ end
176
+ end
177
+
178
+ end
179
+ end