tasker-rb 0.1.5 → 0.1.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67c2fa6d572b21fba2e42334cf90bc2c1ae8a37c5baa2b189334da91c0d795f9
4
- data.tar.gz: f38e8d917297f043ad06518224973f18453596341ef3da465d747cf661d8ebd7
3
+ metadata.gz: a8c5ed3385bea52ede9378bb4cd2f35c4fb03e285a0b811698fa34042b2a0c8a
4
+ data.tar.gz: a9ce3c0d4d390ae8f0263ca60794c8a0997d99ca6272c52ccd7a8cea579de6ae
5
5
  SHA512:
6
- metadata.gz: caa3042ef86162a269ca6dfae5c33c47006e32995cca36cf72654bd25bf7be1deb6639d46c2b5b7ad24e1c3dc0567426f32765d696eabd212b43665aaa32e717
7
- data.tar.gz: e75885dbaba5b94164bbcbffa2250e5d6d1848e0ad8429691d621213f24caa398d8e4400c713635c4a0ef877594004acc981d341fd5a2e1fe950f7a5c5202556
6
+ metadata.gz: d7957c47e7f5403a0adc3cbfc917b49196e44e21438276f806809662b0bd3abaa2ff14408e69233dd06e1521fece83cc0f320d3601d152e8ee69730212f3ca58
7
+ data.tar.gz: e7844fe1ee414c7f3887db916b084c15ac44768885516792964bce9afff6cd807a8b847335428cb367fb7e0042730b8bc95a098e89cc7a24851ca0b96bc8b5db
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tasker-rb"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  edition = "2021"
5
5
  description = "Ruby bindings for tasker-core: High-performance workflow orchestration"
6
6
  readme = "../../../../README.md"
