tasker-rb 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/DEVELOPMENT.md +548 -0
  3. data/README.md +87 -0
  4. data/ext/tasker_core/Cargo.lock +4720 -0
  5. data/ext/tasker_core/Cargo.toml +76 -0
  6. data/ext/tasker_core/extconf.rb +38 -0
  7. data/ext/tasker_core/src/CLAUDE.md +7 -0
  8. data/ext/tasker_core/src/bootstrap.rs +320 -0
  9. data/ext/tasker_core/src/bridge.rs +400 -0
  10. data/ext/tasker_core/src/client_ffi.rs +173 -0
  11. data/ext/tasker_core/src/conversions.rs +131 -0
  12. data/ext/tasker_core/src/diagnostics.rs +57 -0
  13. data/ext/tasker_core/src/event_handler.rs +179 -0
  14. data/ext/tasker_core/src/event_publisher_ffi.rs +239 -0
  15. data/ext/tasker_core/src/ffi_logging.rs +245 -0
  16. data/ext/tasker_core/src/global_event_system.rs +16 -0
  17. data/ext/tasker_core/src/in_process_event_ffi.rs +319 -0
  18. data/ext/tasker_core/src/lib.rs +41 -0
  19. data/ext/tasker_core/src/observability_ffi.rs +339 -0
  20. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  21. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  22. data/lib/tasker_core/bootstrap.rb +394 -0
  23. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  24. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  25. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  26. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  27. data/lib/tasker_core/domain_events.rb +43 -0
  28. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  29. data/lib/tasker_core/errors/common.rb +305 -0
  30. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  31. data/lib/tasker_core/errors.rb +4 -0
  32. data/lib/tasker_core/event_bridge.rb +330 -0
  33. data/lib/tasker_core/handlers.rb +159 -0
  34. data/lib/tasker_core/internal.rb +31 -0
  35. data/lib/tasker_core/logger.rb +234 -0
  36. data/lib/tasker_core/models.rb +337 -0
  37. data/lib/tasker_core/observability/types.rb +158 -0
  38. data/lib/tasker_core/observability.rb +292 -0
  39. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  40. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  41. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  42. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  43. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  44. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  45. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  46. data/lib/tasker_core/registry/resolvers.rb +42 -0
  47. data/lib/tasker_core/registry.rb +12 -0
  48. data/lib/tasker_core/step_handler/api.rb +48 -0
  49. data/lib/tasker_core/step_handler/base.rb +354 -0
  50. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  51. data/lib/tasker_core/step_handler/decision.rb +53 -0
  52. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  53. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  54. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  55. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  56. data/lib/tasker_core/subscriber.rb +212 -0
  57. data/lib/tasker_core/task_handler/base.rb +254 -0
  58. data/lib/tasker_core/tasker_rb.so +0 -0
  59. data/lib/tasker_core/template_discovery.rb +181 -0
  60. data/lib/tasker_core/tracing.rb +166 -0
  61. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  62. data/lib/tasker_core/types/client_types.rb +145 -0
  63. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  64. data/lib/tasker_core/types/error_types.rb +72 -0
  65. data/lib/tasker_core/types/simple_message.rb +151 -0
  66. data/lib/tasker_core/types/step_context.rb +328 -0
  67. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  68. data/lib/tasker_core/types/step_message.rb +112 -0
  69. data/lib/tasker_core/types/step_types.rb +207 -0
  70. data/lib/tasker_core/types/task_template.rb +240 -0
  71. data/lib/tasker_core/types/task_types.rb +148 -0
  72. data/lib/tasker_core/types.rb +132 -0
  73. data/lib/tasker_core/version.rb +13 -0
  74. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  75. data/lib/tasker_core/worker/event_poller.rb +224 -0
  76. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  77. data/lib/tasker_core.rb +160 -0
  78. metadata +322 -0
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-struct'
4
+ require 'dry-types'
5
+
6
+ module TaskerCore
7
+ module Types
8
+ # Include Dry.Types for access to Types::String, etc.
9
+ include Dry.Types()
10
+
11
+ # UUID validation regex pattern (defined at module level for reuse)
12
+ # Updated to accept UUID v7 (version nibble can be 7)
13
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
14
+
15
+ # Simple message structure for UUID-based step processing
16
+ #
17
+ # This replaces the complex nested StepMessage with a minimal 3-field structure
18
+ # that leverages the shared database as the API layer. Ruby workers receive this
19
+ # simple message and use the UUIDs to fetch ActiveRecord models directly.
20
+ #
21
+ # Benefits:
22
+ # - 80%+ message size reduction (3 UUIDs vs complex nested JSON)
23
+ # - Eliminates type conversion issues (no hash-to-object conversion)
24
+ # - Prevents stale queue messages (UUIDs are globally unique)
25
+ # - Real ActiveRecord models for handlers (full ORM functionality)
26
+ # - Database as single source of truth
27
+ #
28
+ # @example Message structure
29
+ # {
30
+ # "task_uuid": "550e8400-e29b-41d4-a716-446655440001",
31
+ # "step_uuid": "550e8400-e29b-41d4-a716-446655440002",
32
+ # "ready_dependency_step_uuids": [
33
+ # "550e8400-e29b-41d4-a716-446655440003",
34
+ # "550e8400-e29b-41d4-a716-446655440004"
35
+ # ]
36
+ # }
37
+ #
38
+ # @example Ruby processing
39
+ # # 1. Receive simple message
40
+ # task = TaskerCore::Database::Models::Task.find_by!(task_uuid: message.task_uuid)
41
+ # step = TaskerCore::Database::Models::WorkflowStep.find_by!(step_uuid: message.step_uuid)
42
+ # dependencies = TaskerCore::Database::Models::WorkflowStep.where(
43
+ # step_uuid: message.ready_dependency_step_uuids
44
+ # ).includes(:results)
45
+ #
46
+ # # 2. Create context and call handler
47
+ # context = TaskerCore::Types::StepContext.new(step_data)
48
+ # handler.call(context)
49
+ class SimpleStepMessage < Dry::Struct
50
+ # Make the struct flexible for additional attributes if needed
51
+ transform_keys(&:to_sym)
52
+
53
+ # Task UUID from tasker_tasks.task_uuid
54
+ attribute :task_uuid, Types::String.constrained(format: UUID_REGEX)
55
+
56
+ # Step UUID from tasker_workflow_steps.step_uuid
57
+ attribute :step_uuid, Types::String.constrained(format: UUID_REGEX)
58
+
59
+ # Array of dependency step UUIDs that are ready/completed
60
+ # Empty array means no dependencies or root step
61
+ attribute :ready_dependency_step_uuids, Types::Array.of(
62
+ Types::String.constrained(format: UUID_REGEX)
63
+ ).default([].freeze)
64
+
65
+ # Convert to hash for JSON serialization
66
+ # @return [Hash] hash representation suitable for JSON
67
+ def to_h
68
+ {
69
+ task_uuid: task_uuid,
70
+ step_uuid: step_uuid,
71
+ ready_dependency_step_uuids: ready_dependency_step_uuids
72
+ }
73
+ end
74
+
75
+ # Create from hash (for deserialization from JSON)
76
+ # @param hash [Hash] hash representation
77
+ # @return [SimpleStepMessage] new simple message instance
78
+ def self.from_hash(hash)
79
+ symbolized = hash.transform_keys(&:to_sym)
80
+ new(symbolized)
81
+ end
82
+
83
+ # Validate that all UUIDs exist in the database
84
+ # @return [Boolean] true if all referenced records exist
85
+ def valid_references?
86
+ task_exists? && step_exists? && all_dependencies_exist?
87
+ end
88
+
89
+ # Check if the task UUID exists in the database
90
+ # @return [Boolean] true if task exists
91
+ def task_exists?
92
+ TaskerCore::Database::Models::Task.exists?(task_uuid: task_uuid)
93
+ end
94
+
95
+ # Check if the step UUID exists in the database
96
+ # @return [Boolean] true if step exists
97
+ def step_exists?
98
+ TaskerCore::Database::Models::WorkflowStep.exists?(workflow_step_uuid: step_uuid)
99
+ end
100
+
101
+ # Check if all dependency UUIDs exist in the database
102
+ # @return [Boolean] true if all dependencies exist
103
+ def all_dependencies_exist?
104
+ return true if ready_dependency_step_uuids.empty?
105
+
106
+ existing_count = TaskerCore::Database::Models::WorkflowStep
107
+ .where(workflow_step_uuid: ready_dependency_step_uuids)
108
+ .count
109
+
110
+ existing_count == ready_dependency_step_uuids.length
111
+ end
112
+
113
+ # Fetch the actual task record from the database
114
+ # @return [TaskerCore::Database::Models::Task] the task record
115
+ # @raise [ActiveRecord::RecordNotFound] if task doesn't exist
116
+ def fetch_task
117
+ TaskerCore::Database::Models::Task.find_by!(task_uuid: task_uuid)
118
+ end
119
+
120
+ # Fetch the actual step record from the database
121
+ # @return [TaskerCore::Database::Models::WorkflowStep] the step record
122
+ # @raise [ActiveRecord::RecordNotFound] if step doesn't exist
123
+ def fetch_step
124
+ TaskerCore::Database::Models::WorkflowStep.find_by!(workflow_step_uuid: step_uuid)
125
+ end
126
+
127
+ # Fetch the dependency step records from the database
128
+ # @return [ActiveRecord::Relation<TaskerCore::Database::Models::WorkflowStep>] dependency steps
129
+ def fetch_dependencies
130
+ return TaskerCore::Database::Models::WorkflowStep.none if ready_dependency_step_uuids.empty?
131
+
132
+ TaskerCore::Database::Models::WorkflowStep
133
+ .where(workflow_step_uuid: ready_dependency_step_uuids)
134
+ .includes(:results, :named_step) # Preload commonly needed associations
135
+ end
136
+
137
+ # Create a step message for testing with generated UUIDs
138
+ # @param task_uuid [String] task UUID
139
+ # @param step_uuid [String] step UUID
140
+ # @param ready_dependency_step_uuids [Array<String>] dependency step UUIDs
141
+ # @return [SimpleStepMessage] new simple message
142
+ def self.build_test(task_uuid:, step_uuid:, ready_dependency_step_uuids: [])
143
+ new(
144
+ task_uuid: task_uuid,
145
+ step_uuid: step_uuid,
146
+ ready_dependency_step_uuids: ready_dependency_step_uuids
147
+ )
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module Types
5
+ # StepContext provides a unified context for step handler execution.
6
+ #
7
+ # This is the cross-language standard context object passed to handler.call(context).
8
+ # It wraps the FFI-provided TaskSequenceStepWrapper and adds convenience accessors
9
+ # that match Python and Rust naming conventions.
10
+ #
11
+ # Cross-language standard fields:
12
+ # - task_uuid: UUID of the task
13
+ # - step_uuid: UUID of the workflow step
14
+ # - input_data: Step input data from workflow_step.inputs
15
+ # - step_inputs: Alias for input_data
16
+ # - step_config: Handler configuration from step_definition.handler.initialization
17
+ # - retry_count: Current retry attempt count
18
+ # - max_retries: Maximum retry attempts allowed
19
+ # - dependency_results: Results from parent steps
20
+ #
21
+ # Ruby-specific accessors (for backward compatibility):
22
+ # - task: TaskWrapper instance
23
+ # - workflow_step: WorkflowStepWrapper instance
24
+ # - step_definition: StepDefinitionWrapper instance
25
+ #
26
+ # @example Accessing context in a handler
27
+ # def call(context)
28
+ # # Cross-language standard fields
29
+ # task_uuid = context.task_uuid
30
+ # step_uuid = context.step_uuid
31
+ # input_data = context.input_data
32
+ # deps = context.dependency_results
33
+ #
34
+ # # Convenience methods
35
+ # even_number = context.get_task_field('even_number')
36
+ # prev_result = context.get_dependency_result('step_1')
37
+ #
38
+ # # Ruby-specific accessors (backward compat)
39
+ # task = context.task
40
+ # step = context.workflow_step
41
+ # end
42
+ #
43
+ # @see TaskerCore::Models::TaskSequenceStepWrapper The underlying wrapper
44
+ class StepContext
45
+ # @return [TaskerCore::Models::TaskWrapper] Task metadata and context
46
+ attr_reader :task
47
+
48
+ # @return [TaskerCore::Models::WorkflowStepWrapper] Step execution state
49
+ attr_reader :workflow_step
50
+
51
+ # @return [TaskerCore::Models::DependencyResultsWrapper] Results from parent steps
52
+ attr_reader :dependency_results
53
+
54
+ # @return [TaskerCore::Models::StepDefinitionWrapper] Step definition from template
55
+ attr_reader :step_definition
56
+
57
+ # @return [String] The handler name for this step
58
+ attr_reader :handler_name
59
+
60
+ # Creates a StepContext from FFI step data
61
+ #
62
+ # @param step_data [Hash, TaskerCore::Models::TaskSequenceStepWrapper] The step data from Rust FFI
63
+ # @param handler_name [String, nil] Optional handler name override
64
+ def initialize(step_data, handler_name: nil)
65
+ if step_data.is_a?(TaskerCore::Models::TaskSequenceStepWrapper)
66
+ @task = step_data.task
67
+ @workflow_step = step_data.workflow_step
68
+ @dependency_results = step_data.dependency_results
69
+ @step_definition = step_data.step_definition
70
+ else
71
+ wrapper = TaskerCore::Models::TaskSequenceStepWrapper.new(step_data)
72
+ @task = wrapper.task
73
+ @workflow_step = wrapper.workflow_step
74
+ @dependency_results = wrapper.dependency_results
75
+ @step_definition = wrapper.step_definition
76
+ end
77
+
78
+ @handler_name = handler_name || @step_definition.handler&.callable
79
+ end
80
+
81
+ # ========================================================================
82
+ # CROSS-LANGUAGE STANDARD FIELDS
83
+ # ========================================================================
84
+
85
+ # Cross-language standard: task_uuid
86
+ # @return [String] UUID of the task
87
+ def task_uuid
88
+ @task.task_uuid
89
+ end
90
+
91
+ # Cross-language standard: step_uuid
92
+ # @return [String] UUID of the workflow step
93
+ def step_uuid
94
+ @workflow_step.workflow_step_uuid
95
+ end
96
+
97
+ # Cross-language standard: input_data
98
+ # Returns the step inputs from the workflow step.
99
+ # @return [ActiveSupport::HashWithIndifferentAccess] Step input data
100
+ def input_data
101
+ @workflow_step.inputs
102
+ end
103
+
104
+ # Cross-language standard: step_inputs (alias for input_data)
105
+ # @return [ActiveSupport::HashWithIndifferentAccess] Step input data
106
+ alias step_inputs input_data
107
+
108
+ # Cross-language standard: step_config
109
+ # Returns the handler configuration from the step definition.
110
+ # @return [ActiveSupport::HashWithIndifferentAccess] Handler configuration from template
111
+ def step_config
112
+ @step_definition.handler&.initialization || {}.with_indifferent_access
113
+ end
114
+
115
+ # Cross-language standard: retry_count
116
+ # @return [Integer] Current retry attempt count
117
+ def retry_count
118
+ @workflow_step.attempts || 0
119
+ end
120
+
121
+ # Cross-language standard: max_retries
122
+ # @return [Integer] Maximum retry attempts allowed
123
+ def max_retries
124
+ @workflow_step.max_attempts || 3
125
+ end
126
+
127
+ # ========================================================================
128
+ # CONVENIENCE METHODS
129
+ # ========================================================================
130
+
131
+ # Get a field from the task context.
132
+ #
133
+ # @param field_name [String, Symbol] Field name in task context
134
+ # @return [Object, nil] The field value or nil if not found
135
+ #
136
+ # @example
137
+ # even_number = context.get_task_field('even_number')
138
+ def get_task_field(field_name)
139
+ @task.context[field_name.to_s]
140
+ end
141
+
142
+ # Cross-language standard alias for get_task_field
143
+ # @see #get_task_field
144
+ alias get_input get_task_field
145
+
146
+ # Get a field from the task context with a default value.
147
+ #
148
+ # @param field_name [String, Symbol] Field name in task context
149
+ # @param default [Object] Default value if field is nil
150
+ # @return [Object] The field value or default if nil
151
+ #
152
+ # @example
153
+ # batch_size = context.get_input_or('batch_size', 100)
154
+ def get_input_or(field_name, default = nil)
155
+ value = get_task_field(field_name)
156
+ value.nil? ? default : value
157
+ end
158
+
159
+ # Get a configuration value from the handler initialization.
160
+ #
161
+ # @param key [String, Symbol] Configuration key
162
+ # @return [Object, nil] The configuration value or nil if not found
163
+ #
164
+ # @example
165
+ # api_url = context.get_config('api_url')
166
+ def get_config(key)
167
+ step_config[key.to_s]
168
+ end
169
+
170
+ # Get a dependency result from a parent step.
171
+ #
172
+ # This returns the actual result value, not the full metadata hash.
173
+ # Use dependency_results.get_result(step_name) for full metadata.
174
+ #
175
+ # @param step_name [String, Symbol] Name of the parent step
176
+ # @return [Object, nil] The result value or nil if not found
177
+ #
178
+ # @example
179
+ # prev_result = context.get_dependency_result('step_1')
180
+ def get_dependency_result(step_name)
181
+ @dependency_results.get_results(step_name)
182
+ end
183
+
184
+ # Extract a nested field from a dependency result.
185
+ #
186
+ # Useful when dependency results are complex objects and you need
187
+ # to extract a specific nested value.
188
+ #
189
+ # @param step_name [String, Symbol] Name of the parent step
190
+ # @param path [Array<String, Symbol>] Path to the nested field
191
+ # @return [Object, nil] The nested value or nil if not found
192
+ #
193
+ # @example
194
+ # csv_path = context.get_dependency_field('analyze_csv', 'csv_file_path')
195
+ # nested_value = context.get_dependency_field('step_1', 'data', 'items', 0)
196
+ def get_dependency_field(step_name, *path)
197
+ result = get_dependency_result(step_name)
198
+ return nil if result.nil?
199
+
200
+ result.dig(*path.map(&:to_s))
201
+ end
202
+
203
+ # ========================================================================
204
+ # RETRY HELPERS
205
+ # ========================================================================
206
+
207
+ # Check if this execution is a retry attempt.
208
+ #
209
+ # @return [Boolean] true if retry_count > 0
210
+ #
211
+ # @example
212
+ # if context.is_retry?
213
+ # logger.info("Retrying step, attempt #{context.retry_count}")
214
+ # end
215
+ def is_retry?
216
+ retry_count.positive?
217
+ end
218
+
219
+ # Check if this is the last retry attempt.
220
+ #
221
+ # @return [Boolean] true if this is the final retry attempt
222
+ #
223
+ # @example
224
+ # if context.is_last_retry?
225
+ # # Send alert or take special action on final attempt
226
+ # end
227
+ def is_last_retry?
228
+ retry_count >= max_retries - 1
229
+ end
230
+
231
+ # ========================================================================
232
+ # CHECKPOINT ACCESSORS (TAS-125 Batch Processing Support)
233
+ # ========================================================================
234
+
235
+ # Get the raw checkpoint data from the workflow step.
236
+ #
237
+ # @return [Hash, nil] The checkpoint data or nil if not set
238
+ def checkpoint
239
+ @workflow_step.checkpoint
240
+ end
241
+
242
+ # Get the checkpoint cursor position.
243
+ #
244
+ # The cursor represents the current position in batch processing,
245
+ # allowing handlers to resume from where they left off.
246
+ #
247
+ # @return [Object, nil] The cursor value (int, string, or object)
248
+ #
249
+ # @example
250
+ # cursor = context.checkpoint_cursor
251
+ # start_from = cursor || 0
252
+ def checkpoint_cursor
253
+ checkpoint&.dig('cursor')
254
+ end
255
+
256
+ # Get the number of items processed in the current batch run.
257
+ #
258
+ # @return [Integer] Number of items processed (0 if no checkpoint)
259
+ def checkpoint_items_processed
260
+ checkpoint&.dig('items_processed') || 0
261
+ end
262
+
263
+ # Get the accumulated results from batch processing.
264
+ #
265
+ # Accumulated results allow handlers to maintain running totals
266
+ # or aggregated state across checkpoint boundaries.
267
+ #
268
+ # @return [Hash, nil] The accumulated results or nil if not set
269
+ #
270
+ # @example
271
+ # totals = context.accumulated_results || {}
272
+ # current_sum = totals['sum'] || 0
273
+ def accumulated_results
274
+ checkpoint&.dig('accumulated_results')
275
+ end
276
+
277
+ # Check if a checkpoint exists for this step.
278
+ #
279
+ # @return [Boolean] true if a checkpoint cursor exists
280
+ #
281
+ # @example
282
+ # if context.has_checkpoint?
283
+ # logger.info("Resuming from checkpoint at cursor: #{context.checkpoint_cursor}")
284
+ # end
285
+ def has_checkpoint?
286
+ !checkpoint_cursor.nil?
287
+ end
288
+
289
+ # ========================================================================
290
+ # ADDITIONAL ACCESSORS
291
+ # ========================================================================
292
+
293
+ # @return [String, nil] Namespace name from task template
294
+ def namespace_name
295
+ @task.respond_to?(:namespace_name) ? @task.namespace_name : nil
296
+ end
297
+
298
+ # @return [String] Step name from workflow step
299
+ def step_name
300
+ @workflow_step.name
301
+ end
302
+
303
+ # @return [ActiveSupport::HashWithIndifferentAccess] Full task context
304
+ def context
305
+ @task.context
306
+ end
307
+
308
+ # @return [Boolean] Whether the step can be retried
309
+ def retryable?
310
+ @workflow_step.retryable
311
+ end
312
+
313
+ # String representation for debugging
314
+ def to_s
315
+ "#<StepContext task_uuid=#{task_uuid} step_uuid=#{step_uuid} step_name=#{step_name}>"
316
+ end
317
+
318
+ # Detailed inspection for debugging
319
+ def inspect
320
+ "#<StepContext:#{object_id} " \
321
+ "task_uuid=#{task_uuid.inspect} " \
322
+ "step_uuid=#{step_uuid.inspect} " \
323
+ "step_name=#{step_name.inspect} " \
324
+ "handler_name=#{handler_name.inspect}>"
325
+ end
326
+ end
327
+ end
328
+ end