tasker-rb 0.1.5-x86_64-linux → 0.1.6-x86_64-linux

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: f8859208dd15658a073e8cf86d4e8f0ddf83eb845a9515da44afbacd413af24b
4
- data.tar.gz: ed71d5a81595ca230b6691fc91707b765025cc3cc04b229889384648c0f88b34
3
+ metadata.gz: 6ad2e7070082b3ce68f86829a83781eaf94b1fbcecfe32bcbbd3f3c668d47d25
4
+ data.tar.gz: 05ac453ea270917bfafc5558e430d6129b2abba4aaffda3ec58467c60c5725d4
5
5
  SHA512:
6
- metadata.gz: 9a03dd9aca141e0ba0394ebc05c893a2348af2c15100a3d19607b6f49c8182a07c7966f543842cef96a14328aaadff40621c89ea743df43f243ebc76bfcf48b1
7
- data.tar.gz: 2a18b8ba4f876cbe8d5b02134447768bd33e962785fee22a66e115aa80dbf4ae1b10e99cb6cf8af2bba6ab454bca8e4a8f3f4c1b810bbb35c63b3838ca4ed5f0
6
+ metadata.gz: 4c8796a036d3ed7e9cfd5818c09ebd8677b7a841721e6e36cce81d787280390d1a8dcd5e312e688f2602afc40d44fc5c560ad5c74039d327f4456a355d8ec779
7
+ data.tar.gz: 5a8b5d080096c7ba5c106e59056132ba8e72319bb0775a2de096c10acb1185bde3cbda0672d800a8d71fe9c5e1c11c4a4802adcb0cd9a640dab8e7b38634af75
@@ -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
@@ -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,452 @@
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
+ symbolized = Hash(raw).transform_keys(&:to_sym)
140
+ known = model_cls.attribute_names
141
+ model_cls.new(**symbolized.slice(*known))
142
+ else
143
+ raw
144
+ end
145
+ else
146
+ args[param_name.to_sym] = context.get_dependency_result(value.to_s)
147
+ end
148
+ end
149
+
150
+ if inputs.is_a?(Class)
151
+ model_data = {}
152
+ inputs.attribute_names.each do |attr_name|
153
+ model_data[attr_name] = context.get_input(attr_name.to_s)
154
+ end
155
+ args[:inputs] = inputs.new(**model_data.compact)
156
+ args[:inputs].validate! if args[:inputs].respond_to?(:validate!)
157
+ else
158
+ Array(inputs).each do |input_key|
159
+ args[input_key.to_sym] = context.get_input(input_key.to_s)
160
+ end
161
+ end
162
+
163
+ args
164
+ end
165
+
166
+ # ========================================================================
167
+ # Public DSL Methods
168
+ # ========================================================================
169
+
170
+ # Define a step handler from a block.
171
+ #
172
+ # Returns a StepHandler::Base subclass that can be registered with HandlerRegistry.
173
+ # The block receives injected dependencies, inputs, and context as keyword arguments.
174
+ # Return values are auto-wrapped as success results, and exceptions are
175
+ # auto-classified as failure results.
176
+ #
177
+ # @param name [String] Handler name (must match step definition)
178
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
179
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
180
+ # @param version [String] Handler version (default: "1.0.0")
181
+ # @yield [**args] Block receiving dependencies, inputs, and context as keyword args
182
+ # @return [Class] StepHandler::Base subclass
183
+ #
184
+ # @example
185
+ # ProcessPayment = step_handler "process_payment",
186
+ # depends_on: { cart: "validate_cart" },
187
+ # inputs: [:payment_info] do |cart:, payment_info:, context:|
188
+ #
189
+ # raise TaskerCore::Errors::PermanentError, "Payment info required" unless payment_info
190
+ # result = charge_card(payment_info, cart["total"])
191
+ # { payment_id: result["id"], amount: cart["total"] }
192
+ # end
193
+ def step_handler(name, depends_on: {}, inputs: [], version: '1.0.0', &block)
194
+ raise ArgumentError, 'block required' unless block
195
+
196
+ handler_depends = depends_on
197
+ handler_inputs = inputs
198
+ handler_block = block
199
+
200
+ Class.new(Base) do
201
+ const_set(:VERSION, version)
202
+
203
+ define_method(:handler_name) { name }
204
+
205
+ define_method(:call) do |context|
206
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
207
+ result = handler_block.call(**args)
208
+ Functional._wrap_result(result)
209
+ rescue StandardError => e
210
+ Functional._wrap_exception(e)
211
+ end
212
+ end
213
+ end
214
+
215
+ # Define a decision handler from a block.
216
+ #
217
+ # The block should return a `Decision.route(...)` or `Decision.skip(...)`.
218
+ #
219
+ # @param name [String] Handler name
220
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
221
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
222
+ # @param version [String] Handler version (default: "1.0.0")
223
+ # @yield [**args] Block returning a Decision
224
+ # @return [Class] StepHandler::Base subclass with decision capabilities
225
+ #
226
+ # @example
227
+ # RouteOrder = decision_handler "route_order",
228
+ # depends_on: { order: "validate_order" } do |order:, context:|
229
+ # if order["tier"] == "premium"
230
+ # Decision.route(["process_premium"], tier: "premium")
231
+ # else
232
+ # Decision.route(["process_standard"])
233
+ # end
234
+ # end
235
+ def decision_handler(name, depends_on: {}, inputs: [], version: '1.0.0', &block)
236
+ raise ArgumentError, 'block required' unless block
237
+
238
+ handler_depends = depends_on
239
+ handler_inputs = inputs
240
+ handler_block = block
241
+
242
+ Class.new(Base) do
243
+ include Mixins::Decision
244
+
245
+ const_set(:VERSION, version)
246
+
247
+ define_method(:handler_name) { name }
248
+
249
+ define_method(:call) do |context|
250
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
251
+ raw_result = handler_block.call(**args)
252
+
253
+ case raw_result
254
+ when Types::StepHandlerCallResult::Success,
255
+ Types::StepHandlerCallResult::Error
256
+ raw_result
257
+ when Decision
258
+ result_data = {}
259
+ if raw_result.type == 'create_steps'
260
+ result_data[:routing_context] = raw_result.routing_context unless raw_result.routing_context.empty?
261
+ decision_success(steps: raw_result.steps, result_data: result_data)
262
+ else
263
+ result_data[:reason] = raw_result.reason if raw_result.reason
264
+ result_data[:routing_context] = raw_result.routing_context unless raw_result.routing_context.empty?
265
+ decision_no_branches(result_data: result_data)
266
+ end
267
+ else
268
+ Functional._wrap_result(raw_result)
269
+ end
270
+ rescue StandardError => e
271
+ Functional._wrap_exception(e)
272
+ end
273
+ end
274
+ end
275
+
276
+ # Define a batch analyzer handler.
277
+ #
278
+ # The block should return a `BatchConfig` with `total_items` and `batch_size`.
279
+ # Cursor configs are generated automatically.
280
+ #
281
+ # @param name [String] Handler name
282
+ # @param worker_template [String] Name of the worker template step
283
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
284
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
285
+ # @param version [String] Handler version (default: "1.0.0")
286
+ # @yield [**args] Block returning a BatchConfig
287
+ # @return [Class] StepHandler::Base subclass
288
+ #
289
+ # @example
290
+ # AnalyzeOrders = batch_analyzer "analyze_orders",
291
+ # worker_template: "process_batch" do |context:|
292
+ # BatchConfig.new(total_items: 250, batch_size: 100)
293
+ # end
294
+ def batch_analyzer(name, worker_template:, depends_on: {}, inputs: [], version: '1.0.0', &block)
295
+ raise ArgumentError, 'block required' unless block
296
+
297
+ handler_depends = depends_on
298
+ handler_inputs = inputs
299
+ handler_block = block
300
+ handler_worker_template = worker_template
301
+
302
+ Class.new(Base) do
303
+ include Mixins::Batchable
304
+
305
+ const_set(:VERSION, version)
306
+
307
+ define_method(:handler_name) { name }
308
+
309
+ define_method(:call) do |context|
310
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
311
+ raw_result = handler_block.call(**args)
312
+
313
+ case raw_result
314
+ when Types::StepHandlerCallResult::Success,
315
+ Types::StepHandlerCallResult::Error
316
+ raw_result
317
+ when BatchConfig
318
+ total_items = raw_result.total_items
319
+ batch_size = raw_result.batch_size
320
+ worker_count = (total_items.to_f / batch_size).ceil
321
+
322
+ cursor_configs = create_cursor_configs(total_items, worker_count)
323
+
324
+ create_batches_outcome(
325
+ worker_template_name: handler_worker_template,
326
+ cursor_configs: cursor_configs,
327
+ total_items: total_items,
328
+ metadata: raw_result.metadata
329
+ )
330
+ else
331
+ Functional._wrap_result(raw_result)
332
+ end
333
+ rescue StandardError => e
334
+ Functional._wrap_exception(e)
335
+ end
336
+ end
337
+ end
338
+
339
+ # Define a batch worker handler.
340
+ #
341
+ # The block receives a `batch_context` keyword argument extracted from the
342
+ # step context, containing cursor configuration for this worker's partition.
343
+ #
344
+ # @param name [String] Handler name
345
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
346
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
347
+ # @param version [String] Handler version (default: "1.0.0")
348
+ # @yield [**args] Block receiving batch_context and other injected args
349
+ # @return [Class] StepHandler::Base subclass
350
+ #
351
+ # @example
352
+ # ProcessBatch = batch_worker "process_batch" do |batch_context:, context:|
353
+ # cursor = batch_context&.dig(:cursor_config)
354
+ # # process items from cursor[:start_cursor] to cursor[:end_cursor]
355
+ # { processed: true }
356
+ # end
357
+ def batch_worker(name, depends_on: {}, inputs: [], version: '1.0.0', &block)
358
+ raise ArgumentError, 'block required' unless block
359
+
360
+ handler_depends = depends_on
361
+ handler_inputs = inputs
362
+ handler_block = block
363
+
364
+ Class.new(Base) do
365
+ include Mixins::Batchable
366
+
367
+ const_set(:VERSION, version)
368
+
369
+ define_method(:handler_name) { name }
370
+
371
+ define_method(:call) do |context|
372
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
373
+
374
+ # Delegate batch context extraction to Batchable mixin
375
+ args[:batch_context] = get_batch_context(context)
376
+
377
+ result = handler_block.call(**args)
378
+ Functional._wrap_result(result)
379
+ rescue StandardError => e
380
+ Functional._wrap_exception(e)
381
+ end
382
+ end
383
+ end
384
+
385
+ # Define an API handler from a block.
386
+ #
387
+ # The block receives an `api` keyword argument that provides pre-configured
388
+ # HTTP methods (get, post, put, delete) and result helpers (api_success,
389
+ # api_failure) from the API mixin, in addition to any declared dependencies,
390
+ # inputs, and context.
391
+ #
392
+ # @param name [String] Handler name (must match step definition)
393
+ # @param base_url [String] Base URL for API calls
394
+ # @param depends_on [Hash] Mapping of parameter names to dependency step names
395
+ # @param inputs [Array<Symbol, String>] Task context input keys to inject
396
+ # @param version [String] Handler version (default: "1.0.0")
397
+ # @param timeout [Integer, nil] Request timeout in seconds
398
+ # @param open_timeout [Integer, nil] Connection open timeout in seconds
399
+ # @param headers [Hash] Default headers for all requests
400
+ # @yield [**args] Block receiving api, dependencies, inputs, and context as keyword args
401
+ # @return [Class] StepHandler::Base subclass with API capabilities
402
+ #
403
+ # @example
404
+ # FetchUser = api_handler "fetch_user",
405
+ # base_url: "https://api.example.com",
406
+ # depends_on: { user_id: "validate_user" } do |user_id:, api:, context:|
407
+ #
408
+ # response = api.get("/users/#{user_id}")
409
+ # body = JSON.parse(response.body)
410
+ # api.api_success(data: body, status: response.status)
411
+ # end
412
+ def api_handler(name, base_url:, depends_on: {}, inputs: [], version: '1.0.0',
413
+ timeout: nil, open_timeout: nil, headers: {}, &block)
414
+ raise ArgumentError, 'block required' unless block
415
+
416
+ handler_depends = depends_on
417
+ handler_inputs = inputs
418
+ handler_block = block
419
+ handler_base_url = base_url
420
+ handler_timeout = timeout
421
+ handler_open_timeout = open_timeout
422
+ handler_headers = headers
423
+
424
+ Class.new(Base) do
425
+ include Mixins::API
426
+
427
+ const_set(:VERSION, version)
428
+
429
+ define_method(:handler_name) { name }
430
+
431
+ define_method(:initialize) do
432
+ super(config: {
433
+ url: handler_base_url,
434
+ timeout: handler_timeout,
435
+ open_timeout: handler_open_timeout,
436
+ headers: handler_headers
437
+ }.compact)
438
+ end
439
+
440
+ define_method(:call) do |context|
441
+ args = Functional._inject_args(context, handler_depends, handler_inputs)
442
+ args[:api] = self
443
+ result = handler_block.call(**args)
444
+ Functional._wrap_result(result)
445
+ rescue StandardError => e
446
+ Functional._wrap_exception(e)
447
+ end
448
+ end
449
+ end
450
+ end
451
+ end
452
+ 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
Binary file
@@ -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|
@@ -16,8 +16,13 @@ module TaskerCore
16
16
  @template_fixtures_path = File.expand_path('../../spec/fixtures/templates', __dir__)
