tasker-rb 0.1.3-arm64-darwin

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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +55 -0
  3. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  4. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  5. data/lib/tasker_core/bootstrap.rb +394 -0
  6. data/lib/tasker_core/client.rb +165 -0
  7. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  8. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  9. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  10. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  11. data/lib/tasker_core/domain_events.rb +43 -0
  12. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  13. data/lib/tasker_core/errors/common.rb +305 -0
  14. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  15. data/lib/tasker_core/errors.rb +4 -0
  16. data/lib/tasker_core/event_bridge.rb +330 -0
  17. data/lib/tasker_core/handlers.rb +159 -0
  18. data/lib/tasker_core/internal.rb +31 -0
  19. data/lib/tasker_core/logger.rb +234 -0
  20. data/lib/tasker_core/models.rb +337 -0
  21. data/lib/tasker_core/observability/types.rb +158 -0
  22. data/lib/tasker_core/observability.rb +292 -0
  23. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  24. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  25. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  26. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  27. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  28. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  29. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  30. data/lib/tasker_core/registry/resolvers.rb +42 -0
  31. data/lib/tasker_core/registry.rb +12 -0
  32. data/lib/tasker_core/step_handler/api.rb +48 -0
  33. data/lib/tasker_core/step_handler/base.rb +354 -0
  34. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  35. data/lib/tasker_core/step_handler/decision.rb +53 -0
  36. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  37. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  38. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  39. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  40. data/lib/tasker_core/subscriber.rb +212 -0
  41. data/lib/tasker_core/task_handler/base.rb +254 -0
  42. data/lib/tasker_core/tasker_rb.bundle +0 -0
  43. data/lib/tasker_core/template_discovery.rb +181 -0
  44. data/lib/tasker_core/test_environment.rb +313 -0
  45. data/lib/tasker_core/tracing.rb +166 -0
  46. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  47. data/lib/tasker_core/types/client_types.rb +145 -0
  48. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  49. data/lib/tasker_core/types/error_types.rb +72 -0
  50. data/lib/tasker_core/types/simple_message.rb +151 -0
  51. data/lib/tasker_core/types/step_context.rb +328 -0
  52. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  53. data/lib/tasker_core/types/step_message.rb +112 -0
  54. data/lib/tasker_core/types/step_types.rb +207 -0
  55. data/lib/tasker_core/types/task_template.rb +240 -0
  56. data/lib/tasker_core/types/task_types.rb +148 -0
  57. data/lib/tasker_core/types.rb +132 -0
  58. data/lib/tasker_core/version.rb +13 -0
  59. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  60. data/lib/tasker_core/worker/event_poller.rb +224 -0
  61. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  62. data/lib/tasker_core.rb +161 -0
  63. metadata +292 -0
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module DomainEvents
5
+ # TAS-65: Base class for custom domain event publishers
6
+ #
7
+ # Domain event publishers transform step execution results into business events.
8
+ # They are declared in task template YAML via the `publisher:` field and registered
9
+ # at bootstrap time.
10
+ #
11
+ # @example Creating a custom publisher
12
+ # class PaymentEventPublisher < TaskerCore::DomainEvents::BasePublisher
13
+ # def name
14
+ # 'PaymentEventPublisher'
15
+ # end
16
+ #
17
+ # def transform_payload(step_result, event_declaration)
18
+ # # Extract business-specific payload from step result
19
+ # {
20
+ # payment_id: step_result[:result][:payment_id],
21
+ # amount: step_result[:result][:amount],
22
+ # currency: step_result[:result][:currency],
23
+ # status: step_result[:success] ? 'succeeded' : 'failed'
24
+ # }
25
+ # end
26
+ #
27
+ # def should_publish?(step_result, event_declaration)
28
+ # # Only publish for successful payments
29
+ # step_result[:success] && step_result[:result][:payment_id].present?
30
+ # end
31
+ # end
32
+ #
33
+ # @example Registering a custom publisher
34
+ # TaskerCore::DomainEvents::PublisherRegistry.instance.register(
35
+ # PaymentEventPublisher.new
36
+ # )
37
+ #
38
+ # @example YAML declaration
39
+ # steps:
40
+ # - name: process_payment
41
+ # publishes_events:
42
+ # - name: payment.processed
43
+ # publisher: PaymentEventPublisher
44
+ # delivery_mode: durable
45
+ # condition: success
46
+ #
47
+ class BasePublisher
48
+ attr_reader :logger
49
+
50
+ def initialize
51
+ @logger = TaskerCore::Logger.instance
52
+ end
53
+
54
+ # The publisher name used for registry lookup
55
+ # Must match the `publisher:` field in YAML
56
+ #
57
+ # @return [String] The publisher name
58
+ def name
59
+ raise NotImplementedError, "#{self.class} must implement #name"
60
+ end
61
+
62
+ # Transform step result into business event payload
63
+ #
64
+ # Override this to customize the event payload for your domain.
65
+ # The default implementation returns the step result as-is.
66
+ #
67
+ # @param step_result [Hash] The step execution result
68
+ # - :success [Boolean] Whether the step succeeded
69
+ # - :result [Hash] The step handler's return value
70
+ # - :metadata [Hash] Execution metadata
71
+ # @param event_declaration [Hash] The event declaration from YAML
72
+ # - :name [String] The event name (e.g., "order.processed")
73
+ # - :delivery_mode [String] "durable" or "fast"
74
+ # - :condition [String] "success", "failure", or "always"
75
+ # @param step_context [Hash] The step execution context
76
+ # - :task [Hash] Task information
77
+ # - :workflow_step [Hash] Workflow step information
78
+ # - :step_definition [Hash] Step definition from YAML
79
+ #
80
+ # @return [Hash] The business event payload to publish
81
+ def transform_payload(step_result, _event_declaration, _step_context = nil)
82
+ step_result[:result] || {}
83
+ end
84
+
85
+ # Determine if this event should be published
86
+ #
87
+ # Override this to add custom publishing conditions beyond the
88
+ # YAML `condition:` field. The YAML condition is evaluated first,
89
+ # then this method is called.
90
+ #
91
+ # @param step_result [Hash] The step execution result
92
+ # @param event_declaration [Hash] The event declaration from YAML
93
+ # @param step_context [Hash] The step execution context
94
+ #
95
+ # @return [Boolean] true if the event should be published
96
+ def should_publish?(_step_result, _event_declaration, _step_context = nil)
97
+ true
98
+ end
99
+
100
+ # Add additional metadata to the event
101
+ #
102
+ # Override this to add custom metadata fields to the event.
103
+ # Default returns empty hash.
104
+ #
105
+ # @param step_result [Hash] The step execution result
106
+ # @param event_declaration [Hash] The event declaration from YAML
107
+ # @param step_context [Hash] The step execution context
108
+ #
109
+ # @return [Hash] Additional metadata to merge into event metadata
110
+ def additional_metadata(_step_result, _event_declaration, _step_context = nil)
111
+ {}
112
+ end
113
+
114
+ # Hook called before publishing
115
+ #
116
+ # Override for pre-publish validation, logging, or metrics.
117
+ # Raise an exception to prevent publishing.
118
+ #
119
+ # @param event_name [String] The event name
120
+ # @param payload [Hash] The transformed payload
121
+ # @param metadata [Hash] The event metadata
122
+ def before_publish(event_name, _payload, _metadata)
123
+ logger.debug "Publishing event: #{event_name}"
124
+ end
125
+
126
+ # Hook called after successful publishing
127
+ #
128
+ # Override for post-publish logging, metrics, or cleanup.
129
+ #
130
+ # @param event_name [String] The event name
131
+ # @param payload [Hash] The transformed payload
132
+ # @param metadata [Hash] The event metadata
133
+ def after_publish(event_name, _payload, _metadata)
134
+ logger.debug "Event published: #{event_name}"
135
+ end
136
+
137
+ # Hook called if publishing fails
138
+ #
139
+ # Override for error handling, logging, or fallback behavior.
140
+ # Default logs the error but does not re-raise.
141
+ #
142
+ # @param event_name [String] The event name
143
+ # @param error [Exception] The error that occurred
144
+ # @param payload [Hash] The transformed payload
145
+ def on_publish_error(event_name, error, _payload)
146
+ logger.error "Failed to publish event #{event_name}: #{error.message}"
147
+ end
148
+
149
+ # ========================================================================
150
+ # CROSS-LANGUAGE STANDARD API (TAS-96)
151
+ # ========================================================================
152
+
153
+ # Cross-language standard: Publish an event with unified context
154
+ #
155
+ # This method coordinates the existing hooks (should_publish?, transform_payload,
156
+ # additional_metadata, before_publish, after_publish) into a single publish call.
157
+ #
158
+ # @param ctx [Hash] Step event context containing:
159
+ # - :event_name [String] The event name (e.g., "payment.processed")
160
+ # - :step_result [Hash] The step execution result
161
+ # - :event_declaration [Hash] The event declaration from YAML
162
+ # - :step_context [Hash, TaskerCore::Types::StepContext] Step execution context
163
+ # @return [Boolean] true if event was published, false if skipped
164
+ #
165
+ # @example Publishing with context
166
+ # ctx = {
167
+ # event_name: 'payment.processed',
168
+ # step_result: { success: true, result: { payment_id: '123' } },
169
+ # event_declaration: { name: 'payment.processed', delivery_mode: 'durable' },
170
+ # step_context: step_context
171
+ # }
172
+ # publisher.publish(ctx)
173
+ def publish(ctx)
174
+ event_name = ctx[:event_name]
175
+ step_result = ctx[:step_result]
176
+ event_declaration = ctx[:event_declaration] || {}
177
+ step_context = ctx[:step_context]
178
+
179
+ # Check publishing conditions
180
+ return false unless should_publish?(step_result, event_declaration, step_context)
181
+
182
+ # Transform payload
183
+ payload = transform_payload(step_result, event_declaration, step_context)
184
+
185
+ # Build metadata
186
+ base_metadata = {
187
+ publisher: name,
188
+ published_at: Time.now.utc.iso8601
189
+ }
190
+
191
+ # Add step context info if available
192
+ if step_context.respond_to?(:task_uuid)
193
+ base_metadata[:task_uuid] = step_context.task_uuid
194
+ base_metadata[:step_uuid] = step_context.step_uuid
195
+ base_metadata[:step_name] = step_context.step_name if step_context.respond_to?(:step_name)
196
+ base_metadata[:namespace] = step_context.namespace_name if step_context.respond_to?(:namespace_name)
197
+ end
198
+
199
+ metadata = base_metadata.merge(additional_metadata(step_result, event_declaration, step_context))
200
+
201
+ begin
202
+ # Pre-publish hook
203
+ before_publish(event_name, payload, metadata)
204
+
205
+ # Actual publishing is handled by the event router/bridge
206
+ # This method just prepares and validates the event
207
+ # Subclasses can override to perform actual publishing
208
+
209
+ # Post-publish hook
210
+ after_publish(event_name, payload, metadata)
211
+
212
+ true
213
+ rescue StandardError => e
214
+ on_publish_error(event_name, e, payload)
215
+ false
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module DomainEvents
5
+ # TAS-65: Base class for domain event subscribers
6
+ #
7
+ # Domain event subscribers handle business events published by steps.
8
+ # They subscribe to event patterns and receive events via the in-process
9
+ # event bus (for fast events) or can poll PGMQ (for durable events).
10
+ #
11
+ # @example Creating a subscriber for payment events
12
+ # class PaymentEventSubscriber < TaskerCore::DomainEvents::BaseSubscriber
13
+ # # Match all payment.* events
14
+ # subscribes_to 'payment.*'
15
+ #
16
+ # def handle(event)
17
+ # case event[:event_name]
18
+ # when 'payment.processed'
19
+ # notify_accounting(event[:business_payload])
20
+ # when 'payment.failed'
21
+ # alert_support(event[:business_payload])
22
+ # end
23
+ # end
24
+ #
25
+ # private
26
+ #
27
+ # def notify_accounting(payload)
28
+ # # Send to accounting system
29
+ # end
30
+ #
31
+ # def alert_support(payload)
32
+ # # Alert support team
33
+ # end
34
+ # end
35
+ #
36
+ # @example Creating a subscriber for metrics collection
37
+ # class MetricsSubscriber < TaskerCore::DomainEvents::BaseSubscriber
38
+ # # Match all events
39
+ # subscribes_to '*'
40
+ #
41
+ # def handle(event)
42
+ # StatsD.increment("domain_events.#{event[:event_name].gsub('.', '_')}")
43
+ # end
44
+ # end
45
+ #
46
+ # @example Registering and starting subscribers
47
+ # # In bootstrap
48
+ # subscribers = [
49
+ # PaymentEventSubscriber.new,
50
+ # MetricsSubscriber.new
51
+ # ]
52
+ #
53
+ # subscribers.each(&:start!)
54
+ #
55
+ class BaseSubscriber
56
+ class << self
57
+ # DSL method to declare event pattern subscriptions
58
+ #
59
+ # @param patterns [Array<String>] Event patterns to subscribe to
60
+ def subscribes_to(*patterns)
61
+ @patterns = patterns.flatten
62
+ end
63
+
64
+ # Get declared patterns
65
+ #
66
+ # @return [Array<String>]
67
+ def patterns
68
+ @patterns || ['*']
69
+ end
70
+ end
71
+
72
+ attr_reader :logger, :active
73
+
74
+ def initialize
75
+ @logger = TaskerCore::Logger.instance
76
+ @active = false
77
+ @subscriptions = []
78
+ end
79
+
80
+ # Start listening for events
81
+ def start!
82
+ return if @active
83
+
84
+ @active = true
85
+ poller = TaskerCore::Worker::InProcessDomainEventPoller.instance
86
+
87
+ self.class.patterns.each do |pattern|
88
+ # Subscribe to the poller with this subscriber's handler
89
+ poller.subscribe(pattern) do |event|
90
+ handle_event_safely(event)
91
+ end
92
+ @subscriptions << pattern
93
+ logger.info "#{self.class.name}: Subscribed to pattern '#{pattern}'"
94
+ end
95
+
96
+ logger.info "#{self.class.name}: Started with #{@subscriptions.size} subscriptions"
97
+ end
98
+
99
+ # Stop listening for events
100
+ def stop!
101
+ return unless @active
102
+
103
+ @active = false
104
+ poller = TaskerCore::Worker::InProcessDomainEventPoller.instance
105
+
106
+ @subscriptions.each do |pattern|
107
+ poller.unsubscribe(pattern)
108
+ end
109
+ @subscriptions.clear
110
+
111
+ logger.info "#{self.class.name}: Stopped"
112
+ end
113
+
114
+ # Check if subscriber is active
115
+ def active?
116
+ @active
117
+ end
118
+
119
+ # Handle a domain event
120
+ #
121
+ # Subclasses MUST implement this method.
122
+ #
123
+ # @param event [Hash] The domain event
124
+ # - :event_name [String] The event name (e.g., "order.processed")
125
+ # - :business_payload [Hash] The business data from the step
126
+ # - :metadata [Hash] Event metadata (task_uuid, step_uuid, correlation_id, etc.)
127
+ # - :execution_result [Hash] The step execution result
128
+ def handle(event)
129
+ raise NotImplementedError, "#{self.class} must implement #handle"
130
+ end
131
+
132
+ # Hook called before handling an event
133
+ #
134
+ # Override for pre-processing, validation, or filtering.
135
+ # Return false to skip handling this event.
136
+ #
137
+ # @param event [Hash] The domain event
138
+ # @return [Boolean] true to continue handling, false to skip
139
+ def before_handle(event) # rubocop:disable Lint/UnusedMethodArgument
140
+ true
141
+ end
142
+
143
+ # Hook called after successful handling
144
+ #
145
+ # Override for post-processing, metrics, or cleanup.
146
+ #
147
+ # @param event [Hash] The domain event
148
+ def after_handle(event)
149
+ # Default: no-op
150
+ end
151
+
152
+ # Hook called if handling fails
153
+ #
154
+ # Override for custom error handling, alerts, or retry logic.
155
+ # Note: Domain event handling uses fire-and-forget semantics - errors
156
+ # are logged but not propagated.
157
+ #
158
+ # @param event [Hash] The domain event
159
+ # @param error [Exception] The error that occurred
160
+ def on_handle_error(event, error)
161
+ logger.error "#{self.class.name}: Failed to handle #{event[:event_name]}: #{error.message}"
162
+ end
163
+
164
+ private
165
+
166
+ # Safely handle an event with error capture
167
+ def handle_event_safely(event)
168
+ return unless @active
169
+ return unless before_handle(event)
170
+
171
+ handle(event)
172
+ after_handle(event)
173
+ rescue StandardError => e
174
+ on_handle_error(event, e)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module TaskerCore
6
+ module DomainEvents
7
+ # TAS-65: Registry for custom domain event publishers
8
+ #
9
+ # Maps publisher names (from YAML configuration) to their Ruby implementations.
10
+ # Publishers are registered at bootstrap time and validated against task templates.
11
+ #
12
+ # @example Registering publishers at bootstrap
13
+ # registry = TaskerCore::DomainEvents::PublisherRegistry.instance
14
+ #
15
+ # # Register custom publishers
16
+ # registry.register(PaymentEventPublisher.new)
17
+ # registry.register(InventoryEventPublisher.new)
18
+ #
19
+ # # Validate against loaded templates
20
+ # required = ['PaymentEventPublisher', 'InventoryEventPublisher', 'MissingPublisher']
21
+ # registry.validate_required!(required)
22
+ # # => raises ValidationError: Missing publishers: MissingPublisher
23
+ #
24
+ # @example Looking up publishers
25
+ # publisher = registry.get('PaymentEventPublisher')
26
+ # publisher.transform_payload(step_result, event_declaration)
27
+ #
28
+ # @example Using default publisher
29
+ # # Returns DefaultPublisher for unregistered names
30
+ # publisher = registry.get_or_default('UnknownPublisher')
31
+ #
32
+ class PublisherRegistry
33
+ include Singleton
34
+
35
+ # Error classes nested in PublisherRegistry for cleaner namespacing
36
+ class ValidationError < StandardError
37
+ attr_reader :missing_publishers, :registered_publishers
38
+
39
+ def initialize(missing, registered)
40
+ @missing_publishers = missing
41
+ @registered_publishers = registered
42
+ super("Missing publishers: #{missing.join(', ')}. Registered: #{registered.join(', ')}")
43
+ end
44
+ end
45
+
46
+ class PublisherNotFoundError < StandardError
47
+ attr_reader :publisher_name, :registered_publishers
48
+
49
+ def initialize(name, registered)
50
+ @publisher_name = name
51
+ @registered_publishers = registered
52
+ super("Publisher '#{name}' not found. Registered: #{registered.join(', ')}")
53
+ end
54
+ end
55
+
56
+ class RegistryFrozenError < StandardError; end
57
+
58
+ class DuplicatePublisherError < StandardError
59
+ attr_reader :publisher_name
60
+
61
+ def initialize(name)
62
+ @publisher_name = name
63
+ super("Publisher '#{name}' is already registered")
64
+ end
65
+ end
66
+
67
+ # Default publisher that passes step result through unchanged
68
+ class DefaultPublisher < BasePublisher
69
+ def name
70
+ 'default'
71
+ end
72
+
73
+ def transform_payload(step_result, _event_declaration, _step_context = nil)
74
+ step_result[:result] || {}
75
+ end
76
+ end
77
+
78
+ attr_reader :logger, :publishers, :default_publisher
79
+
80
+ def initialize
81
+ @logger = TaskerCore::Logger.instance
82
+ @publishers = {}
83
+ @default_publisher = DefaultPublisher.new
84
+ @frozen = false
85
+ end
86
+
87
+ # Register a custom publisher
88
+ #
89
+ # @param publisher [BasePublisher] The publisher instance to register
90
+ # @return [BasePublisher, nil] The previous publisher with the same name, if any
91
+ # @raise [ArgumentError] If publisher does not inherit from BasePublisher
92
+ # @raise [DuplicatePublisherError] If a publisher with the same name is already registered
93
+ # @raise [RegistryFrozenError] If the registry has been frozen
94
+ def register(publisher)
95
+ # Type validation first
96
+ raise ArgumentError, "Expected BasePublisher, got #{publisher.class}" unless publisher.is_a?(BasePublisher)
97
+
98
+ raise RegistryFrozenError, 'Registry is frozen after validation' if @frozen
99
+
100
+ name = publisher.name
101
+
102
+ # Check for duplicates
103
+ raise DuplicatePublisherError, name if @publishers.key?(name)
104
+
105
+ logger.info "Registering domain event publisher: #{name}"
106
+ @publishers[name] = publisher
107
+ nil # No previous since we now disallow duplicates
108
+ end
109
+
110
+ # Get a publisher by name
111
+ #
112
+ # @param name [String] The publisher name
113
+ # @return [BasePublisher, nil] The publisher, or nil if not found
114
+ def get(name)
115
+ @publishers[name]
116
+ end
117
+
118
+ # Get a publisher by name, or return the default if not found
119
+ #
120
+ # @param name [String, nil] The publisher name
121
+ # @return [BasePublisher] The publisher or default
122
+ def get_or_default(name)
123
+ return @default_publisher if name.nil? || name == 'default'
124
+
125
+ @publishers[name] || begin
126
+ logger.warn "Publisher #{name} not found, using default"
127
+ @default_publisher
128
+ end
129
+ end
130
+
131
+ # Get a publisher by name with strict mode (no fallback)
132
+ #
133
+ # @param name [String] The publisher name
134
+ # @return [BasePublisher] The publisher
135
+ # @raise [PublisherNotFoundError] If the publisher is not registered
136
+ def get_strict(name)
137
+ return @default_publisher if name == 'default'
138
+
139
+ @publishers[name] || raise(
140
+ PublisherNotFoundError.new(name, registered_names)
141
+ )
142
+ end
143
+
144
+ # Check if a publisher is registered
145
+ #
146
+ # @param name [String] The publisher name
147
+ # @return [Boolean]
148
+ def registered?(name)
149
+ @publishers.key?(name) || name == 'default'
150
+ end
151
+
152
+ # Get all registered publisher names
153
+ #
154
+ # @return [Array<String>]
155
+ def registered_names
156
+ @publishers.keys
157
+ end
158
+
159
+ # Get count of registered publishers
160
+ #
161
+ # @return [Integer]
162
+ def count
163
+ @publishers.size
164
+ end
165
+
166
+ # Check if registry has no custom publishers
167
+ #
168
+ # @return [Boolean]
169
+ def empty?
170
+ @publishers.empty?
171
+ end
172
+
173
+ # Unregister a publisher by name
174
+ #
175
+ # @param name [String] The publisher name
176
+ # @return [BasePublisher, nil] The removed publisher, if any
177
+ # @raise [RegistryFrozenError] If the registry has been frozen
178
+ def unregister(name)
179
+ raise RegistryFrozenError, 'Registry is frozen after validation' if @frozen
180
+
181
+ logger.info "Unregistering domain event publisher: #{name}"
182
+ @publishers.delete(name)
183
+ end
184
+
185
+ # Clear all registered publishers
186
+ #
187
+ # @raise [RegistryFrozenError] If the registry has been frozen
188
+ def clear
189
+ raise RegistryFrozenError, 'Registry is frozen after validation' if @frozen
190
+
191
+ logger.info 'Clearing all domain event publishers'
192
+ @publishers.clear
193
+ end
194
+
195
+ # TAS-65: Validate that all required publishers are registered
196
+ #
197
+ # Implements "loud failure validation" - validates at init time that all
198
+ # publisher names referenced in task templates exist in the registry.
199
+ # After validation, the registry is frozen to prevent changes.
200
+ #
201
+ # @param required_publishers [Array<String>] Publisher names from YAML configs
202
+ # @return [true] If all required publishers are registered
203
+ # @raise [ValidationError] If some publishers are missing
204
+ def validate_required!(required_publishers)
205
+ missing = []
206
+
207
+ required_publishers.each do |name|
208
+ next if name == 'default'
209
+ next if registered?(name)
210
+
211
+ missing << name
212
+ end
213
+
214
+ raise ValidationError.new(missing, registered_names) if missing.any?
215
+
216
+ @frozen = true
217
+ logger.info "Publisher validation passed. Registered: #{registered_names.join(', ')}"
218
+ true
219
+ end
220
+
221
+ # Check if the registry is frozen
222
+ #
223
+ # @return [Boolean]
224
+ def frozen?
225
+ @frozen
226
+ end
227
+
228
+ # Freeze the registry to prevent further changes
229
+ #
230
+ # @return [void]
231
+ def freeze!
232
+ @frozen = true
233
+ logger.info 'Publisher registry frozen'
234
+ end
235
+
236
+ # Reset the registry (for testing)
237
+ #
238
+ # @note This unfreezes the registry - use only in tests
239
+ def reset!
240
+ @publishers.clear
241
+ @frozen = false
242
+ logger.info 'Publisher registry reset'
243
+ end
244
+ end
245
+
246
+ # Backwards-compatible aliases for classes that were moved into PublisherRegistry
247
+ # These allow existing code to use the old namespace while we transition
248
+ DefaultPublisher = PublisherRegistry::DefaultPublisher
249
+ ValidationError = PublisherRegistry::ValidationError
250
+ PublisherNotFoundError = PublisherRegistry::PublisherNotFoundError
251
+ RegistryFrozenError = PublisherRegistry::RegistryFrozenError
252
+ end
253
+ end