@@ -66,8 +66,8 @@ sqlx = { version = "0.8", features = [
66
66
  # Both path (for workspace builds) and version (for standalone/published builds).
67
67
  # cargo publish strips the path field, leaving only the version for crates.io resolution.
68
68
  # Version pins are updated by scripts/release/update-versions.sh during release-prepare.
69
- tasker-shared = { path = "../../../../tasker-shared", version = "=0.1.4" }
70
- tasker-worker = { path = "../../../../tasker-worker", version = "=0.1.4" }
69
+ tasker-shared = { path = "../../../../tasker-shared", version = "=0.1.5" }
70
+ tasker-worker = { path = "../../../../tasker-worker", version = "=0.1.5" }
71
71
  # Error handling
72
72
  thiserror = "2.0"
73
73
  # Async runtime for blocking on futures in FFI
@@ -80,7 +80,7 @@ uuid = { version = "1.11", features = ["serde", "v4", "v7"] }
80
80
  workspace_tools = { version = "0.11.0", features = ["full"] }
81
81
 
82
82
  [dev-dependencies]
83
- tasker-core = { package = "tasker-core", path = "../../../../", version = "=0.1.4" }
83
+ tasker-core = { package = "tasker-core", path = "../../../../", version = "=0.1.5" }
84
84
 
85
85
  [features]
86
86
  default = []
@@ -4,8 +4,7 @@ module TaskerCore
4
4
  # Clean Handlers Domain API
5
5
  #
6
6
  # This module provides the primary public interface for working with TaskerCore handlers.
7
- # It's organized into two main namespaces: Steps and Tasks, mirroring the Rails engine
8
- # architecture while providing a clean Ruby interface with enhanced type safety.
7
+ # It provides a clean Ruby interface with enhanced type safety for step handlers.
9
8
  #
10
9
  # The Handlers namespace serves as the recommended entry point for handler operations,
11
10
  # abstracting the underlying implementation details while preserving method signatures
@@ -60,14 +59,8 @@ module TaskerCore
60
59
  # end
61
60
  # end
62
61
  #
63
- # @example Task-level handler for workflow coordination
64
- # # Task handlers coordinate multiple steps
65
- # result = TaskerCore::Handlers::Tasks.handle(task_uuid: "123-456")
66
- # # => Orchestrates all steps for the task
67
- #
68
62
  # Architecture:
69
63
  # - **Steps**: Individual business logic units (payment processing, API calls, etc.)
70
- # - **Tasks**: Workflow orchestration and step coordination
71
64
  # - **API**: Specialized step handlers for HTTP operations with automatic retry
72
65
  #
73
66
  # Method Signature:
@@ -78,7 +71,6 @@ module TaskerCore
78
71
  # - `context.dependency_results` - Results from parent steps
79
72
  #
80
73
  # @see TaskerCore::Handlers::Steps For step-level business logic
81
- # @see TaskerCore::Handlers::Tasks For task-level orchestration
82
74
  # @see TaskerCore::StepHandler::Base For low-level step handler implementation
83
75
  # @see TaskerCore::StepHandler::Api For HTTP-based handlers
84
76
  module Handlers
@@ -131,29 +123,5 @@ module TaskerCore
131
123
  end
132
124
  end
133
125
  end
134
-
135
- # Task Handler API
136
- module Tasks
137
- require_relative 'task_handler/base'
138
-
139
- # Re-export with clean namespace
140
- Base = TaskHandler
141
-
142
- class << self
143
- # Handle task with preserved signature
144
- # @param task_uuid [Integer] Task ID to handle
145
- # @return [Object] Task handle result
146
- def handle(task_uuid)
147
- Base.new.handle(task_uuid)
148
- end
149
-
150
- # Create task handler instance
151
- # @param config [Hash] Handler configuration
152
- # @return [TaskHandler] Handler instance
153
- def create(config: {})
154
- Base.new(config: config)
155
- end
156
- end
157
- end
158
126
  end
159
127
  end
@@ -192,8 +192,13 @@ module TaskerCore
192
192
  #
193
193
  # @param results_data [Hash] Hash of step names to their results
194
194
  def initialize(results_data)
195
- # Use HashWithIndifferentAccess for developer-friendly key access
196
- @results = (results_data || {}).with_indifferent_access
195
+ # Use HashWithIndifferentAccess for developer-friendly key access.
196
+ # serde_magnus serializes Rust struct field names as Ruby symbols, so
197
+ # each inner StepExecutionResult hash has symbol keys (:result, :success, etc.).
198
+ # Convert them to indifferent access so callers can use string or symbol keys.
199
+ raw = results_data || {}
200
+ @results = raw.transform_values { |v| v.is_a?(Hash) ? v.with_indifferent_access : v }
201
+ .with_indifferent_access
197
202
  end
198
203
 
199
204
  # Get result from a parent step (returns full result hash)
@@ -298,7 +298,8 @@ module TaskerCore
298
298
  return ENV['TASKER_TEMPLATE_PATH'] if ENV['TASKER_TEMPLATE_PATH']
299
299
 
300
300
  # 2. Test environment: default to spec/fixtures/templates
301
- if test_environment?
301
+ # (skip when TASKER_SKIP_EXAMPLE_HANDLERS is set — the app provides its own templates)
302
+ if test_environment? && ENV['TASKER_SKIP_EXAMPLE_HANDLERS'] != 'true'
302
303
  fixtures_path = File.expand_path('../../../spec/fixtures/templates', __dir__)
303
304
  return fixtures_path if Dir.exist?(fixtures_path)
304
305
  end
@@ -375,7 +376,8 @@ module TaskerCore
375
376
  end
376
377
 
377
378
  # In test environment, add spec/handlers/examples for test fixtures
378
- if test_environment?
379
+ # (skip when TASKER_SKIP_EXAMPLE_HANDLERS is set — the app provides its own handlers)
380
+ if test_environment? && ENV['TASKER_SKIP_EXAMPLE_HANDLERS'] != 'true'
379
381
  spec_dir = File.expand_path('../../../spec/handlers/examples', __dir__)
380
382
  Dir.glob("#{spec_dir}/**/").each { |dir| paths << dir } if Dir.exist?(spec_dir)
381
383
  end
@@ -423,6 +425,26 @@ module TaskerCore
423
425
  # Register with FULL class name so templates can reference it properly
424
426
  register_handler(handler_class_name, handler_class)
425
427
  registered_count += 1
428
+
429
+ # TAS-294: Also register under the DSL handler_name if it differs from the class name.
430
+ # DSL handlers created via step_handler('dot.notation.name') { ... } have a handler_name
431
+ # instance method that returns the dot-notation callable (e.g., 'linear_workflow_dsl.step_handlers.linear_step_1')
432
+ # but are registered above under their Ruby class name (e.g., 'LinearStep1DslHandler').
433
+ # Templates reference the dot-notation name, so we need both keys in the registry.
434
+ begin
435
+ instance = handler_class.new
436
+ if instance.respond_to?(:handler_name)
437
+ dsl_name = instance.handler_name
438
+ if dsl_name && dsl_name != handler_class_name
439
+ register_handler(dsl_name, handler_class)
440
+ registered_count += 1
441
+ logger.debug("✅ Registered DSL handler alias: #{dsl_name}")
442
+ end
443
+ end
444
+ rescue StandardError
445
+ # Some handlers require config args - skip DSL name registration for those
446
+ end
447
+
426
448
  logger.debug("✅ Registered preloaded test handler: #{handler_class_name}")
427
449
  else
428
450
  logger.debug("⚠️ Preloaded handler class not found: #{handler_class_name}")
@@ -80,7 +80,10 @@ module TaskerCore
80
80
  class_name.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '')
81
81
  end
82
82
 
83
- # Get handler metadata for monitoring and introspection
83
+ # Get handler metadata for monitoring and introspection.
84
+ # Note: VERSION is currently a no-op — not yet wired through FFI bridges
85
+ # to StepExecutionMetadata. Reserved for future observability and
86
+ # compatibility tracking.
84
87
  # @return [Hash] Handler metadata
85
88
  def metadata
86
89
  {
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Functional/block DSL for step handlers (TAS-294).
4
+ #
5
+ # This module provides block-based alternatives to the class-based handler API.
6
+ # It reduces boilerplate for common handler patterns while preserving full access
7
+ # to the underlying StepContext for advanced use cases.
8
+ #
9
+ # The DSL methods auto-wrap return values and classify exceptions:
10
+ # - Hash return -> StepHandlerCallResult.success(result: hash)
11
+ # - StepHandlerCallResult return -> pass through unchanged
12
+ # - PermanentError raised -> StepHandlerCallResult.error(retryable: false)
13
+ # - RetryableError raised -> StepHandlerCallResult.error(retryable: true)
14
+ # - Other errors -> StepHandlerCallResult.error(retryable: true)
15
+ #
16
+ # @example Basic handler
17
+ # include TaskerCore::StepHandler::Functional
18
+ #
19
+ # step_handler "process_payment",
20
+ # depends_on: { cart: "validate_cart" },
21
+ # inputs: [:payment_info] do |cart:, payment_info:, context:|
22
+ #
23
+ # raise TaskerCore::Errors::PermanentError, "Payment info required" unless payment_info
24
+ # result = charge_card(payment_info, cart["total"])
25
+ # { payment_id: result["id"], amount: cart["total"] }
26
+ # end
27
+
28
+ module TaskerCore
29
+ module StepHandler
30
+ module Functional
31
+ # ========================================================================
32
+ # Helper: Decision
33
+ # ========================================================================
34
+
35
+ # Helper for decision handler return values.
36
+ #
37
+ # @example
38
+ # decision_handler "route_order",
39
+ # depends_on: { order: "validate_order" } do |order:, context:|
40
+ # if order["tier"] == "premium"
41
+ # Decision.route(["process_premium"], tier: "premium")
42
+ # else
43
+ # Decision.route(["process_standard"])
44
+ # end
45
+ # end
46
+ class Decision
47
+ attr_reader :type, :steps, :reason, :routing_context
48
+
49
+ def initialize(type, steps, reason, routing_context)
50
+ @type = type
51
+ @steps = steps
52
+ @reason = reason
53
+ @routing_context = routing_context
54
+ freeze
55
+ end
56
+
57
+ # Route to the specified steps.
58
+ #
59
+ # @param steps [Array<String>] Step names to route to
60
+ # @param routing_context [Hash] Additional routing context
61
+ # @return [Decision]
62
+ def self.route(steps, **routing_context)
63
+ new('create_steps', Array(steps), nil, routing_context)
64
+ end
65
+
66
+ # Skip all branches.
67
+ #
68
+ # @param reason [String] Reason for skipping
69
+ # @param routing_context [Hash] Additional routing context
70
+ # @return [Decision]
71
+ def self.skip(reason, **routing_context)
72
+ new('no_branches', [], reason, routing_context)
73
+ end
74
+ end
75
+
76
+ # ========================================================================
77
+ # Helper: BatchConfig
78
+ # ========================================================================
79
+
80
+ # Configuration returned by batch analyzer handlers.
81
+ #
82
+ # @example
83
+ # batch_analyzer "analyze_orders", worker_template: "process_batch" do |context:|
84
+ # BatchConfig.new(total_items: 250, batch_size: 100)
85
+ # end
86
+ class BatchConfig
87
+ attr_reader :total_items, :batch_size, :metadata
88
+
89
+ # @param total_items [Integer] Total number of items to process
90
+ # @param batch_size [Integer] Size of each batch
91
+ # @param metadata [Hash] Optional metadata
92
+ def initialize(total_items:, batch_size:, metadata: {})
93
+ @total_items = total_items
94
+ @batch_size = batch_size
95
+ @metadata = metadata
96
+ freeze
97
+ end
98
+ end
99
+
100
+ # ========================================================================
101
+ # Internal: Auto-wrapping
102
+ # ========================================================================
103
+
104
+ module_function
105
+
106
+ # @api private
107
+ def _wrap_result(result)
108
+ case result
109
+ when Types::StepHandlerCallResult::Success,
110
+ Types::StepHandlerCallResult::Error,
111
+ Types::StepHandlerCallResult::CheckpointYield
112
+ result
113
+ when Hash
114
+ Types::StepHandlerCallResult.success(result: result)
115
+ when nil
116
+ Types::StepHandlerCallResult.success(result: {})
117
+ else
118
+ Types::StepHandlerCallResult.success(result: result.respond_to?(:to_h) ? result.to_h : {})
119
+ end
120
+ end
121
+
122
+ # @api private
123
+ def _wrap_exception(exception)
124
+ Types::StepHandlerCallResult.from_exception(exception)
125
+ end
126
+
127
+ # @api private
128
+ def _inject_args(context, depends_on, inputs)
129
+ args = { context: context }
130
+
131
+ depends_on.each do |param_name, value|
132
+ if value.is_a?(Array) && value.length == 2
133
+ step_name, model_cls = value
134
+ raw = context.get_dependency_result(step_name.to_s)
135
+ args[param_name.to_sym] = if raw.is_a?(Hash)
136
+ # Convert to plain Hash first — ActiveSupport::HashWithIndifferentAccess
137
+ # stores keys as strings internally even after transform_keys(&:to_sym),
138
+ # which causes Dry::Struct to silently ignore all attributes.
139
+ # Hash() is a no-op on HWIA (since it IS-A Hash); .to_h produces
140
+ # a true plain Hash so symbol keys actually stick.
141
+ symbolized = raw.to_h.transform_keys(&:to_sym)
142
+ known = model_cls.attribute_names
143
+ model_cls.new(**symbolized.slice(*known))
144
+ else
145
+ raw
146
+ end
147
+ else
148
+ args[param_name.to_sym] = context.get_dependency_result(value.to_s)
149
+ end
150
+ end
151
+
152
+ if inputs.is_a?(Class)
153
+ model_data = {}
154
+ inputs.attribute_names.each do |attr_name|
155
+ model_data[attr_name] = context.get_input(attr_name.to_s)
156
+ end
157
+ args[:inputs] = inputs.new(**model_data.compact)
158
+ args[:inputs].validate! if args[:inputs].respond_to?(:validate!)
159
+ else
160
+ Array(inputs).each do |input_key|
161
+ args[input_key.to_sym] = context.get_input(input_key.to_s)
162
+ end
163
+ end
164
+
165
+ args
166
+ end
167
+
168
+ # ========================================================================
169
+ # Public DSL Methods
170
+ # ========================================================================
171
+
172
+ # Define a step handler from a block.
173
+ #
174
+ # Returns a StepHandler::Base subclass that can be registered with HandlerRegistry.
175
+ # The block receives injected dependencies, inputs, and context as keyword arguments.
176
+ # Return values are auto-wrapped as success results, and exceptions are
177
+ # auto-classified as failure results.
178
+ #
179
+ # @param name [String] Handler name (must match step definition)
180
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
181
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
182
+ # @param version [String] Handler version (default: "1.0.0")
183
+ # @yield [**args] Block receiving dependencies, inputs, and context as keyword args
184
+ # @return [Class] StepHandler::Base subclass
185
+ #
186
+ # @example
187
+ # ProcessPayment = step_handler "process_payment",
188
+ # depends_on: { cart: "validate_cart" },
189
+ # inputs: [:payment_info] do |cart:, payment_info:, context:|
190
+ #
191
+ # raise TaskerCore::Errors::PermanentError, "Payment info required" unless payment_info
192
+ # result = charge_card(payment_info, cart["total"])
193
+ # { payment_id: result["id"], amount: cart["total"] }
194
+ # end
195
+ def step_handler(name, depends_on: {}, inputs: [], version: '1.0.0', &block)
196
+ raise ArgumentError, 'block required' unless block
197
+
198
+ handler_depends = depends_on
199
+ handler_inputs = inputs
200
+ handler_block = block
201
+
202
+ Class.new(Base) do
203
+ const_set(:VERSION, version)
204
+
205
+ define_method(:handler_name) { name }
206
+
207
+ define_method(:call) do |context|
208
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
209
+ result = handler_block.call(**args)
210
+ Functional._wrap_result(result)
211
+ rescue StandardError => e
212
+ Functional._wrap_exception(e)
213
+ end
214
+ end
215
+ end
216
+
217
+ # Define a decision handler from a block.
218
+ #
219
+ # The block should return a `Decision.route(...)` or `Decision.skip(...)`.
220
+ #
221
+ # @param name [String] Handler name
222
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
223
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
224
+ # @param version [String] Handler version (default: "1.0.0")
225
+ # @yield [**args] Block returning a Decision
226
+ # @return [Class] StepHandler::Base subclass with decision capabilities
227
+ #
228
+ # @example
229
+ # RouteOrder = decision_handler "route_order",
230
+ # depends_on: { order: "validate_order" } do |order:, context:|
231
+ # if order["tier"] == "premium"
232
+ # Decision.route(["process_premium"], tier: "premium")
233
+ # else
234
+ # Decision.route(["process_standard"])
235
+ # end
236
+ # end
237
+ def decision_handler(name, depends_on: {}, inputs: [], version: '1.0.0', &block)
238
+ raise ArgumentError, 'block required' unless block
239
+
240
+ handler_depends = depends_on
241
+ handler_inputs = inputs
242
+ handler_block = block
243
+
244
+ Class.new(Base) do
245
+ include Mixins::Decision
246
+
247
+ const_set(:VERSION, version)
248
+
249
+ define_method(:handler_name) { name }
250
+
251
+ define_method(:call) do |context|
252
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
253
+ raw_result = handler_block.call(**args)
254
+
255
+ case raw_result
256
+ when Types::StepHandlerCallResult::Success,
257
+ Types::StepHandlerCallResult::Error
258
+ raw_result
259
+ when Decision
260
+ result_data = {}
261
+ if raw_result.type == 'create_steps'
262
+ result_data[:routing_context] = raw_result.routing_context unless raw_result.routing_context.empty?
263
+ decision_success(steps: raw_result.steps, result_data: result_data)
264
+ else
265
+ result_data[:reason] = raw_result.reason if raw_result.reason
266
+ result_data[:routing_context] = raw_result.routing_context unless raw_result.routing_context.empty?
267
+ decision_no_branches(result_data: result_data)
268
+ end
269
+ else
270
+ Functional._wrap_result(raw_result)
271
+ end
272
+ rescue StandardError => e
273
+ Functional._wrap_exception(e)
274
+ end
275
+ end
276
+ end
277
+
278
+ # Define a batch analyzer handler.
279
+ #
280
+ # The block should return a `BatchConfig` with `total_items` and `batch_size`.
281
+ # Cursor configs are generated automatically.
282
+ #
283
+ # @param name [String] Handler name
284
+ # @param worker_template [String] Name of the worker template step
285
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
286
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
287
+ # @param version [String] Handler version (default: "1.0.0")
288
+ # @yield [**args] Block returning a BatchConfig
289
+ # @return [Class] StepHandler::Base subclass
290
+ #
291
+ # @example
292
+ # AnalyzeOrders = batch_analyzer "analyze_orders",
293
+ # worker_template: "process_batch" do |context:|
294
+ # BatchConfig.new(total_items: 250, batch_size: 100)
295
+ # end
296
+ def batch_analyzer(name, worker_template:, depends_on: {}, inputs: [], version: '1.0.0', &block)
297
+ raise ArgumentError, 'block required' unless block
298
+
299
+ handler_depends = depends_on
300
+ handler_inputs = inputs
301
+ handler_block = block
302
+ handler_worker_template = worker_template
303
+
304
+ Class.new(Base) do
305
+ include Mixins::Batchable
306
+
307
+ const_set(:VERSION, version)
308
+
309
+ define_method(:handler_name) { name }
310
+
311
+ define_method(:call) do |context|
312
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
313
+ raw_result = handler_block.call(**args)
314
+
315
+ case raw_result
316
+ when Types::StepHandlerCallResult::Success,
317
+ Types::StepHandlerCallResult::Error
318
+ raw_result
319
+ when BatchConfig
320
+ total_items = raw_result.total_items
321
+ batch_size = raw_result.batch_size
322
+ worker_count = (total_items.to_f / batch_size).ceil
323
+
324
+ cursor_configs = create_cursor_configs(total_items, worker_count)
325
+
326
+ create_batches_outcome(
327
+ worker_template_name: handler_worker_template,
328
+ cursor_configs: cursor_configs,
329
+ total_items: total_items,
330
+ metadata: raw_result.metadata
331
+ )
332
+ else
333
+ Functional._wrap_result(raw_result)
334
+ end
335
+ rescue StandardError => e
336
+ Functional._wrap_exception(e)
337
+ end
338
+ end
339
+ end
340
+
341
+ # Define a batch worker handler.
342
+ #
343
+ # The block receives a `batch_context` keyword argument extracted from the
344
+ # step context, containing cursor configuration for this worker's partition.
345
+ #
346
+ # @param name [String] Handler name
347
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
348
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
349
+ # @param version [String] Handler version (default: "1.0.0")
350
+ # @yield [**args] Block receiving batch_context and other injected args
351
+ # @return [Class] StepHandler::Base subclass
352
+ #
353
+ # @example
354
+ # ProcessBatch = batch_worker "process_batch" do |batch_context:, context:|
355
+ # cursor = batch_context&.dig(:cursor_config)
356
+ # # process items from cursor[:start_cursor] to cursor[:end_cursor]
357
+ # { processed: true }
358
+ # end
359
+ def batch_worker(name, depends_on: {}, inputs: [], version: '1.0.0', &block)
360
+ raise ArgumentError, 'block required' unless block
361
+
362
+ handler_depends = depends_on
363
+ handler_inputs = inputs
364
+ handler_block = block
365
+
366
+ Class.new(Base) do
367
+ include Mixins::Batchable
368
+
369
+ const_set(:VERSION, version)
370
+
371
+ define_method(:handler_name) { name }
372
+
373
+ define_method(:call) do |context|
374
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
375
+
376
+ # Delegate batch context extraction to Batchable mixin
377
+ args[:batch_context] = get_batch_context(context)
378
+
379
+ result = handler_block.call(**args)
380
+ Functional._wrap_result(result)
381
+ rescue StandardError => e
382
+ Functional._wrap_exception(e)
383
+ end
384
+ end
385
+ end
386
+
387
+ # Define an API handler from a block.
388
+ #
389
+ # The block receives an `api` keyword argument that provides pre-configured
390
+ # HTTP methods (get, post, put, delete) and result helpers (api_success,
391
+ # api_failure) from the API mixin, in addition to any declared dependencies,
392
+ # inputs, and context.
393
+ #
394
+ # @param name [String] Handler name (must match step definition)
395
+ # @param base_url [String] Base URL for API calls
396
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
397
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
398
+ # @param version [String] Handler version (default: "1.0.0")
399
+ # @param timeout [Integer, nil] Request timeout in seconds
400
+ # @param open_timeout [Integer, nil] Connection open timeout in seconds
401
+ # @param headers [Hash] Default headers for all requests
402
+ # @yield [**args] Block receiving api, dependencies, inputs, and context as keyword args
403
+ # @return [Class] StepHandler::Base subclass with API capabilities
404
+ #
405
+ # @example
406
+ # FetchUser = api_handler "fetch_user",
407
+ # base_url: "https://api.example.com",
408
+ # depends_on: { user_id: "validate_user" } do |user_id:, api:, context:|
409
+ #
410
+ # response = api.get("/users/#{user_id}")
411
+ # body = JSON.parse(response.body)
412
+ # api.api_success(data: body, status: response.status)
413
+ # end
414
+ def api_handler(name, base_url:, depends_on: {}, inputs: [], version: '1.0.0',
415
+ timeout: nil, open_timeout: nil, headers: {}, &block)
416
+ raise ArgumentError, 'block required' unless block
417
+
418
+ handler_depends = depends_on
419
+ handler_inputs = inputs
420
+ handler_block = block
421
+ handler_base_url = base_url
422
+ handler_timeout = timeout
423
+ handler_open_timeout = open_timeout
424
+ handler_headers = headers
425
+
426
+ Class.new(Base) do
427
+ include Mixins::API
428
+
429
+ const_set(:VERSION, version)
430
+
431
+ define_method(:handler_name) { name }
432
+
433
+ define_method(:initialize) do
434
+ super(config: {
435
+ url: handler_base_url,
436
+ timeout: handler_timeout,
437
+ open_timeout: handler_open_timeout,
438
+ headers: handler_headers
439
+ }.compact)
440
+ end
441
+
442
+ define_method(:call) do |context|
443
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
444
+ args[:api] = self
445
+ result = handler_block.call(**args)
446
+ Functional._wrap_result(result)
447
+ rescue StandardError => e
448
+ Functional._wrap_exception(e)
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end
@@ -53,6 +53,7 @@
53
53
  require_relative 'mixins/api'
54
54
  require_relative 'mixins/decision'
55
55
  require_relative 'mixins/batchable'
56
+ require_relative 'functional'
56
57
 
57
58
  module TaskerCore
58
59
  module StepHandler
@@ -94,11 +94,6 @@ module TaskerCore
94
94
  def extract_handlers_from_template(template_data)
95
95
  handlers = []
96
96
 
97
- # Add task handler if present
98
- if template_data['task_handler'] && template_data['task_handler']['callable']
99
- handlers << template_data['task_handler']['callable']
100
- end
101
-
102
97
  # Add step handlers
103
98
  if template_data['steps'].is_a?(Array)
104
99
  template_data['steps'].each do |step|
@@ -139,7 +139,6 @@ module TaskerCore
139
139
 
140
140
  # Environment-specific overrides
141
141
  class EnvironmentOverride < Dry::Struct
142
- attribute :task_handler, HandlerOverride.optional.default(nil)
143
142
  attribute :steps, Types::Array.of(StepOverride).default([].freeze)
144
143
  end
145
144
 
@@ -156,7 +155,6 @@ module TaskerCore
156
155
  # Self-describing structure
157
156
  attribute :description, Types::String.optional.default(nil)
158
157
  attribute :metadata, TemplateMetadata.optional.default(nil)
159
- attribute :task_handler, HandlerDefinition.optional.default(nil)
160
158
  attribute(:system_dependencies, SystemDependencies.default { SystemDependencies.new })
161
159
  attribute :domain_events, Types::Array.of(DomainEventDefinition).default([].freeze)
162
160
  attribute :input_schema, Types::Hash.optional.default(nil) # JSON Schema
@@ -173,10 +171,7 @@ module TaskerCore
173
171
 
174
172
  # Extract all callable references
175
173
  def all_callables
176
- callables = []
177
- callables << task_handler.callable if task_handler
178
- steps.each { |step| callables << step.handler.callable }
179
- callables
174
+ steps.map { |step| step.handler.callable }
180
175
  end
181
176
 
182
177
  # Check if template is valid for registration
@@ -197,11 +192,6 @@ module TaskerCore
197
192
  if environments[environment_name]
198
193
  env_override = environments[environment_name]
199
194
 
200
- # Apply task handler overrides
201
- if env_override.task_handler && resolved_template.task_handler && env_override.task_handler.initialization
202
- resolved_template.task_handler.initialization.merge!(env_override.task_handler.initialization)
203
- end
204
-
205
195
  # Apply step overrides
206
196
  env_override.steps.each do |step_override|
207
197
  if step_override.name == 'ALL'
@@ -3,7 +3,7 @@
3
3
  module TaskerCore
4
4
  # Version synchronization with the core Rust crate
5
5
  # This should be kept in sync with the Cargo.toml version
6
- VERSION = '0.1.5'
6
+ VERSION = '0.1.7'
7
7
 
8
8
  def self.version_info
9
9
  {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tasker-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pete Taylor
@@ -263,12 +263,12 @@ files:
263
263
  - lib/tasker_core/step_handler/base.rb
264
264
  - lib/tasker_core/step_handler/batchable.rb
265
265
  - lib/tasker_core/step_handler/decision.rb
266
+ - lib/tasker_core/step_handler/functional.rb
266
267
  - lib/tasker_core/step_handler/mixins.rb
267
268
  - lib/tasker_core/step_handler/mixins/api.rb
268
269
  - lib/tasker_core/step_handler/mixins/batchable.rb
269
270
  - lib/tasker_core/step_handler/mixins/decision.rb
270
271
  - lib/tasker_core/subscriber.rb
271
- - lib/tasker_core/task_handler/base.rb
272
272
  - lib/tasker_core/template_discovery.rb
273
273
  - lib/tasker_core/tracing.rb
274
274
  - lib/tasker_core/types.rb
@@ -1,254 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'yaml'
4
- require 'logger'
5
- require 'securerandom'
6
-
7
- module TaskerCore
8
- module TaskHandler
9
- class Base
10
- # Ruby task handler for pgmq-based orchestration
11
- #
12
- # This class provides a simplified interface for task processing using the new
13
- # pgmq architecture. Instead of TCP commands, it uses direct pgmq communication
14
- # and the embedded orchestrator for step enqueueing.
15
- #
16
- # Key changes from TCP architecture:
17
- # - No command_client dependencies
18
- # - Direct pgmq messaging for task initialization
19
- # - Embedded orchestrator for step enqueueing
20
- # - Simplified error handling and validation
21
-
22
- attr_reader :logger, :task_config
23
-
24
- def initialize(task_config_path: nil, task_config: nil)
25
- @logger = TaskerCore::Logger.instance
26
- @task_config = task_config || (task_config_path ? load_task_config_from_path(task_config_path) : {})
27
- @pgmq_client = nil # Lazy initialization to avoid database connection during setup
28
- end
29
-
30
- # Get or create pgmq client (lazy initialization)
31
- # @return [TaskerCore::Messaging::PgmqClient] pgmq client instance
32
- def pgmq_client
33
- @pgmq_client ||= TaskerCore::Messaging::PgmqClient.new
34
- end
35
-
36
- # Main task processing method - Rails engine signature: handle(task_uuid)
37
- #
38
- # Mode-aware processing: Uses embedded FFI orchestrator in embedded mode,
39
- # or pure pgmq communication in distributed mode.
40
- #
41
- # @param task_uuid [Integer] ID of the task to process
42
- # @return [Hash] Result of step enqueueing operation
43
- def handle(task_uuid)
44
- unless task_uuid.is_a?(Integer)
45
- raise TaskerCore::Errors::ValidationError.new('task_uuid is required and must be an integer', :task_uuid)
46
- end
47
-
48
- mode = orchestration_mode
49
- logger.info "🚀 Processing task #{task_uuid} with pgmq orchestration (#{mode} mode)"
50
-
51
- case mode
52
- when 'embedded'
53
- handle_embedded_mode(task_uuid)
54
- when 'distributed'
55
- handle_distributed_mode(task_uuid)
56
- else
57
- raise TaskerCore::Errors::OrchestrationError,
58
- "Unknown orchestration mode: #{mode}. Expected 'embedded' or 'distributed'"
59
- end
60
- rescue TaskerCore::Errors::OrchestrationError => e
61
- logger.error "❌ Orchestration error for task #{task_uuid}: #{e.message}"
62
- {
63
- success: false,
64
- task_uuid: task_uuid,
65
- error: e.message,
66
- error_type: 'OrchestrationError',
67
- architecture: 'pgmq',
68
- processed_at: Time.now.utc.iso8601
69
- }
70
- rescue StandardError => e
71
- logger.error "❌ Unexpected error processing task #{task_uuid}: #{e.class.name}: #{e.message}"
72
- {
73
- success: false,
74
- task_uuid: task_uuid,
75
- error: e.message,
76
- error_type: e.class.name,
77
- architecture: 'pgmq',
78
- processed_at: Time.now.utc.iso8601
79
- }
80
- end
81
-
82
- # Initialize a new task with workflow steps
83
- #
84
- # In the pgmq architecture, this sends a task request message to the orchestration
85
- # core monitored task_requests_queue, which will be processed by the Rust orchestrator
86
- # to create the task record and enqueue initial steps.
87
- #
88
- # @param task_request [Hash] Task initialization data
89
- # @return [void] No return value - operation is async via pgmq
90
- def initialize_task(task_request)
91
- logger.info '🚀 Initializing task with pgmq architecture'
92
-
93
- task_request = TaskerCore::Types::TaskTypes::TaskRequest.from_hash(task_request)
94
-
95
- # Prepare task request message for pgmq
96
- task_request_message = {
97
- message_type: 'task_request',
98
- task_request: task_request.to_ffi_hash,
99
- enqueued_at: Time.now.utc.iso8601,
100
- message_id: SecureRandom.uuid
101
- }
102
-
103
- # Send message to task_requests_queue for orchestration core processing
104
- begin
105
- pgmq_client.send_message('task_requests_queue', task_request_message)
106
- logger.info "✅ Task request sent to orchestration queue: #{task_request.namespace}/#{task_request.name}"
107
-
108
- # Return void - this is now an async operation
109
- nil
110
- rescue StandardError => e
111
- logger.error "❌ Failed to send task request to orchestration queue: #{e.message}"
112
- raise TaskerCore::Errors::OrchestrationError, "Failed to send task request: #{e.message}"
113
- end
114
- rescue TaskerCore::Errors::ValidationError => e
115
- logger.error "❌ Validation error initializing task: #{e.message}"
116
- raise e
117
- rescue StandardError => e
118
- logger.error "❌ Unexpected error initializing task: #{e.class.name}: #{e.message}"
119
- raise TaskerCore::Errors::OrchestrationError, "Task initialization failed: #{e.message}"
120
- end
121
-
122
- # Check if the pgmq orchestration system is available and ready
123
- # @return [Boolean] true if system is ready for task processing
124
- def orchestration_ready?
125
- orchestrator = TaskerCore.embedded_orchestrator
126
- orchestrator.running?
127
- rescue StandardError => e
128
- logger.warn "⚠️ Failed to check orchestration status: #{e.message}"
129
- false
130
- end
131
-
132
- # Get status information for this task handler
133
- # @return [Hash] Status information including mode and pgmq connectivity
134
- def status
135
- mode = orchestration_mode
136
-
137
- # Check pgmq availability without forcing connection
138
- pgmq_available = begin
139
- !pgmq_client.nil?
140
- rescue TaskerCore::Errors::Error => e
141
- logger.debug "🔍 PGMQ not available: #{e.message}"
142
- false
143
- end
144
-
145
- status_info = {
146
- handler_type: 'TaskHandler::Base',
147
- architecture: 'pgmq',
148
- orchestration_mode: mode,
149
- orchestration_ready: orchestration_ready?,
150
- pgmq_available: pgmq_available,
151
- task_config_loaded: !task_config.empty?,
152
- checked_at: Time.now.utc.iso8601
153
- }
154
-
155
- # Include embedded orchestrator status only in embedded mode
156
- status_info[:embedded_orchestrator] = embedded_orchestrator_status if mode == 'embedded'
157
-
158
- status_info
159
- end
160
-
161
- private
162
-
163
- def load_task_config_from_path(path)
164
- return {} unless path && File.exist?(path)
165
-
166
- YAML.load_file(path)
167
- rescue StandardError => e
168
- logger.warn "Error loading task configuration: #{e.message}"
169
- {}
170
- end
171
-
172
- def embedded_orchestrator_status
173
- orchestrator = TaskerCore.embedded_orchestrator
174
- {
175
- running: orchestrator.running?,
176
- namespaces: orchestrator.namespaces,
177
- started_at: orchestrator.started_at&.iso8601
178
- }
179
- rescue StandardError => e
180
- logger.warn "⚠️ Failed to get embedded orchestrator status: #{e.message}"
181
- { running: false, error: e.message }
182
- end
183
-
184
- # Determine orchestration mode from configuration
185
- # @return [String] 'embedded' or 'distributed'
186
- def orchestration_mode
187
- config = TaskerCore::Config.instance
188
- mode = config.orchestration_config.mode
189
-
190
- # Default to embedded mode if not specified or in test environment
191
- if mode.nil?
192
- if config.test_environment?
193
- 'embedded'
194
- else
195
- 'distributed'
196
- end
197
- else
198
- mode
199
- end
200
- rescue StandardError => e
201
- logger.warn "⚠️ Failed to determine orchestration mode: #{e.message}, defaulting to distributed"
202
- 'distributed'
203
- end
204
-
205
- # Handle task processing in embedded mode using FFI orchestrator
206
- # @param task_uuid [Integer] ID of the task to process
207
- # @return [Hash] Result of step enqueueing operation
208
- def handle_embedded_mode(task_uuid)
209
- # Use embedded orchestrator to enqueue ready steps for the task
210
- orchestrator = TaskerCore.embedded_orchestrator
211
-
212
- unless orchestrator.running?
213
- raise TaskerCore::Errors::OrchestrationError,
214
- 'Embedded orchestration system not running. Call TaskerCore.start_embedded_orchestration! first.'
215
- end
216
-
217
- # Enqueue steps for the task - this will publish step messages to appropriate queues
218
- result = orchestrator.enqueue_steps(task_uuid)
219
-
220
- logger.info "✅ Task #{task_uuid} step enqueueing completed (embedded): #{result}"
221
-
222
- {
223
- success: true,
224
- task_uuid: task_uuid,
225
- message: result,
226
- mode: 'embedded',
227
- architecture: 'pgmq',
228
- processed_at: Time.now.utc.iso8601
229
- }
230
- end
231
-
232
- # Handle task processing in distributed mode using pure pgmq
233
- # @param task_uuid [Integer] ID of the task to process
234
- # @return [Hash] Result of step enqueueing operation
235
- def handle_distributed_mode(task_uuid)
236
- # In distributed mode, we don't directly enqueue steps via FFI
237
- # Instead, we could publish a task processing request to a queue
238
- # For now, return a message indicating distributed mode handling
239
-
240
- logger.info "✅ Task #{task_uuid} queued for distributed processing"
241
-
242
- {
243
- success: true,
244
- task_uuid: task_uuid,
245
- message: 'Task queued for distributed orchestration processing',
246
- mode: 'distributed',
247
- architecture: 'pgmq',
248
- processed_at: Time.now.utc.iso8601,
249
- note: 'Phase 4.5 will complete distributed orchestration integration'
250
- }
251
- end
252
- end
253
- end
254
- end