17
17
  end
18
18
 
19
- # Check if we should load test environment components
19
+ # Check if we should load test environment components.
20
+ # Apps using tasker-rb via a local path: dependency can set
21
+ # TASKER_SKIP_EXAMPLE_HANDLERS=true to prevent the gem's spec
22
+ # handlers from shadowing the app's own handlers.
20
23
  def should_load_test_environment?
24
+ return false if ENV['TASKER_SKIP_EXAMPLE_HANDLERS'] == 'true'
25
+
21
26
  test_env = ENV['TASKER_ENV']&.downcase == 'test'
22
27
  rails_test_env = ENV['RAILS_ENV']&.downcase == 'test'
23
28
  force_examples = ENV['TASKER_FORCE_EXAMPLE_HANDLERS'] == 'true'
@@ -176,6 +181,14 @@ module TaskerCore
176
181
  handler_files = Dir.glob("#{@example_handlers_path}/**/*_handler.rb")
177
182
  log_debug("🔍 Found #{handler_files.count} example handler files")
178
183
 
184
+ # TAS-294: Also load DSL example handlers (functional API mirrors)
185
+ dsl_examples_path = @example_handlers_path.sub(%r{/examples$}, '/dsl_examples')
186
+ if Dir.exist?(dsl_examples_path)
187
+ dsl_files = Dir.glob("#{dsl_examples_path}/**/*.rb")
188
+ log_debug("🔍 Found #{dsl_files.count} DSL example handler files")
189
+ handler_files += dsl_files
190
+ end
191
+
179
192
  loaded_count = 0
