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,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-struct'
4
+ require 'dry-types'
5
+
6
+ module TaskerCore
7
+ module Types
8
+ # Standardized result structure for step handler call methods
9
+ #
10
+ # This provides a consistent interface for all step handlers to return
11
+ # structured results with proper metadata for observability.
12
+ #
13
+ # Success example:
14
+ # StepHandlerCallResult.success(
15
+ # result: { validated_items: [...], total: 100.00 },
16
+ # metadata: {
17
+ # operation: "validate_order",
18
+ # item_count: 3,
19
+ # processing_time_ms: 125
20
+ # }
21
+ # )
22
+ #
23
+ # Error example (typically raised as exception):
24
+ # raise TaskerCore::Errors::PermanentError.new(
25
+ # "Invalid order total",
26
+ # error_code: "INVALID_TOTAL",
27
+ # context: { total: -50, min_allowed: 0 }
28
+ # )
29
+ module StepHandlerCallResult
30
+ class Success < Dry::Struct
31
+ # Always true for success results
32
+ attribute :success, Types::Bool.default(true)
33
+
34
+ # The actual result data from the step handler
35
+ # Can be any JSON-serializable value (Hash, Array, String, Number, etc.)
36
+ attribute :result, Types::Any
37
+
38
+ # Optional metadata for observability
39
+ attribute(:metadata, Types::Hash.default { {} })
40
+
41
+ # Predicate method for checking if result is successful
42
+ def success?
43
+ success
44
+ end
45
+
46
+ # TAS-125: Not a checkpoint - this is a final success result
47
+ def checkpoint?
48
+ false
49
+ end
50
+ end
51
+
52
+ class Error < Dry::Struct
53
+ # Always false for error results
54
+ attribute :success, Types::Bool.default(false)
55
+
56
+ # Error type (matches our error class names)
57
+ attribute :error_type,
58
+ Types::String.enum('PermanentError', 'RetryableError', 'ValidationError', 'UnexpectedError',
59
+ 'StepCompletionError')
60
+
61
+ # Human-readable error message
62
+ attribute :message, Types::String
63
+
64
+ # Optional error code for categorization
65
+ attribute :error_code, Types::String.optional
66
+
67
+ # Whether this error should trigger a retry
68
+ attribute :retryable, Types::Bool.default(false)
69
+
70
+ # Additional error context and metadata
71
+ attribute(:metadata, Types::Hash.default { {} })
72
+
73
+ # Predicate method for checking if result is successful
74
+ def success?
75
+ success
76
+ end
77
+
78
+ # TAS-125: Not a checkpoint - this is a final error result
79
+ def checkpoint?
80
+ false
81
+ end
82
+ end
83
+
84
+ # TAS-125: Checkpoint yield result for batch processing
85
+ #
86
+ # This result type signals that a batch processing handler wants to
87
+ # persist its progress (checkpoint) and be re-dispatched for continued
88
+ # processing. Unlike Success or Error, this is an intermediate state
89
+ # that doesn't complete the step.
90
+ #
91
+ # Example:
92
+ # StepHandlerCallResult.checkpoint_yield(
93
+ # cursor: 1000,
94
+ # items_processed: 1000,
95
+ # accumulated_results: { total_amount: 50000.00 }
96
+ # )
97
+ class CheckpointYield < Dry::Struct
98
+ # Position to resume from (Integer, String, or Hash)
99
+ # For offset-based pagination: Integer (row number)
100
+ # For cursor-based pagination: String or Hash (opaque cursor)
101
+ attribute :cursor, Types::Any
102
+
103
+ # Total number of items processed so far (cumulative across all yields)
104
+ attribute :items_processed, Types::Integer.constrained(gteq: 0)
105
+
106
+ # Optional partial aggregations to carry forward to next iteration
107
+ # Useful for running totals, counters, or intermediate calculations
108
+ attribute :accumulated_results, Types::Any.optional
109
+
110
+ # This is a checkpoint, not a final result
111
+ def checkpoint?
112
+ true
113
+ end
114
+
115
+ # Not successful yet - still in progress
116
+ def success?
117
+ false
118
+ end
119
+
120
+ # Convert to hash for FFI transport
121
+ def to_checkpoint_data(event_id:, step_uuid:)
122
+ {
123
+ event_id: event_id,
124
+ step_uuid: step_uuid,
125
+ cursor: cursor,
126
+ items_processed: items_processed,
127
+ accumulated_results: accumulated_results
128
+ }
129
+ end
130
+ end
131
+
132
+ # Factory methods for creating results
133
+ class << self
134
+ # Create a success result
135
+ #
136
+ # @param result [Object] The result data (must be JSON-serializable)
137
+ # @param metadata [Hash] Optional metadata for observability
138
+ # @return [Success] A success result instance
139
+ def success(result:, metadata: {})
140
+ Success.new(
141
+ success: true,
142
+ result: result,
143
+ metadata: metadata
144
+ )
145
+ end
146
+
147
+ # Create an error result
148
+ #
149
+ # @param error_type [String] Type of error (PermanentError, RetryableError, etc.)
150
+ # @param message [String] Human-readable error message
151
+ # @param error_code [String, nil] Optional error code
152
+ # @param retryable [Boolean] Whether to retry this error
153
+ # @param metadata [Hash] Additional error context
154
+ # @return [Error] An error result instance
155
+ def error(error_type:, message:, error_code: nil, retryable: false, metadata: {})
156
+ Error.new(
157
+ success: false,
158
+ error_type: error_type,
159
+ message: message,
160
+ error_code: error_code,
161
+ retryable: retryable,
162
+ metadata: metadata
163
+ )
164
+ end
165
+
166
+ # TAS-125: Create a checkpoint yield result for batch processing
167
+ #
168
+ # Use this when your batch processing handler wants to persist progress
169
+ # and be re-dispatched for continued processing.
170
+ #
171
+ # @param cursor [Integer, String, Hash] Position to resume from
172
+ # @param items_processed [Integer] Total items processed so far (cumulative)
173
+ # @param accumulated_results [Hash, nil] Optional partial aggregations
174
+ # @return [CheckpointYield] A checkpoint yield result instance
175
+ #
176
+ # @example Yield checkpoint with offset cursor
177
+ # StepHandlerCallResult.checkpoint_yield(
178
+ # cursor: 1000,
179
+ # items_processed: 1000,
180
+ # accumulated_results: { total_amount: 50000.00, row_count: 1000 }
181
+ # )
182
+ #
183
+ # @example Yield checkpoint with opaque cursor
184
+ # StepHandlerCallResult.checkpoint_yield(
185
+ # cursor: { page_token: "eyJsYXN0X2lkIjo5OTl9" },
186
+ # items_processed: 500
187
+ # )
188
+ def checkpoint_yield(cursor:, items_processed:, accumulated_results: nil)
189
+ CheckpointYield.new(
190
+ cursor: cursor,
191
+ items_processed: items_processed,
192
+ accumulated_results: accumulated_results
193
+ )
194
+ end
195
+
196
+ # Convert arbitrary handler output to a StepHandlerCallResult
197
+ #
198
+ # @param output [Object] The output from a handler's call method
199
+ # @return [Success, Error, CheckpointYield] A properly structured result
200
+ def from_handler_output(output)
201
+ case output
202
+ when Success, Error, CheckpointYield
203
+ # Already a proper result
204
+ output
205
+ when Hash
206
+ # Check if it looks like a result structure
207
+ if output[:success] == true || output['success'] == true
208
+ # Looks like a success result
209
+ Success.new(
210
+ success: true,
211
+ result: output[:result] || output['result'] || output,
212
+ metadata: output[:metadata] || output['metadata'] || {}
213
+ )
214
+ elsif output[:success] == false || output['success'] == false
215
+ # Looks like an error result
216
+ Error.new(
217
+ success: false,
218
+ error_type: output[:error_type] || output['error_type'] || 'UnexpectedError',
219
+ message: output[:message] || output['message'] || 'Unknown error',
220
+ error_code: output[:error_code] || output['error_code'],
221
+ retryable: output[:retryable] || output['retryable'] || false,
222
+ metadata: output[:metadata] || output['metadata'] || {}
223
+ )
224
+ else
225
+ # Just a hash of results - wrap it as success
226
+ Success.new(
227
+ success: true,
228
+ result: output,
229
+ metadata: {
230
+ wrapped: true,
231
+ original_type: 'Hash'
232
+ }
233
+ )
234
+ end
235
+ else
236
+ # Any other type - wrap as success
237
+ Success.new(
238
+ success: true,
239
+ result: output,
240
+ metadata: {
241
+ wrapped: true,
242
+ original_type: output.class.name
243
+ }
244
+ )
245
+ end
246
+ end
247
+
248
+ # Create error result from an exception
249
+ #
250
+ # @param exception [Exception] The exception that was raised
251
+ # @return [Error] An error result instance
252
+ def from_exception(exception)
253
+ case exception
254
+ when TaskerCore::Errors::PermanentError
255
+ Error.new(
256
+ success: false,
257
+ error_type: 'PermanentError',
258
+ message: exception.message,
259
+ error_code: exception.error_code,
260
+ retryable: false,
261
+ metadata: {
262
+ context: exception.context || {}
263
+ }.compact
264
+ )
265
+ when TaskerCore::Errors::RetryableError
266
+ Error.new(
267
+ success: false,
268
+ error_type: 'RetryableError',
269
+ message: exception.message,
270
+ error_code: nil, # RetryableError doesn't have error_code
271
+ retryable: true,
272
+ metadata: {
273
+ context: exception.context || {},
274
+ retry_after: exception.retry_after
275
+ }.compact
276
+ )
277
+ when TaskerCore::Errors::ValidationError
278
+ Error.new(
279
+ success: false,
280
+ error_type: 'ValidationError',
281
+ message: exception.message,
282
+ error_code: exception.error_code,
283
+ retryable: false,
284
+ metadata: {
285
+ context: exception.context || {},
286
+ field: exception.field
287
+ }.compact
288
+ )
289
+ else
290
+ # Generic exception
291
+ Error.new(
292
+ success: false,
293
+ error_type: 'UnexpectedError',
294
+ message: exception.message,
295
+ error_code: nil,
296
+ retryable: true,
297
+ metadata: {
298
+ exception_class: exception.class.name,
299
+ stack_trace: exception.backtrace&.first(10)&.join("\n")
300
+ }.compact
301
+ )
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-struct'
4
+ require 'dry-types'
5
+
6
+ module TaskerCore
7
+ module Types
8
+ # Define custom types for the system
9
+ include Dry.Types()
10
+
11
+ # Custom types for TaskerCore - Essential validation types
12
+ Namespace = Types::String.constrained(filled: true)
13
+ StepName = Types::String.constrained(filled: true)
14
+ TaskName = Types::String # Allow empty strings for task names
15
+ TaskVersion = Types::String.constrained(filled: true)
16
+ Priority = Types::Integer.constrained(gteq: 1, lteq: 10)
17
+ RetryCount = Types::Integer.constrained(gteq: 0)
18
+ TimeoutMs = Types::Integer.constrained(gt: 0)
19
+
20
+ # Lightweight struct for execution context (replaces OpenStruct)
21
+ ExecutionContext = Struct.new(:step, :task, keyword_init: true)
22
+
23
+ # Lightweight struct for queue message envelope (replaces OpenStruct)
24
+ QueueMessageEnvelope = Struct.new(:msg_id, :read_ct, :enqueued_at, :vt, :message, keyword_init: true)
25
+
26
+ # Minimal StepMessage stub for registry compatibility only
27
+ # This is a lightweight replacement for the complex 324-line StepMessage class
28
+ # Used only for temporary registry lookup objects in queue_worker.rb
29
+ class StepMessage
30
+ attr_reader :step_name, :namespace, :task_name, :task_version, :step_id, :task_uuid, :step_uuid
31
+
32
+ def initialize(attrs = {})
33
+ @step_name = attrs[:step_name]
34
+ @namespace = attrs[:namespace]
35
+ @task_name = attrs[:task_name]
36
+ @task_version = attrs[:task_version]
37
+ @step_id = attrs[:step_id]
38
+ @task_uuid = attrs[:task_uuid]
39
+ @step_uuid = attrs[:step_uuid]
40
+ end
41
+
42
+ # Minimal interface for registry compatibility
43
+ def execution_context
44
+ ExecutionContext.new(
45
+ step: {
46
+ step_name: @step_name,
47
+ workflow_step_id: @step_id,
48
+ step_uuid: @step_uuid
49
+ },
50
+ task: {
51
+ task_uuid: @task_uuid,
52
+ namespace: @namespace,
53
+ task_name: @task_name,
54
+ task_version: @task_version
55
+ }
56
+ )
57
+ end
58
+ end
59
+
60
+ # Simple queue message data for new UUID-based architecture
61
+ # This is the core message structure for the simplified messaging system
62
+ # Used extensively in pgmq_client.rb and queue_worker.rb
63
+ class SimpleQueueMessageData < Dry::Struct
64
+ transform_keys(&:to_sym)
65
+
66
+ # pgmq envelope data
67
+ attribute :msg_id, Types::Integer
68
+ attribute :read_ct, Types::Integer
69
+ # Accept either String or Time for timestamps (PostgreSQL may return either)
70
+ attribute :enqueued_at, Types::String | Types::Instance(Time)
71
+ attribute :vt, Types::String | Types::Instance(Time)
72
+
73
+ # Simple message content (UUID-based)
74
+ attribute :message, Types::Hash.schema(
75
+ task_uuid: Types::String,
76
+ step_uuid: Types::String,
77
+ ready_dependency_step_uuids: Types::Array.of(Types::String)
78
+ )
79
+
80
+ # Convenience accessor for the simple message
81
+ def step_message
82
+ @step_message ||= SimpleStepMessage.new(message)
83
+ end
84
+
85
+ def queue_message
86
+ @queue_message ||= QueueMessageEnvelope.new(
87
+ msg_id: msg_id,
88
+ read_ct: read_ct,
89
+ enqueued_at: enqueued_at,
90
+ vt: vt,
91
+ message: message
92
+ )
93
+ end
94
+
95
+ # Delegate UUID accessors to the message hash for convenience
96
+ def task_uuid
97
+ message[:task_uuid]
98
+ end
99
+
100
+ def step_uuid
101
+ message[:step_uuid]
102
+ end
103
+
104
+ def ready_dependency_step_uuids
105
+ message[:ready_dependency_step_uuids]
106
+ end
107
+ end
108
+
109
+ # SimpleStepMessage is defined in simple_message.rb with full UUID validation
110
+ # This avoids duplicate class definitions
111
+ end
112
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-struct'
4
+ require 'dry-types'
5
+ require 'socket'
6
+
7
+ module TaskerCore
8
+ module Types
9
+ # Step-related type definitions for workflow execution
10
+ module StepTypes
11
+ module Types
12
+ include Dry.Types()
13
+ end
14
+
15
+ # Step execution status enum
16
+ class StepExecutionStatus < Dry::Struct
17
+ attribute :status, Types::String.enum('success', 'failed', 'in_progress', 'cancelled', 'timeout')
18
+
19
+ def success?
20
+ status == 'success'
21
+ end
22
+
23
+ def completed?
24
+ status == 'success'
25
+ end
26
+
27
+ def failed?
28
+ status == 'failed'
29
+ end
30
+
31
+ def in_progress?
32
+ status == 'in_progress'
33
+ end
34
+ end
35
+
36
+ # Step execution error details
37
+ class StepExecutionError < Dry::Struct
38
+ attribute :error_type,
39
+ Types::String.enum(
40
+ 'HandlerNotFound',
41
+ 'HandlerException',
42
+ 'ProcessingError',
43
+ 'MaxRetriesExceeded',
44
+ 'RecordNotFound',
45
+ 'UnexpectedError',
46
+ 'PermanentError',
47
+ 'RetryableError'
48
+ )
49
+ attribute :message, Types::String
50
+ attribute :retryable, Types::Bool.default(true)
51
+ attribute? :error_code, Types::String.optional
52
+ attribute? :stack_trace, Types::String.optional
53
+
54
+ def to_h
55
+ {
56
+ message: message,
57
+ error_type: error_type,
58
+ status_code: nil,
59
+ context: {
60
+ error_code: error_code,
61
+ stack_trace: stack_trace
62
+ }.compact,
63
+ retryable: retryable
64
+ }.compact
65
+ end
66
+ end
67
+
68
+ # Step execution result from pgmq worker processing
69
+ class StepResult < Dry::Struct
70
+ attribute :task_uuid, Types::String
71
+ attribute :step_uuid, Types::String
72
+ attribute :status, StepExecutionStatus
73
+ attribute :execution_time_ms, Types::Integer
74
+ attribute :completed_at, Types::Constructor(Time) { |value| value.is_a?(Time) ? value : Time.parse(value.to_s) }
75
+ attribute? :result_data, Types::Any.optional
76
+ attribute? :error, StepExecutionError.optional
77
+ attribute? :orchestration_metadata, Types::Hash.optional
78
+
79
+ # Factory methods for creating results
80
+ def self.success(step_uuid:, task_uuid:, result_data: nil, execution_time_ms: 0)
81
+ new(
82
+ step_uuid: step_uuid,
83
+ task_uuid: task_uuid,
84
+ status: StepExecutionStatus.new(status: 'success'),
85
+ execution_time_ms: execution_time_ms,
86
+ completed_at: Time.now,
87
+ result_data: result_data
88
+ )
89
+ end
90
+
91
+ def self.in_progress(step_uuid:, task_uuid:, result_data: nil, execution_time_ms: 0)
92
+ new(
93
+ step_uuid: step_uuid,
94
+ task_uuid: task_uuid,
95
+ status: StepExecutionStatus.new(status: 'in_progress'),
96
+ execution_time_ms: execution_time_ms,
97
+ completed_at: Time.now,
98
+ result_data: result_data
99
+ )
100
+ end
101
+
102
+ def self.failure(step_uuid:, task_uuid:, error:, execution_time_ms: 0)
103
+ new(
104
+ step_uuid: step_uuid,
105
+ task_uuid: task_uuid,
106
+ status: StepExecutionStatus.new(status: 'failed'),
107
+ execution_time_ms: execution_time_ms,
108
+ completed_at: Time.now,
109
+ error: error
110
+ )
111
+ end
112
+
113
+ # Convenience methods
114
+ def success?
115
+ status.success?
116
+ end
117
+
118
+ def failed?
119
+ status.failed?
120
+ end
121
+
122
+ # Convert to hash for message serialization matching Rust StepResultMessage structure
123
+ def to_h
124
+ {
125
+ step_uuid: step_uuid,
126
+ task_uuid: task_uuid,
127
+ status: map_status_to_rust_enum(status.status),
128
+ results: result_data,
129
+ error: error&.to_h,
130
+ execution_time_ms: execution_time_ms,
131
+ orchestration_metadata: orchestration_metadata,
132
+ metadata: {
133
+ worker_id: "ruby_worker_#{Process.pid}",
134
+ worker_hostname: Socket.gethostname,
135
+ started_at: (completed_at - (execution_time_ms / 1000.0)).utc.iso8601,
136
+ completed_at: completed_at.utc.iso8601,
137
+ custom: {}
138
+ }
139
+ }.compact
140
+ end
141
+
142
+ private
143
+
144
+ # Map Ruby status strings to Rust enum variants
145
+ def map_status_to_rust_enum(status_string)
146
+ case status_string
147
+ when 'success'
148
+ 'Success'
149
+ when 'failed'
150
+ 'Failed'
151
+ when 'cancelled'
152
+ 'Cancelled'
153
+ when 'timeout'
154
+ 'Timeout'
155
+ when 'in_progress'
156
+ 'InProgress'
157
+ else # rubocop:disable Lint/DuplicateBranch
158
+ 'Failed' # fallback
159
+ end
160
+ end
161
+ end
162
+
163
+ # Step completion struct for workflow step results
164
+ class StepCompletion < Dry::Struct
165
+ attribute :step_name, Types::Coercible::String
166
+ attribute :status, Types::Coercible::String.enum('complete', 'failed', 'pending')
167
+ attribute :results, Types::Hash.default({}.freeze)
168
+ attribute :duration_ms, Types::Integer.optional
169
+ attribute :completed_at, Types::Constructor(Time).optional
170
+ attribute :error_message, Types::Coercible::String.optional
171
+
172
+ # Validation for step completion data
173
+ def valid?
174
+ !step_name.empty? &&
175
+ %w[complete failed pending].include?(status) &&
176
+ results.is_a?(Hash)
177
+ end
178
+
179
+ # Check if step completed successfully
180
+ def completed?
181
+ status == 'complete'
182
+ end
183
+
184
+ # Check if step failed
185
+ def failed?
186
+ status == 'failed'
187
+ end
188
+
189
+ # Check if step is still pending
190
+ def pending?
191
+ status == 'pending'
192
+ end
193
+
194
+ # Get execution duration in seconds
195
+ def duration_seconds
196
+ return nil unless duration_ms
197
+
198
+ duration_ms / 1000.0
199
+ end
200
+
201
+ def to_s
202
+ "#<StepCompletion #{step_name} status=#{status} duration=#{duration_seconds}s>"
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end