tasker-rb 0.1.3-arm64-darwin

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +55 -0
  3. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  4. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  5. data/lib/tasker_core/bootstrap.rb +394 -0
  6. data/lib/tasker_core/client.rb +165 -0
  7. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  8. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  9. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  10. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  11. data/lib/tasker_core/domain_events.rb +43 -0
  12. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  13. data/lib/tasker_core/errors/common.rb +305 -0
  14. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  15. data/lib/tasker_core/errors.rb +4 -0
  16. data/lib/tasker_core/event_bridge.rb +330 -0
  17. data/lib/tasker_core/handlers.rb +159 -0
  18. data/lib/tasker_core/internal.rb +31 -0
  19. data/lib/tasker_core/logger.rb +234 -0
  20. data/lib/tasker_core/models.rb +337 -0
  21. data/lib/tasker_core/observability/types.rb +158 -0
  22. data/lib/tasker_core/observability.rb +292 -0
  23. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  24. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  25. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  26. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  27. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  28. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  29. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  30. data/lib/tasker_core/registry/resolvers.rb +42 -0
  31. data/lib/tasker_core/registry.rb +12 -0
  32. data/lib/tasker_core/step_handler/api.rb +48 -0
  33. data/lib/tasker_core/step_handler/base.rb +354 -0
  34. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  35. data/lib/tasker_core/step_handler/decision.rb +53 -0
  36. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  37. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  38. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  39. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  40. data/lib/tasker_core/subscriber.rb +212 -0
  41. data/lib/tasker_core/task_handler/base.rb +254 -0
  42. data/lib/tasker_core/tasker_rb.bundle +0 -0
  43. data/lib/tasker_core/template_discovery.rb +181 -0
  44. data/lib/tasker_core/test_environment.rb +313 -0
  45. data/lib/tasker_core/tracing.rb +166 -0
  46. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  47. data/lib/tasker_core/types/client_types.rb +145 -0
  48. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  49. data/lib/tasker_core/types/error_types.rb +72 -0
  50. data/lib/tasker_core/types/simple_message.rb +151 -0
  51. data/lib/tasker_core/types/step_context.rb +328 -0
  52. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  53. data/lib/tasker_core/types/step_message.rb +112 -0
  54. data/lib/tasker_core/types/step_types.rb +207 -0
  55. data/lib/tasker_core/types/task_template.rb +240 -0
  56. data/lib/tasker_core/types/task_types.rb +148 -0
  57. data/lib/tasker_core/types.rb +132 -0
  58. data/lib/tasker_core/version.rb +13 -0
  59. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  60. data/lib/tasker_core/worker/event_poller.rb +224 -0
  61. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  62. data/lib/tasker_core.rb +161 -0
  63. metadata +292 -0
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module Worker
5
+ # Bootstrap orchestrator for Ruby worker
6
+ #
7
+ # Manages the complete initialization and lifecycle of the Ruby worker system,
8
+ # coordinating both Rust foundation layer and Ruby business logic components.
9
+ # This is the primary entry point for starting a TaskerCore worker process.
10
+ #
11
+ # The bootstrap process follows this sequence:
12
+ # 1. **Initialize Ruby Components**: EventBridge, HandlerRegistry, Subscribers
13
+ # 2. **Bootstrap Rust Foundation**: Start Rust worker via FFI
14
+ # 3. **Start Event Processing**: Begin EventPoller for step execution
15
+ # 4. **Register Shutdown Handlers**: Setup graceful termination on signals
16
+ #
17
+ # The Bootstrap class is a singleton, ensuring only one worker instance runs
18
+ # per process. It handles the complex coordination between Ruby and Rust,
19
+ # managing lifecycle transitions and providing health monitoring.
20
+ #
21
+ # @example Basic worker startup
22
+ # # Start with default configuration
23
+ # bootstrap = TaskerCore::Worker::Bootstrap.start!
24
+ # # => Returns bootstrap instance in :running status
25
+ #
26
+ # # Check if worker is running
27
+ # bootstrap.running?
28
+ # # => true
29
+ #
30
+ # @example Custom configuration
31
+ # bootstrap = TaskerCore::Worker::Bootstrap.start!(
32
+ # worker_id: "custom-worker-1",
33
+ # enable_web_api: false,
34
+ # event_driven_enabled: true,
35
+ # deployment_mode: "Hybrid", # PollingOnly, EventDrivenOnly, or Hybrid
36
+ # namespaces: ["payments", "fulfillment", "notifications"]
37
+ # )
38
+ #
39
+ # @example Health checking
40
+ # status = TaskerCore::Worker::Bootstrap.instance.health_check
41
+ # # => {
42
+ # # healthy: true,
43
+ # # status: :running,
44
+ # # rust: { running: true, worker_core_status: "processing" },
45
+ # # ruby: {
46
+ # # status: :running,
47
+ # # event_bridge_active: true,
48
+ # # handlers_registered: 12,
49
+ # # subscriber_active: true,
50
+ # # event_poller_active: true
51
+ # # }
52
+ # # }
53
+ #
54
+ # @example Graceful shutdown
55
+ # # Shutdown cleanly, completing in-flight work
56
+ # TaskerCore::Worker::Bootstrap.instance.shutdown!
57
+ # # => Stops Rust worker, Ruby components, runs shutdown handlers
58
+ #
59
+ # @example Custom shutdown handlers
60
+ # bootstrap = TaskerCore::Worker::Bootstrap.instance
61
+ # bootstrap.on_shutdown do
62
+ # puts "Cleaning up resources..."
63
+ # cleanup_database_connections
64
+ # flush_metrics
65
+ # end
66
+ #
67
+ # @example Getting comprehensive status
68
+ # status = bootstrap.status
69
+ # # => {
70
+ # # rust: { running: true, ... },
71
+ # # ruby: {
72
+ # # status: :running,
73
+ # # handle_stored: true,
74
+ # # handle_id: "550e8400-e29b-41d4-a716-446655440000",
75
+ # # worker_id: "ruby-worker-123",
76
+ # # event_bridge_active: true,
77
+ # # handler_registry_size: 12,
78
+ # # subscriber_active: true,
79
+ # # event_poller_active: true
80
+ # # }
81
+ # # }
82
+ #
83
+ # Deployment Modes:
84
+ # - **PollingOnly**: Traditional polling-based coordination (highest latency, most reliable)
85
+ # - **EventDrivenOnly**: Pure event-driven using PostgreSQL LISTEN/NOTIFY (lowest latency)
86
+ # - **Hybrid**: Event-driven with polling fallback (recommended for production)
87
+ #
88
+ # Worker States:
89
+ # - **:initialized**: Created but not started
90
+ # - **:running**: Fully operational and processing events
91
+ # - **:shutting_down**: Graceful shutdown in progress
92
+ # - **:stopped**: Completely shut down
93
+ #
94
+ # Signal Handling:
95
+ # The bootstrap automatically registers signal handlers for:
96
+ # - **SIGINT** (Ctrl+C): Graceful shutdown
97
+ # - **SIGTERM**: Graceful shutdown
98
+ # - **at_exit**: Cleanup on process exit
99
+ #
100
+ # @see TaskerCore::Worker::EventPoller For event polling details
101
+ # @see TaskerCore::EventBridge For event coordination
102
+ # @see TaskerCore::Registry::HandlerRegistry For handler management
103
+ # @see TaskerCore::FFI For Rust FFI operations
104
+ class Bootstrap
105
+ include Singleton
106
+
107
+ attr_reader :logger, :config, :rust_handle
108
+
109
+ def initialize
110
+ @logger = TaskerCore::Logger.instance
111
+ @status = :initialized
112
+ @shutdown_handlers = []
113
+ @rust_handle = nil
114
+ end
115
+
116
+ # Start the worker system with optional configuration
117
+ def self.start!
118
+ instance.start!
119
+ end
120
+
121
+ # Main boots
122
+ def start!
123
+ logger.info 'Starting Ruby worker bootstrap'
124
+
125
+ # Initialize Ruby components first
126
+ initialize_ruby_components!
127
+
128
+ # Bootstrap Rust foundation via FFI
129
+ bootstrap_rust_foundation!
130
+
131
+ # Start event processing
132
+ start_event_processing!
133
+
134
+ # Register shutdown handlers
135
+ register_shutdown_handlers!
136
+
137
+ @status = :running
138
+ logger.info 'Ruby worker system started successfully'
139
+
140
+ self
141
+ rescue StandardError => e
142
+ logger.error "Failed to start worker: #{e.message}"
143
+ logger.error e.backtrace.join("\n")
144
+ shutdown!
145
+ raise
146
+ end
147
+
148
+ # Check if worker is running
149
+ def running?
150
+ @status == :running && rust_worker_running?
151
+ end
152
+
153
+ # Check if Rust handle is valid and running
154
+ def rust_handle_running?
155
+ return false unless @rust_handle.is_a?(Hash) &&
156
+ (@rust_handle[:handle_id] || @rust_handle['handle_id'])
157
+
158
+ rust_worker_running?
159
+ end
160
+
161
+ # Get comprehensive status
162
+ def status
163
+ rust_status = TaskerCore::FFI.worker_status
164
+ {
165
+ rust: rust_status,
166
+ ruby: {
167
+ status: @status,
168
+ handle_stored: !@rust_handle.nil?,
169
+ handle_id: @rust_handle&.dig('handle_id') || @rust_handle&.dig(:handle_id),
170
+ worker_id: @rust_handle&.dig('worker_id') || @rust_handle&.dig(:worker_id),
171
+ event_bridge_active: EventBridge.instance.active?,
172
+ handler_registry_size: Registry::HandlerRegistry.instance.handlers.size,
173
+ subscriber_active: @step_subscriber&.active? || false,
174
+ event_poller_active: EventPoller.instance.active?
175
+ }
176
+ }
177
+ rescue StandardError => e
178
+ logger.error "Failed to get status: #{e.message}"
179
+ { error: e.message, status: @status }
180
+ end
181
+
182
+ # Execute a block with the Rust handle, bootstrapping if necessary
183
+ def with_rust_handle(&block)
184
+ bootstrap_rust_foundation! unless rust_handle_running?
185
+ block.call
186
+ end
187
+
188
+ # Graceful shutdown
189
+ def shutdown!
190
+ return if @status == :stopped
191
+
192
+ logger.info 'Initiating graceful shutdown'
193
+ @status = :shutting_down
194
+
195
+ # Transition Rust to graceful shutdown first
196
+ if @rust_handle
197
+ begin
198
+ TaskerCore::FFI.transition_to_graceful_shutdown
199
+ rescue StandardError => e
200
+ logger.error "Failed to transition to graceful shutdown: #{e.message}"
201
+ end
202
+ end
203
+
204
+ # Stop Ruby components
205
+ @step_subscriber&.stop!
206
+ EventBridge.instance.stop!
207
+ EventPoller.instance.stop!
208
+
209
+ # TAS-65: Stop domain event components
210
+ DomainEvents::SubscriberRegistry.instance.stop_all!
211
+ InProcessDomainEventPoller.instance.stop!
212
+
213
+ # Stop Rust worker and clear handle
214
+ if @rust_handle
215
+ begin
216
+ TaskerCore::FFI.stop_worker
217
+ rescue StandardError => e
218
+ logger.error "Failed to stop Rust worker: #{e.message}"
219
+ ensure
220
+ @rust_handle = nil
221
+ end
222
+ end
223
+
224
+ # Run custom shutdown handlers
225
+ @shutdown_handlers.each do |handler|
226
+ handler.call
227
+ rescue StandardError
228
+ nil
229
+ end
230
+
231
+ @status = :stopped
232
+ logger.info 'Worker shutdown complete'
233
+ end
234
+
235
+ # Register custom shutdown handler
236
+ def on_shutdown(&block)
237
+ @shutdown_handlers << block if block_given?
238
+ end
239
+
240
+ # Perform health check on both Ruby and Rust components
241
+ def health_check
242
+ return { healthy: false, status: @status, error: 'not_running' } unless running?
243
+
244
+ begin
245
+ # Get Rust worker status
246
+ rust_status = TaskerCore::FFI.worker_status
247
+ rust_running = rust_status['running'] || rust_status[:running]
248
+
249
+ # Check Ruby components
250
+ ruby_healthy = @status == :running &&
251
+ EventBridge.instance.active? &&
252
+ EventPoller.instance.active? &&
253
+ Registry::HandlerRegistry.instance.handlers.any?
254
+
255
+ overall_healthy = rust_running && ruby_healthy
256
+
257
+ {
258
+ healthy: overall_healthy,
259
+ status: @status,
260
+ rust: {
261
+ running: rust_running,
262
+ worker_core_status: rust_status['worker_core_status'] || rust_status[:worker_core_status]
263
+ },
264
+ ruby: {
265
+ status: @status,
266
+ event_bridge_active: EventBridge.instance.active?,
267
+ handlers_registered: Registry::HandlerRegistry.instance.handlers.size,
268
+ subscriber_active: @step_subscriber&.active? || false,
269
+ event_poller_active: EventPoller.instance.active?,
270
+ # TAS-65: Domain event system status
271
+ domain_event_poller_active: InProcessDomainEventPoller.instance.active?,
272
+ domain_event_subscribers: DomainEvents::SubscriberRegistry.instance.stats
273
+ }
274
+ }
275
+ rescue StandardError => e
276
+ logger.error "Health check failed: #{e.message}"
277
+ {
278
+ healthy: false,
279
+ status: @status,
280
+ error: e.message
281
+ }
282
+ end
283
+ end
284
+
285
+ private
286
+
287
+ def detect_namespaces
288
+ # Auto-detect from registered handlers
289
+ Registry::HandlerRegistry.instance.registered_handlers.map do |handler_class|
290
+ handler_class.namespace if handler_class.respond_to?(:namespace)
291
+ end.compact.uniq
292
+ end
293
+
294
+ def initialize_ruby_components!
295
+ logger.info 'Initializing Ruby components...'
296
+
297
+ # Initialize event bridge
298
+ EventBridge.instance
299
+
300
+ # Initialize handler registry (bootstrap happens automatically)
301
+ Registry::HandlerRegistry.instance
302
+
303
+ # Initialize step execution subscriber
304
+ @step_subscriber = StepExecutionSubscriber.new
305
+
306
+ logger.info 'Ruby components initialized'
307
+ end
308
+
309
+ def bootstrap_rust_foundation!
310
+ # Check if we already have a running handle
311
+ if rust_handle_running?
312
+ logger.debug 'Rust worker foundation already running, reusing handle'
313
+ return @rust_handle
314
+ end
315
+
316
+ logger.info 'Bootstrapping Rust worker foundation...'
317
+
318
+ # Bootstrap the worker and store the handle result
319
+ result = TaskerCore::FFI.bootstrap_worker
320
+ logger.info "Rust bootstrap result: #{result.inspect}"
321
+
322
+ # Check if it was already running or newly started
323
+ # Handle both string and symbol keys from Rust FFI
324
+ status = result['status'] || result[:status]
325
+ worker_id = result['worker_id'] || result[:worker_id]
326
+
327
+ if status == 'already_running'
328
+ logger.info 'Worker system was already running, reusing existing handle'
329
+ elsif status == 'started'
330
+ logger.info "New worker system started with ID: #{worker_id}"
331
+ else
332
+ raise "Unexpected bootstrap status: #{status}"
333
+ end
334
+
335
+ # Store the handle information
336
+ @rust_handle = result
337
+
338
+ # Verify it's running
339
+ status = TaskerCore::FFI.worker_status
340
+ # Handle both string and symbol keys from Rust FFI
341
+ running = status['running'] || status[:running]
342
+ unless running
343
+ @rust_handle = nil
344
+ raise "Rust worker failed to start: #{status.inspect}"
345
+ end
346
+
347
+ handle_id = @rust_handle['handle_id'] || @rust_handle[:handle_id]
348
+ logger.info "Rust foundation bootstrapped with handle: #{handle_id}"
349
+ @rust_handle
350
+ end
351
+
352
+ def start_event_processing!
353
+ logger.info 'Starting event processing...'
354
+
355
+ # Start the EventPoller to poll for step execution events from Rust
356
+ EventPoller.instance.start!
357
+
358
+ # NOTE: StepExecutionSubscriber already subscribes to step execution events
359
+ # in its initializer, so we don't need to subscribe again here.
360
+ # Duplicate subscriptions cause the same event to be processed twice,
361
+ # leading to double state transitions.
362
+
363
+ # TAS-65: Start in-process domain event poller for fast events
364
+ InProcessDomainEventPoller.instance.start!
365
+
366
+ # TAS-65: Start domain event subscribers
367
+ DomainEvents::SubscriberRegistry.instance.start_all!
368
+
369
+ logger.info 'Event processing started (step events + domain events)'
370
+ end
371
+
372
+ def register_shutdown_handlers!
373
+ # Graceful shutdown on signals
374
+ %w[INT TERM].each do |signal|
375
+ Signal.trap(signal) do
376
+ Thread.new { shutdown! }
377
+ end
378
+ end
379
+
380
+ # Shutdown on exit
381
+ at_exit { shutdown! if running? }
382
+ end
383
+
384
+ def rust_worker_running?
385
+ status = TaskerCore::FFI.worker_status
386
+ # Handle both string and symbol keys from Rust FFI
387
+ running = status['running'] || status[:running]
388
+ running == true
389
+ rescue StandardError
390
+ false
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ # High-level client wrapper around the TaskerCore FFI client methods.
5
+ #
6
+ # The raw FFI exposes `TaskerCore::FFI.client_create_task(hash)` and similar
7
+ # methods that require callers to construct complete request hashes with all
8
+ # required fields (initiator, source_system, reason, etc.) and return plain
9
+ # hashes. This module provides keyword-argument methods with sensible defaults
10
+ # and wraps responses into typed `ClientTypes::*` Dry::Struct objects.
11
+ #
12
+ # @example Creating a task with defaults
13
+ # response = TaskerCore::Client.create_task(
14
+ # name: 'process_order',
15
+ # namespace: 'ecommerce',
16
+ # context: { order_id: 123 }
17
+ # )
18
+ # response.task_uuid # => "550e8400-..."
19
+ # response.status # => "pending"
20
+ #
21
+ # @example Getting a task
22
+ # task = TaskerCore::Client.get_task("550e8400-...")
23
+ # task.name # => "process_order"
24
+ # task.namespace # => "ecommerce"
25
+ #
26
+ # @example Listing tasks with filters
27
+ # list = TaskerCore::Client.list_tasks(namespace: 'ecommerce', limit: 10)
28
+ # list.tasks.size # => 3
29
+ # list.pagination # => { "total_count" => 42, ... }
30
+ module Client
31
+ module_function
32
+
33
+ # Create a task via the orchestration API.
34
+ #
35
+ # @param name [String] Named task template name
36
+ # @param namespace [String] Task namespace (default: "default")
37
+ # @param context [Hash] Workflow context passed to step handlers
38
+ # @param version [String] Template version (default: "1.0.0")
39
+ # @param initiator [String] Who initiated the request
40
+ # @param source_system [String] Originating system
41
+ # @param reason [String] Reason for creating the task
42
+ # @param options [Hash] Additional TaskRequest fields
43
+ # @return [ClientTypes::TaskResponse, Hash] Typed response or raw hash on schema mismatch
44
+ def create_task(name:, namespace: 'default', context: {}, version: '1.0.0',
45
+ initiator: 'tasker-core-ruby', source_system: 'tasker-core',
46
+ reason: 'Task requested', **options)
47
+ request = {
48
+ 'name' => name,
49
+ 'namespace' => namespace,
50
+ 'version' => version,
51
+ 'context' => deep_to_hash(context),
52
+ 'initiator' => initiator,
53
+ 'source_system' => source_system,
54
+ 'reason' => reason
55
+ }
56
+ options.each { |k, v| request[k.to_s] = v }
57
+
58
+ response = TaskerCore::FFI.client_create_task(request)
59
+ wrap_response(response, Types::ClientTaskResponse)
60
+ end
61
+
62
+ # Get a task by UUID.
63
+ #
64
+ # @param task_uuid [String] The task UUID
65
+ # @return [ClientTypes::TaskResponse, Hash] Typed response or raw hash
66
+ def get_task(task_uuid)
67
+ response = TaskerCore::FFI.client_get_task(task_uuid.to_s)
68
+ wrap_response(response, Types::ClientTaskResponse)
69
+ end
70
+
71
+ # List tasks with optional filtering and pagination.
72
+ #
73
+ # @param limit [Integer] Maximum number of results (default: 50)
74
+ # @param offset [Integer] Pagination offset (default: 0)
75
+ # @param namespace [String, nil] Filter by namespace
76
+ # @param status [String, nil] Filter by status
77
+ # @return [ClientTypes::TaskListResponse, Hash] Typed response or raw hash
78
+ def list_tasks(limit: 50, offset: 0, namespace: nil, status: nil)
79
+ response = TaskerCore::FFI.client_list_tasks(limit, offset, namespace, status)
80
+ wrap_response(response, Types::ClientTaskListResponse)
81
+ end
82
+
83
+ # Cancel a task by UUID.
84
+ #
85
+ # @param task_uuid [String] The task UUID
86
+ # @return [Hash] Cancellation result
87
+ def cancel_task(task_uuid)
88
+ TaskerCore::FFI.client_cancel_task(task_uuid.to_s)
89
+ end
90
+
91
+ # List workflow steps for a task.
92
+ #
93
+ # @param task_uuid [String] The task UUID
94
+ # @return [Array<ClientTypes::StepResponse>, Array<Hash>] Typed steps or raw hashes
95
+ def list_task_steps(task_uuid)
96
+ response = TaskerCore::FFI.client_list_task_steps(task_uuid.to_s)
97
+ return response unless response.is_a?(Array)
98
+
99
+ response.map { |step| wrap_response(step, Types::ClientStepResponse) }
100
+ end
101
+
102
+ # Get a specific workflow step.
103
+ #
104
+ # @param task_uuid [String] The task UUID
105
+ # @param step_uuid [String] The step UUID
106
+ # @return [ClientTypes::StepResponse, Hash] Typed response or raw hash
107
+ def get_step(task_uuid, step_uuid)
108
+ response = TaskerCore::FFI.client_get_step(task_uuid.to_s, step_uuid.to_s)
109
+ wrap_response(response, Types::ClientStepResponse)
110
+ end
111
+
112
+ # Get audit history for a workflow step.
113
+ #
114
+ # @param task_uuid [String] The task UUID
115
+ # @param step_uuid [String] The step UUID
116
+ # @return [Array<ClientTypes::StepAuditResponse>, Array<Hash>] Typed audit entries or raw hashes
117
+ def get_step_audit_history(task_uuid, step_uuid)
118
+ response = TaskerCore::FFI.client_get_step_audit_history(task_uuid.to_s, step_uuid.to_s)
119
+ return response unless response.is_a?(Array)
120
+
121
+ response.map { |entry| wrap_response(entry, Types::ClientStepAuditResponse) }
122
+ end
123
+
124
+ # Check orchestration API health.
125
+ #
126
+ # @return [ClientTypes::HealthResponse, Hash] Typed response or raw hash
127
+ def health_check
128
+ response = TaskerCore::FFI.client_health_check
129
+ wrap_response(response, Types::ClientHealthResponse)
130
+ end
131
+
132
+ # @api private
133
+ # Wrap a raw FFI hash response into a Dry::Struct type.
134
+ # Falls back to the raw hash if the schema doesn't match,
135
+ # providing forward-compatibility when the API adds new fields.
136
+ def wrap_response(raw, type_class)
137
+ return raw unless raw.is_a?(Hash)
138
+
139
+ # Dry::Struct requires symbolized keys; raw FFI returns string keys
140
+ type_class.new(raw.transform_keys(&:to_sym))
141
+ rescue Dry::Struct::Error, KeyError, Dry::Types::ConstraintError
142
+ raw
143
+ end
144
+
145
+ # @api private
146
+ # Recursively convert ActionController::Parameters (and similar) to plain hashes/arrays.
147
+ def deep_to_hash(obj)
148
+ case obj
149
+ when Hash
150
+ obj.transform_values { |v| deep_to_hash(v) }
151
+ when Array
152
+ obj.map { |v| deep_to_hash(v) }
153
+ else
154
+ # Handle ActionController::Parameters if available (Rails contexts)
155
+ if obj.respond_to?(:to_unsafe_h)
156
+ obj.to_unsafe_h.transform_values { |v| deep_to_hash(v) }
157
+ else
158
+ obj
159
+ end
160
+ end
161
+ end
162
+
163
+ private_class_method :wrap_response, :deep_to_hash
164
+ end
165
+ end