180
193
  handler_files.each do |handler_file|
181
194
  # Use require instead of require_relative to avoid duplicate loading
@@ -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
@@ -174,7 +172,6 @@ module TaskerCore
174
172
  # Extract all callable references
175
173
  def all_callables
176
174
  callables = []
177
- callables << task_handler.callable if task_handler
178
175
  steps.each { |step| callables << step.handler.callable }
179
176
  callables
180
177
  end
@@ -197,11 +194,6 @@ module TaskerCore
197
194
  if environments[environment_name]
198
195
  env_override = environments[environment_name]
199
196
 
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
197
  # Apply step overrides
206
198
  env_override.steps.each do |step_override|
207
199
  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.6'
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.6
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Pete Taylor
@@ -231,12 +231,12 @@ files:
231
231
  - lib/tasker_core/step_handler/base.rb
232
232
  - lib/tasker_core/step_handler/batchable.rb
233
233
  - lib/tasker_core/step_handler/decision.rb
234
+ - lib/tasker_core/step_handler/functional.rb
234
235
  - lib/tasker_core/step_handler/mixins.rb
235
236
  - lib/tasker_core/step_handler/mixins/api.rb
236
237
  - lib/tasker_core/step_handler/mixins/batchable.rb
237
238
  - lib/tasker_core/step_handler/mixins/decision.rb
238
239
  - lib/tasker_core/subscriber.rb
239
- - lib/tasker_core/task_handler/base.rb
240
240
  - lib/tasker_core/tasker_rb.so
241
241
  - lib/tasker_core/template_discovery.rb
242
242
  - lib/tasker_core/test_environment.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