tasker-rb 0.1.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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/DEVELOPMENT.md +548 -0
  3. data/README.md +87 -0
  4. data/ext/tasker_core/Cargo.lock +4720 -0
  5. data/ext/tasker_core/Cargo.toml +76 -0
  6. data/ext/tasker_core/extconf.rb +38 -0
  7. data/ext/tasker_core/src/CLAUDE.md +7 -0
  8. data/ext/tasker_core/src/bootstrap.rs +320 -0
  9. data/ext/tasker_core/src/bridge.rs +400 -0
  10. data/ext/tasker_core/src/client_ffi.rs +173 -0
  11. data/ext/tasker_core/src/conversions.rs +131 -0
  12. data/ext/tasker_core/src/diagnostics.rs +57 -0
  13. data/ext/tasker_core/src/event_handler.rs +179 -0
  14. data/ext/tasker_core/src/event_publisher_ffi.rs +239 -0
  15. data/ext/tasker_core/src/ffi_logging.rs +245 -0
  16. data/ext/tasker_core/src/global_event_system.rs +16 -0
  17. data/ext/tasker_core/src/in_process_event_ffi.rs +319 -0
  18. data/ext/tasker_core/src/lib.rs +41 -0
  19. data/ext/tasker_core/src/observability_ffi.rs +339 -0
  20. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  21. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  22. data/lib/tasker_core/bootstrap.rb +394 -0
  23. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  24. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  25. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  26. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  27. data/lib/tasker_core/domain_events.rb +43 -0
  28. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  29. data/lib/tasker_core/errors/common.rb +305 -0
  30. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  31. data/lib/tasker_core/errors.rb +4 -0
  32. data/lib/tasker_core/event_bridge.rb +330 -0
  33. data/lib/tasker_core/handlers.rb +159 -0
  34. data/lib/tasker_core/internal.rb +31 -0
  35. data/lib/tasker_core/logger.rb +234 -0
  36. data/lib/tasker_core/models.rb +337 -0
  37. data/lib/tasker_core/observability/types.rb +158 -0
  38. data/lib/tasker_core/observability.rb +292 -0
  39. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  40. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  41. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  42. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  43. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  44. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  45. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  46. data/lib/tasker_core/registry/resolvers.rb +42 -0
  47. data/lib/tasker_core/registry.rb +12 -0
  48. data/lib/tasker_core/step_handler/api.rb +48 -0
  49. data/lib/tasker_core/step_handler/base.rb +354 -0
  50. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  51. data/lib/tasker_core/step_handler/decision.rb +53 -0
  52. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  53. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  54. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  55. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  56. data/lib/tasker_core/subscriber.rb +212 -0
  57. data/lib/tasker_core/task_handler/base.rb +254 -0
  58. data/lib/tasker_core/tasker_rb.so +0 -0
  59. data/lib/tasker_core/template_discovery.rb +181 -0
  60. data/lib/tasker_core/tracing.rb +166 -0
  61. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  62. data/lib/tasker_core/types/client_types.rb +145 -0
  63. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  64. data/lib/tasker_core/types/error_types.rb +72 -0
  65. data/lib/tasker_core/types/simple_message.rb +151 -0
  66. data/lib/tasker_core/types/step_context.rb +328 -0
  67. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  68. data/lib/tasker_core/types/step_message.rb +112 -0
  69. data/lib/tasker_core/types/step_types.rb +207 -0
  70. data/lib/tasker_core/types/task_template.rb +240 -0
  71. data/lib/tasker_core/types/task_types.rb +148 -0
  72. data/lib/tasker_core/types.rb +132 -0
  73. data/lib/tasker_core/version.rb +13 -0
  74. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  75. data/lib/tasker_core/worker/event_poller.rb +224 -0
  76. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  77. data/lib/tasker_core.rb +160 -0
  78. metadata +322 -0
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module Errors
5
+ # Base error class for all TaskerCore-related errors
6
+ class Error < StandardError; end
7
+
8
+ # Raised when there are configuration-related issues in TaskerCore
9
+ class ConfigurationError < Error; end
10
+
11
+ # Base class for all TaskerCore-specific errors that occur during workflow execution
12
+ # Maps to the comprehensive error system in Rust orchestration/errors.rs
13
+ class ProceduralError < Error; end
14
+
15
+ # Raised when there are orchestration-related issues in TaskerCore
16
+ class OrchestrationError < Error; end
17
+
18
+ # Raised when there are database-related issues in TaskerCore
19
+ class DatabaseError < Error; end
20
+
21
+ # Error indicating a step failed but should be retried with backoff
22
+ # Maps to Rust StepExecutionError::Retryable
23
+ #
24
+ # Use this error when an operation fails due to temporary conditions like:
25
+ # - Network timeouts
26
+ # - Rate limiting (429 status)
27
+ # - Server errors (5xx status)
28
+ # - Temporary service unavailability
29
+ # - Resource exhaustion that may resolve
30
+ #
31
+ # @example Basic retryable error
32
+ # raise TaskerCore::Errors::RetryableError, "Payment service timeout"
33
+ #
34
+ # @example With retry delay
35
+ # raise TaskerCore::Errors::RetryableError.new("Rate limited", retry_after: 60)
36
+ #
37
+ # @example With context for monitoring
38
+ # raise TaskerCore::Errors::RetryableError.new(
39
+ # "External API unavailable",
40
+ # retry_after: 30,
41
+ # context: { service: 'billing_api', error_code: 503 }
42
+ # )
43
+ class RetryableError < ProceduralError
44
+ # @return [Integer, nil] Suggested retry delay in seconds
45
+ attr_reader :retry_after
46
+
47
+ # @return [Hash] Additional context for error monitoring and debugging
48
+ attr_reader :context
49
+
50
+ # @param message [String] Error message
51
+ # @param retry_after [Integer, nil] Suggested retry delay in seconds
52
+ # @param context [Hash] Additional context for monitoring
53
+ def initialize(message, retry_after: nil, context: {})
54
+ super(message)
55
+ @retry_after = retry_after
56
+ @context = context
57
+ end
58
+
59
+ # Get the error class name for Rust FFI compatibility
60
+ def error_class
61
+ 'RetryableError'
62
+ end
63
+ end
64
+
65
+ # Error indicating a step failed permanently and should not be retried
66
+ # Maps to Rust StepExecutionError::Permanent
67
+ #
68
+ # Use this error when an operation fails due to permanent conditions like:
69
+ # - Invalid request data (400 status)
70
+ # - Authentication/authorization failures (401/403 status)
71
+ # - Validation errors (422 status)
72
+ # - Resource not found when it should exist (404 status in some contexts)
73
+ # - Business logic violations
74
+ # - Configuration errors
75
+ #
76
+ # @example Basic permanent error
77
+ # raise TaskerCore::Errors::PermanentError, "Invalid user ID format"
78
+ #
79
+ # @example With error code for categorization
80
+ # raise TaskerCore::Errors::PermanentError.new(
81
+ # "Insufficient funds for transaction",
82
+ # error_code: 'INSUFFICIENT_FUNDS'
83
+ # )
84
+ #
85
+ # @example With context for monitoring
86
+ # raise TaskerCore::Errors::PermanentError.new(
87
+ # "User not authorized for this operation",
88
+ # error_code: 'AUTHORIZATION_FAILED',
89
+ # context: { user_id: 123, operation: 'admin_access' }
90
+ # )
91
+ class PermanentError < ProceduralError
92
+ # @return [String, nil] Machine-readable error code for categorization
93
+ attr_reader :error_code
94
+
95
+ # @return [Hash] Additional context for error monitoring and debugging
96
+ attr_reader :context
97
+
98
+ # @param message [String] Error message
99
+ # @param error_code [String, nil] Machine-readable error code
100
+ # @param context [Hash] Additional context for monitoring
101
+ def initialize(message, error_code: nil, context: {})
102
+ super(message)
103
+ @error_code = error_code
104
+ @context = context
105
+ end
106
+
107
+ # Get the error class name for Rust FFI compatibility
108
+ def error_class
109
+ 'PermanentError'
110
+ end
111
+ end
112
+
113
+ # Error indicating a timeout occurred during step execution
114
+ # Maps to Rust StepExecutionError::Timeout
115
+ #
116
+ # Use this error when an operation fails due to timeout conditions:
117
+ # - Step execution timeout
118
+ # - Network request timeout
119
+ # - Database operation timeout
120
+ # - External service timeout
121
+ #
122
+ # @example Basic timeout error
123
+ # raise TaskerCore::Errors::TimeoutError, "Payment processing timed out"
124
+ #
125
+ # @example With timeout duration
126
+ # raise TaskerCore::Errors::TimeoutError.new(
127
+ # "Database query timed out",
128
+ # timeout_duration: 30
129
+ # )
130
+ class TimeoutError < ProceduralError
131
+ # @return [Integer, nil] Timeout duration in seconds
132
+ attr_reader :timeout_duration
133
+
134
+ # @return [Hash] Additional context for error monitoring and debugging
135
+ attr_reader :context
136
+
137
+ # @param message [String] Error message
138
+ # @param timeout_duration [Integer, nil] Timeout duration in seconds
139
+ # @param context [Hash] Additional context for monitoring
140
+ def initialize(message, timeout_duration: nil, context: {})
141
+ super(message)
142
+ @timeout_duration = timeout_duration
143
+ @context = context
144
+ end
145
+
146
+ # Get the error class name for Rust FFI compatibility
147
+ def error_class
148
+ 'TimeoutError'
149
+ end
150
+ end
151
+
152
+ # Error indicating a network-related failure
153
+ # Maps to Rust StepExecutionError::NetworkError
154
+ #
155
+ # Use this error when an operation fails due to network conditions:
156
+ # - Connection failures
157
+ # - DNS resolution errors
158
+ # - HTTP errors (4xx/5xx when network-related)
159
+ # - TLS/SSL errors
160
+ # - Network timeouts
161
+ #
162
+ # @example Basic network error
163
+ # raise TaskerCore::Errors::NetworkError, "Connection refused"
164
+ #
165
+ # @example With HTTP status code
166
+ # raise TaskerCore::Errors::NetworkError.new(
167
+ # "Service unavailable",
168
+ # status_code: 503
169
+ # )
170
+ class NetworkError < ProceduralError
171
+ # @return [Integer, nil] HTTP status code if applicable
172
+ attr_reader :status_code
173
+
174
+ # @return [Hash] Additional context for error monitoring and debugging
175
+ attr_reader :context
176
+
177
+ # @param message [String] Error message
178
+ # @param status_code [Integer, nil] HTTP status code if applicable
179
+ # @param context [Hash] Additional context for monitoring
180
+ def initialize(message, status_code: nil, context: {})
181
+ super(message)
182
+ @status_code = status_code
183
+ @context = context
184
+ end
185
+
186
+ # Get the error class name for Rust FFI compatibility
187
+ def error_class
188
+ 'NetworkError'
189
+ end
190
+ end
191
+
192
+ # Error indicating validation failed
193
+ # Maps to Rust OrchestrationError::ValidationError
194
+ #
195
+ # Use this error when data validation fails:
196
+ # - Schema validation errors
197
+ # - Business rule validation
198
+ # - Input format validation
199
+ # - Required field validation
200
+ class ValidationError < PermanentError
201
+ # @return [String, nil] Field that failed validation
202
+ attr_reader :field
203
+
204
+ # @param message [String] Error message
205
+ # @param field [String, nil] Field that failed validation
206
+ # @param error_code [String, nil] Machine-readable error code
207
+ # @param context [Hash] Additional context for monitoring
208
+ def initialize(message, field: nil, error_code: nil, context: {})
209
+ super(message, error_code: error_code, context: context)
210
+ @field = field
211
+ end
212
+
213
+ # Get the error class name for Rust FFI compatibility
214
+ def error_class
215
+ 'ValidationError'
216
+ end
217
+ end
218
+
219
+ # Error indicating a handler or step was not found
220
+ # Maps to Rust OrchestrationError::HandlerNotFound and StepHandlerNotFound
221
+ class NotFoundError < PermanentError
222
+ # @return [String, nil] Type of resource not found (handler, step, etc.)
223
+ attr_reader :resource_type
224
+
225
+ # @param message [String] Error message
226
+ # @param resource_type [String, nil] Type of resource not found
227
+ # @param error_code [String, nil] Machine-readable error code
228
+ # @param context [Hash] Additional context for monitoring
229
+ def initialize(message, resource_type: nil, error_code: nil, context: {})
230
+ super(message, error_code: error_code, context: context)
231
+ @resource_type = resource_type
232
+ end
233
+
234
+ # Get the error class name for Rust FFI compatibility
235
+ def error_class
236
+ 'NotFoundError'
237
+ end
238
+ end
239
+
240
+ # Error indicating an FFI operation failed
241
+ # Maps to Rust OrchestrationError::FfiBridgeError
242
+ class FFIError < Error
243
+ # @return [String, nil] FFI operation that failed
244
+ attr_reader :operation
245
+
246
+ # @return [Hash] Additional context for error monitoring and debugging
247
+ attr_reader :context
248
+
249
+ # @param message [String] Error message
250
+ # @param operation [String, nil] FFI operation that failed
251
+ # @param context [Hash] Additional context for monitoring
252
+ def initialize(message, operation: nil, context: {})
253
+ super(message)
254
+ @operation = operation
255
+ @context = context
256
+ end
257
+
258
+ # Get the error class name for Rust FFI compatibility
259
+ def error_class
260
+ 'FFIError'
261
+ end
262
+ end
263
+
264
+ # Error indicating embedded server operation failed
265
+ # Maps to Rust ServerError types
266
+ #
267
+ # Use this error when embedded server operations fail:
268
+ # - Server startup failures
269
+ # - Server shutdown failures
270
+ # - Configuration errors
271
+ # - Runtime errors
272
+ # - Server already running/not running
273
+ #
274
+ # @example Basic server error
275
+ # raise TaskerCore::Errors::ServerError, "Failed to start embedded server"
276
+ #
277
+ # @example With server operation context
278
+ # raise TaskerCore::Errors::ServerError.new(
279
+ # "Server startup failed",
280
+ # operation: 'start',
281
+ # context: { bind_address: '127.0.0.1:8080', error_code: 'ADDRESS_IN_USE' }
282
+ # )
283
+ class ServerError < Error
284
+ # @return [String, nil] Server operation that failed
285
+ attr_reader :operation
286
+
287
+ # @return [Hash] Additional context for error monitoring and debugging
288
+ attr_reader :context
289
+
290
+ # @param message [String] Error message
291
+ # @param operation [String, nil] Server operation that failed (start, stop, config, etc.)
292
+ # @param context [Hash] Additional context for monitoring
293
+ def initialize(message, operation: nil, context: {})
294
+ super(message)
295
+ @operation = operation
296
+ @context = context
297
+ end
298
+
299
+ # Get the error class name for Rust FFI compatibility
300
+ def error_class
301
+ 'ServerError'
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'common'
4
+
5
+ module TaskerCore
6
+ module Errors
7
+ # ErrorClassifier provides systematic classification of errors for retry logic
8
+ #
9
+ # This classifier determines whether errors should be retried based on their type.
10
+ # It maintains explicit lists of permanent (non-retryable) and retryable error classes.
11
+ #
12
+ # @example Classify a configuration error
13
+ # ErrorClassifier.retryable?(ConfigurationError.new("Missing handler"))
14
+ # # => false
15
+ #
16
+ # @example Classify a network error
17
+ # ErrorClassifier.retryable?(NetworkError.new("Connection timeout"))
18
+ # # => true
19
+ class ErrorClassifier
20
+ # System-level errors that should NEVER be retried
21
+ # These represent fundamental configuration or setup issues that won't resolve on retry
22
+ PERMANENT_ERROR_CLASSES = [
23
+ ConfigurationError, # Configuration issues (missing config, invalid settings)
24
+ PermanentError, # Explicitly marked permanent errors
25
+ ValidationError, # Data validation failures
26
+ NotFoundError, # Resource not found (handler, step, etc.)
27
+ DatabaseError, # Database schema/constraint errors
28
+ FFIError # FFI bridge failures
29
+ ].freeze
30
+
31
+ # Application errors that are retryable by default
32
+ # These represent transient failures that may resolve on subsequent attempts
33
+ RETRYABLE_ERROR_CLASSES = [
34
+ RetryableError, # Explicitly marked retryable errors
35
+ NetworkError, # Network/connection failures
36
+ TimeoutError # Timeout errors
37
+ ].freeze
38
+
39
+ # Determine if an error should be retried
40
+ #
41
+ # @param error [StandardError] The error to classify
42
+ # @return [Boolean] true if the error should be retried, false otherwise
43
+ #
44
+ # @note The default behavior is to mark errors as retryable unless they are
45
+ # explicitly in the PERMANENT_ERROR_CLASSES list. This follows the principle
46
+ # that it's safer to retry an error that shouldn't be retried (will eventually
47
+ # hit retry limit) than to not retry an error that should be (will fail immediately).
48
+ def self.retryable?(error)
49
+ # Explicit permanent errors - never retry
50
+ return false if PERMANENT_ERROR_CLASSES.any? { |klass| error.is_a?(klass) }
51
+
52
+ # Explicit retryable errors - always retry
53
+ return true if RETRYABLE_ERROR_CLASSES.any? { |klass| error.is_a?(klass) }
54
+
55
+ # Default: StandardError and subclasses are retryable unless explicitly marked permanent
56
+ # This is a safe default - worst case, we retry until max_attempts is hit
57
+ true
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors/common'
4
+ require_relative 'errors/error_classifier'
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'models'
4
+
5
+ module TaskerCore
6
+ module Worker
7
+ # Event bridge between Rust and Ruby using dry-events
8
+ #
9
+ # Handles bidirectional event communication between the Rust orchestration
10
+ # layer and Ruby business logic handlers. The bridge uses dry-events for
11
+ # Ruby-side pub/sub and FFI for cross-language communication.
12
+ #
13
+ # Event Flow:
14
+ # 1. **Rust → Ruby**: StepExecutionEvent indicates step is ready for processing
15
+ # 2. **Ruby Processing**: Handler executes business logic
16
+ # 3. **Ruby → Rust**: StepExecutionCompletionEvent returns results
17
+ #
18
+ # The EventBridge automatically wraps raw FFI data in accessor objects for
19
+ # developer convenience and maintains event schema validation.
20
+ #
21
+ # @example Publishing step execution (from Rust FFI)
22
+ # # This is called automatically by Rust via FFI when a step is ready
23
+ # bridge = TaskerCore::Worker::EventBridge.instance
24
+ # bridge.publish_step_execution({
25
+ # event_id: "550e8400-e29b-41d4-a716-446655440000",
26
+ # task_uuid: "7c9e6679-7425-40de-944b-e07fc1f90ae7",
27
+ # step_uuid: "123e4567-e89b-12d3-a456-426614174000",
28
+ # task_sequence_step: {
29
+ # task: { context: { amount: 100.00, currency: "USD" } },
30
+ # step: { name: "process_payment", handler_class: "ProcessPaymentHandler" },
31
+ # workflow_step: { state: "in_progress", attempts: 1 }
32
+ # }
33
+ # })
34
+ # # => Publishes 'step.execution.received' event to Ruby subscribers
35
+ #
36
+ # @example Subscribing to step execution events
37
+ # # This is typically done in StepExecutionSubscriber
38
+ # bridge.subscribe_to_step_execution do |event|
39
+ # # Resolve handler
40
+ # handler_class = event[:task_sequence_step].handler_class
41
+ # handler = registry.resolve_handler(handler_class)
42
+ #
43
+ # # Create context and execute handler
44
+ # context = TaskerCore::Types::StepContext.new(event[:task_sequence_step])
45
+ # result = handler.call(context)
46
+ #
47
+ # # Send completion back to Rust
48
+ # bridge.publish_step_completion({
49
+ # event_id: event[:event_id],
50
+ # task_uuid: event[:task_uuid],
51
+ # step_uuid: event[:step_uuid],
52
+ # success: true,
53
+ # result: result
54
+ # })
55
+ # end
56
+ #
57
+ # @example Sending completion back to Rust
58
+ # bridge.publish_step_completion({
59
+ # event_id: "550e8400-e29b-41d4-a716-446655440000",
60
+ # task_uuid: "7c9e6679-7425-40de-944b-e07fc1f90ae7",
61
+ # step_uuid: "123e4567-e89b-12d3-a456-426614174000",
62
+ # success: true,
63
+ # result: { payment_id: "pay_123", status: "succeeded" },
64
+ # metadata: {
65
+ # handler_class: "ProcessPaymentHandler",
66
+ # execution_time_ms: 125
67
+ # }
68
+ # })
69
+ # # => Sends completion to Rust via FFI and publishes 'step.completion.sent'
70
+ #
71
+ # @example Handling errors in step execution
72
+ # begin
73
+ # result = handler.call(context)
74
+ # bridge.publish_step_completion({
75
+ # event_id: event_id,
76
+ # task_uuid: task_uuid,
77
+ # step_uuid: step_uuid,
78
+ # success: true,
79
+ # result: result
80
+ # })
81
+ # rescue TaskerCore::Errors::RetryableError => e
82
+ # bridge.publish_step_completion({
83
+ # event_id: event_id,
84
+ # task_uuid: task_uuid,
85
+ # step_uuid: step_uuid,
86
+ # success: false,
87
+ # error_message: e.message,
88
+ # error_class: e.class.name,
89
+ # retryable: true,
90
+ # retry_after: e.retry_after
91
+ # })
92
+ # rescue TaskerCore::Errors::PermanentError => e
93
+ # bridge.publish_step_completion({
94
+ # event_id: event_id,
95
+ # task_uuid: task_uuid,
96
+ # step_uuid: step_uuid,
97
+ # success: false,
98
+ # error_message: e.message,
99
+ # error_class: e.class.name,
100
+ # retryable: false
101
+ # })
102
+ # end
103
+ #
104
+ # Event Flow Diagram:
105
+ #
106
+ # ```
107
+ # Rust Orchestration EventBridge Ruby Handler
108
+ # ----------------- ----------- ------------
109
+ # | | |
110
+ # | 1. Step ready for execution | |
111
+ # |------------------------------->| |
112
+ # | publish_step_execution | |
113
+ # | | 2. Publish event |
114
+ # | |-------------------------->|
115
+ # | | step.execution.received|
116
+ # | | |
117
+ # | | 3. Execute handler
118
+ # | | |
119
+ # | | 4. Completion |
120
+ # | |<--------------------------|
121
+ # | 5. FFI completion | publish_step_completion|
122
+ # |<-------------------------------| |
123
+ # | send_step_completion_event | |
124
+ # ```
125
+ #
126
+ # Registered Events:
127
+ # - **step.execution.received**: Step ready for execution (Rust → Ruby)
128
+ # - **step.completion.sent**: Step execution completed (Ruby → Rust)
129
+ # - **bridge.error**: Error in event processing
130
+ #
131
+ # Completion Data Validation:
132
+ # The bridge validates completion data before sending to Rust:
133
+ # - **event_id**: Required, UUID of the original execution event
134
+ # - **task_uuid**: Required, UUID of the task
135
+ # - **step_uuid**: Required, UUID of the step
136
+ # - **success**: Required, boolean indicating success/failure
137
+ # - **metadata**: Optional, hash with additional context
138
+ # - **completed_at**: Optional, ISO 8601 timestamp (auto-generated if missing)
139
+ #
140
+ # @see TaskerCore::Subscriber For event subscription implementation
141
+ # @see TaskerCore::Worker::EventPoller For polling mechanism
142
+ # @see TaskerCore::FFI For Rust FFI operations
143
+ # @see TaskerCore::Models::TaskSequenceStepWrapper For data wrappers
144
+ class EventBridge
145
+ include Singleton
146
+ include Dry::Events::Publisher[:tasker_core]
147
+
148
+ attr_reader :logger, :active
149
+
150
+ def initialize
151
+ @logger = TaskerCore::Logger.instance
152
+ @active = true
153
+
154
+ setup_event_schema!
155
+ end
156
+
157
+ # Check if bridge is active
158
+ def active?
159
+ @active
160
+ end
161
+
162
+ # Stop the event bridge
163
+ def stop!
164
+ @active = false
165
+ logger.info 'Event bridge stopped'
166
+ end
167
+
168
+ # Called by Rust FFI when StepExecutionEvent is received
169
+ # This is the entry point for events from Rust
170
+ def publish_step_execution(event_data)
171
+ return unless active?
172
+
173
+ event_data = event_data.to_h.deep_symbolize_keys
174
+ logger.debug "Publishing step execution event: #{event_data[:event_id]}"
175
+
176
+ # Wrap the raw data in accessor objects for easier use
177
+ wrapped_event = wrap_step_execution_event(event_data)
178
+
179
+ # Publish to dry-events subscribers (Ruby handlers)
180
+ publish('step.execution.received', wrapped_event)
181
+
182
+ logger.debug 'Step execution event published successfully'
183
+ true
184
+ rescue StandardError => e
185
+ logger.error "Failed to publish step execution: #{e.message}"
186
+ logger.error e.backtrace.join("\n")
187
+ raise
188
+ end
189
+
190
+ # Subscribe to step execution events (used by StepExecutionSubscriber)
191
+ def subscribe_to_step_execution(&)
192
+ subscribe('step.execution.received', &)
193
+ end
194
+
195
+ # Send completion event back to Rust
196
+ # Called by StepExecutionSubscriber after handler execution
197
+ def publish_step_completion(completion_data)
198
+ return unless active?
199
+
200
+ logger.debug "Sending step completion to Rust: #{completion_data[:event_id]}"
201
+
202
+ # Validate completion data
203
+ validate_completion!(completion_data)
204
+
205
+ # Send to Rust via FFI (TAS-67: complete_step_event takes event_id and completion_data)
206
+ TaskerCore::FFI.complete_step_event(completion_data[:event_id].to_s, completion_data)
207
+
208
+ # Also publish locally for monitoring/debugging
209
+ publish('step.completion.sent', completion_data)
210
+
211
+ logger.debug 'Step completion sent to Rust'
212
+ rescue StandardError => e
213
+ logger.error "Failed to send step completion: #{e.message}"
214
+ logger.error e.backtrace.join("\n")
215
+ raise
216
+ end
217
+
218
+ # TAS-125: Send checkpoint yield back to Rust for batch processing
219
+ #
220
+ # Called by batch processing handlers when they want to persist progress
221
+ # and be re-dispatched for continuation. Unlike publish_step_completion,
222
+ # this does NOT complete the step - instead it persists checkpoint data
223
+ # and causes the step to be re-dispatched for continued processing.
224
+ #
225
+ # @param checkpoint_data [Hash] Checkpoint data to persist:
226
+ # - :event_id [String] Required, UUID of the original execution event
227
+ # - :step_uuid [String] Required, UUID of the step
228
+ # - :cursor [Object] Required, position to resume from (Integer, String, or Hash)
229
+ # - :items_processed [Integer] Required, count of items processed so far
230
+ # - :accumulated_results [Hash] Optional, partial results to carry forward
231
+ # @return [Boolean] true if checkpoint was persisted and step re-dispatched
232
+ #
233
+ # @example Yield checkpoint in batch processing
234
+ # bridge.publish_step_checkpoint_yield({
235
+ # event_id: "550e8400-e29b-41d4-a716-446655440000",
236
+ # step_uuid: "123e4567-e89b-12d3-a456-426614174000",
237
+ # cursor: 1000,
238
+ # items_processed: 1000,
239
+ # accumulated_results: { total_amount: 50000.00, processed_count: 1000 }
240
+ # })
241
+ def publish_step_checkpoint_yield(checkpoint_data)
242
+ return false unless active?
243
+
244
+ logger.debug "Sending checkpoint yield to Rust: #{checkpoint_data[:event_id]}"
245
+
246
+ # Validate checkpoint data
247
+ validate_checkpoint_yield!(checkpoint_data)
248
+
249
+ # Send to Rust via FFI (TAS-125)
250
+ success = TaskerCore::FFI.checkpoint_yield_step_event(
251
+ checkpoint_data[:event_id].to_s,
252
+ checkpoint_data
253
+ )
254
+
255
+ if success
256
+ # Publish locally for monitoring/debugging
257
+ publish('step.checkpoint_yield.sent', checkpoint_data)
258
+ logger.debug 'Checkpoint yield sent to Rust - step will be re-dispatched'
259
+ else
260
+ logger.warn 'Checkpoint yield failed - checkpoint support may not be configured'
261
+ end
262
+
263
+ success
264
+ rescue StandardError => e
265
+ logger.error "Failed to send checkpoint yield: #{e.message}"
266
+ logger.error e.backtrace.join("\n")
267
+ raise
268
+ end
269
+
270
+ private
271
+
272
+ def setup_event_schema!
273
+ # Register event types
274
+ register_event('step.execution.received')
275
+ register_event('step.completion.sent')
276
+ register_event('step.checkpoint_yield.sent') # TAS-125
277
+ register_event('bridge.error')
278
+ end
279
+
280
+ def wrap_step_execution_event(event_data)
281
+ wrapped = {
282
+ event_id: event_data[:event_id],
283
+ task_uuid: event_data[:task_uuid],
284
+ step_uuid: event_data[:step_uuid],
285
+ task_sequence_step: TaskerCore::Models::TaskSequenceStepWrapper.new(event_data[:task_sequence_step])
286
+ }
287
+
288
+ # TAS-29: Expose correlation_id at top level for easy access
289
+ wrapped[:correlation_id] = event_data[:correlation_id] if event_data[:correlation_id]
290
+ wrapped[:parent_correlation_id] = event_data[:parent_correlation_id] if event_data[:parent_correlation_id]
291
+
292
+ # TAS-65 Phase 1.5b: Expose trace_id and span_id for distributed tracing
293
+ wrapped[:trace_id] = event_data[:trace_id] if event_data[:trace_id]
294
+ wrapped[:span_id] = event_data[:span_id] if event_data[:span_id]
295
+
296
+ wrapped
297
+ end
298
+
299
+ def validate_completion!(completion_data)
300
+ required_fields = %i[event_id task_uuid step_uuid success]
301
+ missing_fields = required_fields - completion_data.keys
302
+
303
+ if missing_fields.any?
304
+ raise ArgumentError, "Missing required fields in completion: #{missing_fields.join(', ')}"
305
+ end
306
+
307
+ # Ensure metadata is a hash
308
+ completion_data[:metadata] ||= {}
309
+
310
+ # Ensure timestamps
311
+ completion_data[:completed_at] ||= Time.now.utc.iso8601
312
+ end
313
+
314
+ # TAS-125: Validate checkpoint yield data before sending to Rust
315
+ def validate_checkpoint_yield!(checkpoint_data)
316
+ required_fields = %i[event_id step_uuid cursor items_processed]
317
+ missing_fields = required_fields - checkpoint_data.keys
318
+
319
+ if missing_fields.any?
320
+ raise ArgumentError, "Missing required fields in checkpoint yield: #{missing_fields.join(', ')}"
321
+ end
322
+
323
+ # Validate items_processed is a non-negative integer
324
+ return if checkpoint_data[:items_processed].is_a?(Integer) && checkpoint_data[:items_processed] >= 0
325
+
326
+ raise ArgumentError, 'items_processed must be a non-negative integer'
327
+ end
328
+ end
329
+ end
330
+ end