conductor_ruby 0.1.0
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 +7 -0
- data/CHANGELOG.md +142 -0
- data/LICENSE +190 -0
- data/README.md +517 -0
- data/examples/agentic_workflows/llm_chat.rb +106 -0
- data/examples/dynamic_workflow.rb +177 -0
- data/examples/event_handler.rb +94 -0
- data/examples/event_listener_examples.rb +430 -0
- data/examples/helloworld/greetings_worker.rb +24 -0
- data/examples/helloworld/helloworld.rb +99 -0
- data/examples/kitchensink.rb +213 -0
- data/examples/metadata_journey.rb +189 -0
- data/examples/metrics_example.rb +284 -0
- data/examples/new_dsl_demo.rb +141 -0
- data/examples/orkes/http_poll.rb +83 -0
- data/examples/orkes/secrets_example.rb +69 -0
- data/examples/orkes/wait_for_webhook.rb +90 -0
- data/examples/prompt_journey.rb +245 -0
- data/examples/rag_workflow.rb +167 -0
- data/examples/schedule_journey.rb +244 -0
- data/examples/simple_worker.rb +125 -0
- data/examples/simple_workflow.rb +89 -0
- data/examples/task_context_example.rb +257 -0
- data/examples/task_listener_example.rb +192 -0
- data/examples/worker_configuration_example.rb +282 -0
- data/examples/workflow_dsl.rb +316 -0
- data/examples/workflow_ops.rb +305 -0
- data/lib/conductor/client/authorization_client.rb +238 -0
- data/lib/conductor/client/integration_client.rb +108 -0
- data/lib/conductor/client/metadata_client.rb +139 -0
- data/lib/conductor/client/prompt_client.rb +58 -0
- data/lib/conductor/client/scheduler_client.rb +132 -0
- data/lib/conductor/client/schema_client.rb +32 -0
- data/lib/conductor/client/secret_client.rb +48 -0
- data/lib/conductor/client/task_client.rb +168 -0
- data/lib/conductor/client/workflow_client.rb +242 -0
- data/lib/conductor/configuration/authentication_settings.rb +17 -0
- data/lib/conductor/configuration.rb +103 -0
- data/lib/conductor/exceptions.rb +86 -0
- data/lib/conductor/http/api/application_resource_api.rb +107 -0
- data/lib/conductor/http/api/authorization_resource_api.rb +56 -0
- data/lib/conductor/http/api/event_resource_api.rb +133 -0
- data/lib/conductor/http/api/gateway_auth_resource_api.rb +48 -0
- data/lib/conductor/http/api/group_resource_api.rb +76 -0
- data/lib/conductor/http/api/integration_resource_api.rb +145 -0
- data/lib/conductor/http/api/metadata_resource_api.rb +231 -0
- data/lib/conductor/http/api/prompt_resource_api.rb +81 -0
- data/lib/conductor/http/api/role_resource_api.rb +60 -0
- data/lib/conductor/http/api/scheduler_resource_api.rb +211 -0
- data/lib/conductor/http/api/schema_resource_api.rb +82 -0
- data/lib/conductor/http/api/secret_resource_api.rb +134 -0
- data/lib/conductor/http/api/task_resource_api.rb +321 -0
- data/lib/conductor/http/api/token_resource_api.rb +42 -0
- data/lib/conductor/http/api/user_resource_api.rb +59 -0
- data/lib/conductor/http/api/workflow_bulk_resource_api.rb +91 -0
- data/lib/conductor/http/api/workflow_resource_api.rb +451 -0
- data/lib/conductor/http/api_client.rb +437 -0
- data/lib/conductor/http/models/authentication_config.rb +67 -0
- data/lib/conductor/http/models/authorization_request.rb +39 -0
- data/lib/conductor/http/models/base_model.rb +162 -0
- data/lib/conductor/http/models/bulk_response.rb +39 -0
- data/lib/conductor/http/models/conductor_application.rb +39 -0
- data/lib/conductor/http/models/conductor_user.rb +53 -0
- data/lib/conductor/http/models/create_or_update_application_request.rb +24 -0
- data/lib/conductor/http/models/create_or_update_role_request.rb +27 -0
- data/lib/conductor/http/models/event_handler.rb +130 -0
- data/lib/conductor/http/models/generate_token_request.rb +27 -0
- data/lib/conductor/http/models/group.rb +36 -0
- data/lib/conductor/http/models/integration.rb +70 -0
- data/lib/conductor/http/models/integration_api.rb +53 -0
- data/lib/conductor/http/models/integration_api_update.rb +43 -0
- data/lib/conductor/http/models/integration_update.rb +36 -0
- data/lib/conductor/http/models/permission.rb +24 -0
- data/lib/conductor/http/models/poll_data.rb +33 -0
- data/lib/conductor/http/models/prompt_template.rb +59 -0
- data/lib/conductor/http/models/prompt_template_test_request.rb +43 -0
- data/lib/conductor/http/models/rerun_workflow_request.rb +37 -0
- data/lib/conductor/http/models/role.rb +27 -0
- data/lib/conductor/http/models/schema_def.rb +59 -0
- data/lib/conductor/http/models/search_result.rb +187 -0
- data/lib/conductor/http/models/skip_task_request.rb +27 -0
- data/lib/conductor/http/models/start_workflow_request.rb +68 -0
- data/lib/conductor/http/models/subject_ref.rb +35 -0
- data/lib/conductor/http/models/tag_object.rb +36 -0
- data/lib/conductor/http/models/target_ref.rb +39 -0
- data/lib/conductor/http/models/task.rb +156 -0
- data/lib/conductor/http/models/task_def.rb +95 -0
- data/lib/conductor/http/models/task_exec_log.rb +30 -0
- data/lib/conductor/http/models/task_result.rb +115 -0
- data/lib/conductor/http/models/task_result_status.rb +24 -0
- data/lib/conductor/http/models/token.rb +33 -0
- data/lib/conductor/http/models/upsert_group_request.rb +30 -0
- data/lib/conductor/http/models/upsert_user_request.rb +39 -0
- data/lib/conductor/http/models/workflow.rb +202 -0
- data/lib/conductor/http/models/workflow_def.rb +73 -0
- data/lib/conductor/http/models/workflow_schedule.rb +100 -0
- data/lib/conductor/http/models/workflow_state_update.rb +30 -0
- data/lib/conductor/http/models/workflow_status_constants.rb +57 -0
- data/lib/conductor/http/models/workflow_task.rb +169 -0
- data/lib/conductor/http/models/workflow_test_request.rb +67 -0
- data/lib/conductor/http/rest_client.rb +211 -0
- data/lib/conductor/orkes/models/access_key.rb +56 -0
- data/lib/conductor/orkes/models/granted_permission.rb +27 -0
- data/lib/conductor/orkes/models/metadata_tag.rb +15 -0
- data/lib/conductor/orkes/models/rate_limit_tag.rb +15 -0
- data/lib/conductor/orkes/orkes_clients.rb +69 -0
- data/lib/conductor/version.rb +5 -0
- data/lib/conductor/worker/events/conductor_event.rb +40 -0
- data/lib/conductor/worker/events/global_dispatcher.rb +37 -0
- data/lib/conductor/worker/events/http_events.rb +25 -0
- data/lib/conductor/worker/events/listener_registry.rb +40 -0
- data/lib/conductor/worker/events/listeners.rb +34 -0
- data/lib/conductor/worker/events/sync_event_dispatcher.rb +78 -0
- data/lib/conductor/worker/events/task_runner_events.rb +271 -0
- data/lib/conductor/worker/events/workflow_events.rb +49 -0
- data/lib/conductor/worker/fiber_executor.rb +532 -0
- data/lib/conductor/worker/ractor_task_runner.rb +501 -0
- data/lib/conductor/worker/task_context.rb +114 -0
- data/lib/conductor/worker/task_definition_registrar.rb +322 -0
- data/lib/conductor/worker/task_handler.rb +360 -0
- data/lib/conductor/worker/task_in_progress.rb +60 -0
- data/lib/conductor/worker/task_runner.rb +538 -0
- data/lib/conductor/worker/telemetry/metrics_collector.rb +196 -0
- data/lib/conductor/worker/telemetry/prometheus_backend.rb +224 -0
- data/lib/conductor/worker/worker.rb +355 -0
- data/lib/conductor/worker/worker_config.rb +154 -0
- data/lib/conductor/worker/worker_registry.rb +71 -0
- data/lib/conductor/workflow/dsl/input_ref.rb +37 -0
- data/lib/conductor/workflow/dsl/output_ref.rb +44 -0
- data/lib/conductor/workflow/dsl/parallel_builder.rb +49 -0
- data/lib/conductor/workflow/dsl/switch_builder.rb +74 -0
- data/lib/conductor/workflow/dsl/task_ref.rb +178 -0
- data/lib/conductor/workflow/dsl/workflow_builder.rb +1016 -0
- data/lib/conductor/workflow/dsl/workflow_definition.rb +150 -0
- data/lib/conductor/workflow/llm/chat_message.rb +47 -0
- data/lib/conductor/workflow/llm/embedding_model.rb +19 -0
- data/lib/conductor/workflow/llm/tool_call.rb +43 -0
- data/lib/conductor/workflow/llm/tool_spec.rb +46 -0
- data/lib/conductor/workflow/task_type.rb +68 -0
- data/lib/conductor/workflow/timeout_policy.rb +31 -0
- data/lib/conductor/workflow/workflow_executor.rb +373 -0
- data/lib/conductor.rb +192 -0
- metadata +359 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../http/models/task_def'
|
|
5
|
+
require_relative '../client/metadata_client'
|
|
6
|
+
|
|
7
|
+
module Conductor
|
|
8
|
+
module Worker
|
|
9
|
+
# TaskDefinitionRegistrar - Handles automatic task definition registration
|
|
10
|
+
# Generates JSON schemas from worker function signatures and registers
|
|
11
|
+
# task definitions with the Conductor server.
|
|
12
|
+
class TaskDefinitionRegistrar
|
|
13
|
+
# @param configuration [Configuration] Conductor configuration
|
|
14
|
+
# @param logger [Logger] Logger instance
|
|
15
|
+
def initialize(configuration, logger: nil)
|
|
16
|
+
@configuration = configuration
|
|
17
|
+
@metadata_client = Client::MetadataClient.new(configuration)
|
|
18
|
+
@logger = logger || Logger.new($stdout)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Register a task definition for a worker
|
|
22
|
+
# @param worker [Worker] The worker instance
|
|
23
|
+
# @return [Boolean] True if registration succeeded
|
|
24
|
+
def register(worker)
|
|
25
|
+
return false unless worker.register_task_def
|
|
26
|
+
|
|
27
|
+
task_def = build_task_definition(worker)
|
|
28
|
+
|
|
29
|
+
# Generate schemas if worker has typed parameters
|
|
30
|
+
input_schema = generate_input_schema(worker)
|
|
31
|
+
output_schema = generate_output_schema(worker)
|
|
32
|
+
|
|
33
|
+
# Register schemas if available
|
|
34
|
+
register_schemas(worker.task_definition_name, input_schema, output_schema) if input_schema || output_schema
|
|
35
|
+
|
|
36
|
+
# Register or update task definition
|
|
37
|
+
if worker.overwrite_task_def
|
|
38
|
+
register_or_update_task_def(task_def)
|
|
39
|
+
else
|
|
40
|
+
register_if_not_exists(task_def)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@logger.info("Registered task definition: #{worker.task_definition_name}")
|
|
44
|
+
true
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
@logger.warn("Failed to register task definition '#{worker.task_definition_name}': #{e.message}")
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Build a TaskDef from worker configuration
|
|
53
|
+
# @param worker [Worker] The worker instance
|
|
54
|
+
# @return [TaskDef]
|
|
55
|
+
def build_task_definition(worker)
|
|
56
|
+
task_def = worker.task_def_template&.dup || Http::Models::TaskDef.new
|
|
57
|
+
|
|
58
|
+
task_def.name = worker.task_definition_name
|
|
59
|
+
|
|
60
|
+
# Set reasonable defaults if not provided
|
|
61
|
+
task_def.retry_count ||= 3
|
|
62
|
+
task_def.retry_logic ||= Http::Models::TaskDef::RetryLogic::FIXED
|
|
63
|
+
task_def.timeout_policy ||= Http::Models::TaskDef::TaskTimeoutPolicy::TIME_OUT_WF
|
|
64
|
+
task_def.timeout_seconds ||= 60
|
|
65
|
+
task_def.response_timeout_seconds ||= 60
|
|
66
|
+
|
|
67
|
+
task_def
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Generate JSON Schema for worker input parameters
|
|
71
|
+
# @param worker [Worker] The worker instance
|
|
72
|
+
# @return [Hash, nil] JSON Schema or nil
|
|
73
|
+
def generate_input_schema(worker)
|
|
74
|
+
return nil unless worker.execute_function.respond_to?(:parameters)
|
|
75
|
+
|
|
76
|
+
params = worker.execute_function.parameters
|
|
77
|
+
return nil if params.empty?
|
|
78
|
+
|
|
79
|
+
# Skip if first param is a positional arg (takes full Task object)
|
|
80
|
+
first_type = params.first&.first
|
|
81
|
+
return nil if %i[req opt rest].include?(first_type)
|
|
82
|
+
|
|
83
|
+
properties = {}
|
|
84
|
+
required = []
|
|
85
|
+
|
|
86
|
+
params.each do |type, name|
|
|
87
|
+
next unless name
|
|
88
|
+
|
|
89
|
+
prop_name = name.to_s
|
|
90
|
+
|
|
91
|
+
case type
|
|
92
|
+
when :keyreq # Required keyword argument
|
|
93
|
+
properties[prop_name] = infer_property_schema(name)
|
|
94
|
+
required << prop_name
|
|
95
|
+
when :key # Optional keyword argument
|
|
96
|
+
properties[prop_name] = infer_property_schema(name)
|
|
97
|
+
when :keyrest # **kwargs
|
|
98
|
+
# Can't generate schema for **kwargs
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
return nil if properties.empty?
|
|
104
|
+
|
|
105
|
+
schema = {
|
|
106
|
+
'$schema' => 'http://json-schema.org/draft-07/schema#',
|
|
107
|
+
'type' => 'object',
|
|
108
|
+
'title' => "#{worker.task_definition_name}_input",
|
|
109
|
+
'properties' => properties
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
schema['required'] = required unless required.empty?
|
|
113
|
+
schema['additionalProperties'] = !worker.strict_schema
|
|
114
|
+
|
|
115
|
+
schema
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Generate JSON Schema for worker output
|
|
119
|
+
# @param worker [Worker] The worker instance
|
|
120
|
+
# @return [Hash, nil] JSON Schema or nil
|
|
121
|
+
def generate_output_schema(_worker)
|
|
122
|
+
# Output schema is harder to infer without return type annotations
|
|
123
|
+
# In Ruby, we'd need Sorbet/RBS type annotations
|
|
124
|
+
# For now, return nil (no output schema)
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Infer property schema from parameter name
|
|
129
|
+
# Uses naming conventions to guess types
|
|
130
|
+
# @param name [Symbol] Parameter name
|
|
131
|
+
# @return [Hash] Property schema
|
|
132
|
+
def infer_property_schema(name)
|
|
133
|
+
name_str = name.to_s.downcase
|
|
134
|
+
|
|
135
|
+
# Infer type from naming conventions
|
|
136
|
+
type = if name_str.end_with?('_id', 'id', '_count', 'count', '_num', 'num', '_index', 'index')
|
|
137
|
+
'integer'
|
|
138
|
+
elsif name_str.end_with?('_at', '_time', '_date')
|
|
139
|
+
'string' # ISO8601 date string
|
|
140
|
+
elsif name_str.start_with?('is_', 'has_', 'can_', 'should_', 'enable')
|
|
141
|
+
'boolean'
|
|
142
|
+
elsif name_str.end_with?('_amount', '_price', '_rate', '_percent')
|
|
143
|
+
'number'
|
|
144
|
+
elsif name_str.end_with?('_list', '_items', '_array', '_ids')
|
|
145
|
+
'array'
|
|
146
|
+
elsif name_str.end_with?('_data', '_config', '_options', '_params', '_payload')
|
|
147
|
+
'object'
|
|
148
|
+
else
|
|
149
|
+
'string' # Default to string
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
schema = { 'type' => type }
|
|
153
|
+
|
|
154
|
+
# Add format hints for certain types
|
|
155
|
+
case name_str
|
|
156
|
+
when /email/
|
|
157
|
+
schema['format'] = 'email'
|
|
158
|
+
when /url/, /uri/, /href/
|
|
159
|
+
schema['format'] = 'uri'
|
|
160
|
+
when /_at$/, /_time$/
|
|
161
|
+
schema['format'] = 'date-time'
|
|
162
|
+
when /_date$/
|
|
163
|
+
schema['format'] = 'date'
|
|
164
|
+
when /uuid/, /guid/
|
|
165
|
+
schema['format'] = 'uuid'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
schema
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Register schemas with the server
|
|
172
|
+
# @param task_name [String] Task definition name
|
|
173
|
+
# @param input_schema [Hash, nil] Input schema
|
|
174
|
+
# @param output_schema [Hash, nil] Output schema
|
|
175
|
+
def register_schemas(task_name, input_schema, output_schema)
|
|
176
|
+
# NOTE: Schema registration requires Orkes Conductor
|
|
177
|
+
# OSS Conductor may not have this endpoint
|
|
178
|
+
|
|
179
|
+
if input_schema
|
|
180
|
+
begin
|
|
181
|
+
register_schema("#{task_name}_input", input_schema)
|
|
182
|
+
rescue ApiError => e
|
|
183
|
+
@logger.debug("Schema registration not available: #{e.message}") if e.status == 404
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
return unless output_schema
|
|
188
|
+
|
|
189
|
+
begin
|
|
190
|
+
register_schema("#{task_name}_output", output_schema)
|
|
191
|
+
rescue ApiError => e
|
|
192
|
+
@logger.debug("Schema registration not available: #{e.message}") if e.status == 404
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Register a single schema
|
|
197
|
+
# @param name [String] Schema name
|
|
198
|
+
# @param schema [Hash] JSON Schema
|
|
199
|
+
def register_schema(name, _schema)
|
|
200
|
+
# This would call the schema API if available
|
|
201
|
+
# For now, just log
|
|
202
|
+
@logger.debug("Would register schema: #{name}")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Register task def, update if already exists
|
|
206
|
+
# @param task_def [TaskDef]
|
|
207
|
+
def register_or_update_task_def(task_def)
|
|
208
|
+
@metadata_client.update_task_def(task_def)
|
|
209
|
+
rescue ApiError => e
|
|
210
|
+
raise unless e.status == 404
|
|
211
|
+
|
|
212
|
+
# Task def doesn't exist, create it
|
|
213
|
+
@metadata_client.register_task_def([task_def])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Register task def only if it doesn't exist
|
|
217
|
+
# @param task_def [TaskDef]
|
|
218
|
+
def register_if_not_exists(task_def)
|
|
219
|
+
existing = @metadata_client.get_task_def(task_def.name)
|
|
220
|
+
@logger.info("Task definition '#{task_def.name}' already exists, skipping")
|
|
221
|
+
rescue ApiError => e
|
|
222
|
+
raise unless e.status == 404
|
|
223
|
+
|
|
224
|
+
# Task def doesn't exist, create it
|
|
225
|
+
@metadata_client.register_task_def([task_def])
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# JsonSchemaGenerator - Utility for generating JSON Schema from Ruby types
|
|
230
|
+
# Can be extended to work with Sorbet types or RBS annotations
|
|
231
|
+
class JsonSchemaGenerator
|
|
232
|
+
# Ruby type to JSON Schema type mapping
|
|
233
|
+
TYPE_MAP = {
|
|
234
|
+
'String' => 'string',
|
|
235
|
+
'Integer' => 'integer',
|
|
236
|
+
'Float' => 'number',
|
|
237
|
+
'Numeric' => 'number',
|
|
238
|
+
'TrueClass' => 'boolean',
|
|
239
|
+
'FalseClass' => 'boolean',
|
|
240
|
+
'Array' => 'array',
|
|
241
|
+
'Hash' => 'object',
|
|
242
|
+
'NilClass' => 'null',
|
|
243
|
+
'Time' => 'string',
|
|
244
|
+
'Date' => 'string',
|
|
245
|
+
'DateTime' => 'string'
|
|
246
|
+
}.freeze
|
|
247
|
+
|
|
248
|
+
# Generate JSON Schema from a Ruby value (for inference)
|
|
249
|
+
# @param value [Object] A sample value
|
|
250
|
+
# @return [Hash] JSON Schema
|
|
251
|
+
def self.from_value(value)
|
|
252
|
+
case value
|
|
253
|
+
when String
|
|
254
|
+
{ 'type' => 'string' }
|
|
255
|
+
when Integer
|
|
256
|
+
{ 'type' => 'integer' }
|
|
257
|
+
when Float
|
|
258
|
+
{ 'type' => 'number' }
|
|
259
|
+
when TrueClass, FalseClass
|
|
260
|
+
{ 'type' => 'boolean' }
|
|
261
|
+
when Array
|
|
262
|
+
if value.empty?
|
|
263
|
+
{ 'type' => 'array' }
|
|
264
|
+
else
|
|
265
|
+
{ 'type' => 'array', 'items' => from_value(value.first) }
|
|
266
|
+
end
|
|
267
|
+
when Hash
|
|
268
|
+
generate_object_schema(value)
|
|
269
|
+
when Time, DateTime
|
|
270
|
+
{ 'type' => 'string', 'format' => 'date-time' }
|
|
271
|
+
when Date
|
|
272
|
+
{ 'type' => 'string', 'format' => 'date' }
|
|
273
|
+
when NilClass
|
|
274
|
+
{ 'type' => 'null' }
|
|
275
|
+
else
|
|
276
|
+
{ 'type' => 'object' }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Generate schema from a hash with sample values
|
|
281
|
+
# @param hash [Hash] Hash with sample values
|
|
282
|
+
# @return [Hash] JSON Schema
|
|
283
|
+
def self.generate_object_schema(hash)
|
|
284
|
+
properties = {}
|
|
285
|
+
hash.each do |key, value|
|
|
286
|
+
properties[key.to_s] = from_value(value)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
{
|
|
290
|
+
'type' => 'object',
|
|
291
|
+
'properties' => properties
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Generate a schema from a Ruby class definition
|
|
296
|
+
# Works with Struct, Data (Ruby 3.2+), or classes with attr_accessor
|
|
297
|
+
# @param klass [Class] Ruby class
|
|
298
|
+
# @return [Hash] JSON Schema
|
|
299
|
+
def self.from_class(klass)
|
|
300
|
+
properties = {}
|
|
301
|
+
|
|
302
|
+
# Try to get attribute names
|
|
303
|
+
if klass.respond_to?(:members)
|
|
304
|
+
# Struct or Data
|
|
305
|
+
klass.members.each do |attr|
|
|
306
|
+
properties[attr.to_s] = { 'type' => 'string' }
|
|
307
|
+
end
|
|
308
|
+
elsif klass.instance_methods.include?(:to_h)
|
|
309
|
+
# Has to_h, try to instantiate and inspect
|
|
310
|
+
# Skip this for safety
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
'$schema' => 'http://json-schema.org/draft-07/schema#',
|
|
315
|
+
'type' => 'object',
|
|
316
|
+
'title' => klass.name,
|
|
317
|
+
'properties' => properties
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'worker'
|
|
5
|
+
require_relative 'worker_registry'
|
|
6
|
+
require_relative 'worker_config'
|
|
7
|
+
require_relative 'task_runner'
|
|
8
|
+
require_relative 'task_definition_registrar'
|
|
9
|
+
require_relative 'events/sync_event_dispatcher'
|
|
10
|
+
require_relative 'events/listener_registry'
|
|
11
|
+
|
|
12
|
+
module Conductor
|
|
13
|
+
module Worker
|
|
14
|
+
# TaskHandler - The top-level orchestrator that manages all workers
|
|
15
|
+
# Creates one Thread per worker, each running a TaskRunner
|
|
16
|
+
#
|
|
17
|
+
# Supports multiple execution modes based on worker configuration:
|
|
18
|
+
# - :thread (default) - Thread-based with ThreadPoolExecutor
|
|
19
|
+
# - :ractor - Ractor-based for true parallelism (Ruby 3.1+)
|
|
20
|
+
# - :fiber - Fiber-based with async gem for high I/O concurrency
|
|
21
|
+
class TaskHandler
|
|
22
|
+
attr_reader :workers, :configuration, :event_dispatcher
|
|
23
|
+
|
|
24
|
+
# Initialize TaskHandler
|
|
25
|
+
# @param workers [Array<Worker>, nil] Pre-created worker instances
|
|
26
|
+
# @param configuration [Configuration, nil] Conductor configuration
|
|
27
|
+
# @param scan_for_annotated_workers [Boolean] Auto-discover workers from registry
|
|
28
|
+
# @param import_modules [Array<String>, nil] Ruby files to require (triggers registration)
|
|
29
|
+
# @param event_listeners [Array<Object>, nil] Custom event listeners
|
|
30
|
+
# @param logger [Logger, nil] Logger instance
|
|
31
|
+
# @param register_task_definitions [Boolean] Auto-register task definitions on start
|
|
32
|
+
def initialize(
|
|
33
|
+
workers: nil,
|
|
34
|
+
configuration: nil,
|
|
35
|
+
scan_for_annotated_workers: true,
|
|
36
|
+
import_modules: nil,
|
|
37
|
+
event_listeners: nil,
|
|
38
|
+
logger: nil,
|
|
39
|
+
register_task_definitions: false
|
|
40
|
+
)
|
|
41
|
+
@configuration = configuration || Configuration.new
|
|
42
|
+
@logger = logger || create_default_logger
|
|
43
|
+
@event_dispatcher = Events::SyncEventDispatcher.new
|
|
44
|
+
@workers = []
|
|
45
|
+
@threads = []
|
|
46
|
+
@runners = []
|
|
47
|
+
@ractors = [] # For Ractor-based workers
|
|
48
|
+
@running = false
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@register_task_definitions = register_task_definitions
|
|
51
|
+
@event_listeners = []
|
|
52
|
+
|
|
53
|
+
# Register event listeners
|
|
54
|
+
register_listeners(event_listeners) if event_listeners
|
|
55
|
+
|
|
56
|
+
# Import modules (triggers worker_task registrations)
|
|
57
|
+
import_worker_modules(import_modules) if import_modules
|
|
58
|
+
|
|
59
|
+
# Discover workers from registry
|
|
60
|
+
discover_registered_workers if scan_for_annotated_workers
|
|
61
|
+
|
|
62
|
+
# Add provided workers
|
|
63
|
+
add_workers(workers) if workers
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Add workers to the handler
|
|
67
|
+
# @param workers [Array<Worker>] Workers to add
|
|
68
|
+
# @return [self]
|
|
69
|
+
def add_workers(workers)
|
|
70
|
+
workers.each { |w| add_worker(w) }
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Add a single worker
|
|
75
|
+
# @param worker [Worker] Worker to add
|
|
76
|
+
# @return [self]
|
|
77
|
+
def add_worker(worker)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
@workers << worker
|
|
80
|
+
end
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Start all worker threads
|
|
85
|
+
# @return [self]
|
|
86
|
+
def start
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
return self if @running
|
|
89
|
+
|
|
90
|
+
@running = true
|
|
91
|
+
|
|
92
|
+
# Register task definitions if enabled
|
|
93
|
+
register_all_task_definitions if @register_task_definitions
|
|
94
|
+
|
|
95
|
+
@workers.each do |worker|
|
|
96
|
+
start_worker(worker)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@logger.info("TaskHandler started with #{@workers.size} workers")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Start a single worker with appropriate runner type
|
|
108
|
+
# @param worker [Worker] The worker to start
|
|
109
|
+
def start_worker(worker)
|
|
110
|
+
# Determine execution mode from worker configuration
|
|
111
|
+
isolation = worker.respond_to?(:isolation) ? worker.isolation : :thread
|
|
112
|
+
executor = worker.respond_to?(:executor) ? worker.executor : :thread_pool
|
|
113
|
+
|
|
114
|
+
case isolation
|
|
115
|
+
when :ractor
|
|
116
|
+
start_ractor_worker(worker)
|
|
117
|
+
else
|
|
118
|
+
# Thread-based execution (default)
|
|
119
|
+
case executor
|
|
120
|
+
when :fiber
|
|
121
|
+
start_fiber_worker(worker)
|
|
122
|
+
else
|
|
123
|
+
start_thread_worker(worker)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Start a thread-based worker (default mode)
|
|
129
|
+
# @param worker [Worker] The worker to start
|
|
130
|
+
def start_thread_worker(worker)
|
|
131
|
+
runner = TaskRunner.new(
|
|
132
|
+
worker,
|
|
133
|
+
configuration: @configuration,
|
|
134
|
+
event_dispatcher: @event_dispatcher,
|
|
135
|
+
logger: @logger
|
|
136
|
+
)
|
|
137
|
+
@runners << runner
|
|
138
|
+
|
|
139
|
+
thread = Thread.new(runner) do |r|
|
|
140
|
+
Thread.current.name = "conductor-worker-#{r.worker.task_definition_name}"
|
|
141
|
+
r.run
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
@logger.fatal("Fatal error in worker '#{r.worker.task_definition_name}': #{e.message}")
|
|
144
|
+
@logger.debug(e.backtrace.join("\n")) if e.backtrace
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@threads << thread
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Start a Ractor-based worker for true parallelism
|
|
151
|
+
# @param worker [Worker] The worker to start
|
|
152
|
+
def start_ractor_worker(worker)
|
|
153
|
+
require_relative 'ractor_task_runner'
|
|
154
|
+
|
|
155
|
+
RactorSupport.require_ractors!
|
|
156
|
+
|
|
157
|
+
thread_count = worker.respond_to?(:thread_count) ? worker.thread_count : 1
|
|
158
|
+
|
|
159
|
+
# Create event receiver Ractor to collect events from worker Ractors
|
|
160
|
+
event_receiver = create_event_receiver_ractor(worker.task_definition_name)
|
|
161
|
+
|
|
162
|
+
# Create multiple Ractors for parallelism
|
|
163
|
+
thread_count.times do |i|
|
|
164
|
+
ractor = Ractor.new(worker, @configuration, i, event_receiver) do |w, config, ractor_id, evt_queue|
|
|
165
|
+
runner = RactorTaskRunner.new(
|
|
166
|
+
w,
|
|
167
|
+
configuration: config,
|
|
168
|
+
ractor_id: ractor_id,
|
|
169
|
+
event_queue: evt_queue
|
|
170
|
+
)
|
|
171
|
+
runner.run
|
|
172
|
+
end
|
|
173
|
+
@ractors << ractor
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
@logger.info("Started #{thread_count} Ractor(s) for '#{worker.task_definition_name}'")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Start a fiber-based worker for high I/O concurrency
|
|
180
|
+
# @param worker [Worker] The worker to start
|
|
181
|
+
def start_fiber_worker(worker)
|
|
182
|
+
require_relative 'fiber_executor'
|
|
183
|
+
|
|
184
|
+
AsyncSupport.require_async!
|
|
185
|
+
|
|
186
|
+
runner = FiberTaskRunner.new(
|
|
187
|
+
worker,
|
|
188
|
+
configuration: @configuration,
|
|
189
|
+
event_dispatcher: @event_dispatcher,
|
|
190
|
+
logger: @logger
|
|
191
|
+
)
|
|
192
|
+
@runners << runner
|
|
193
|
+
|
|
194
|
+
thread = Thread.new(runner) do |r|
|
|
195
|
+
Thread.current.name = "conductor-fiber-#{r.worker.task_definition_name}"
|
|
196
|
+
r.run
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
@logger.fatal("Fatal error in fiber worker '#{r.worker.task_definition_name}': #{e.message}")
|
|
199
|
+
@logger.debug(e.backtrace.join("\n")) if e.backtrace
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
@threads << thread
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Create event receiver Ractor to forward events to dispatcher
|
|
206
|
+
# @param task_name [String] Task name for logging
|
|
207
|
+
# @return [Ractor] Event receiver Ractor
|
|
208
|
+
def create_event_receiver_ractor(task_name)
|
|
209
|
+
dispatcher = @event_dispatcher
|
|
210
|
+
logger = @logger
|
|
211
|
+
|
|
212
|
+
Thread.new do
|
|
213
|
+
Thread.current.name = "conductor-event-receiver-#{task_name}"
|
|
214
|
+
# NOTE: In production, this would need proper Ractor communication
|
|
215
|
+
# For now, events from Ractors are logged but not dispatched
|
|
216
|
+
# due to Ractor isolation constraints
|
|
217
|
+
logger.debug("Event receiver started for #{task_name}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Return nil for now - Ractor event communication needs more work
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Register all task definitions
|
|
225
|
+
def register_all_task_definitions
|
|
226
|
+
registrar = TaskDefinitionRegistrar.new(@configuration, logger: @logger)
|
|
227
|
+
@workers.each do |worker|
|
|
228
|
+
registrar.register(worker)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
public
|
|
233
|
+
|
|
234
|
+
# Stop all workers gracefully
|
|
235
|
+
# @param timeout [Integer] Seconds to wait before force-killing threads
|
|
236
|
+
# @return [self]
|
|
237
|
+
def stop(timeout: 5)
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
return self unless @running
|
|
240
|
+
|
|
241
|
+
@logger.info('Stopping TaskHandler...')
|
|
242
|
+
|
|
243
|
+
# Signal all runners to shutdown
|
|
244
|
+
@runners.each(&:shutdown)
|
|
245
|
+
|
|
246
|
+
# Wait for threads to finish
|
|
247
|
+
@threads.each do |thread|
|
|
248
|
+
thread.join(timeout)
|
|
249
|
+
thread.kill if thread.alive?
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Shutdown Ractors
|
|
253
|
+
@ractors.each do |ractor|
|
|
254
|
+
# Ractors don't have a clean shutdown mechanism
|
|
255
|
+
# They'll be GC'd when no longer referenced
|
|
256
|
+
ractor.take if ractor.respond_to?(:take)
|
|
257
|
+
rescue Ractor::ClosedError, Ractor::RemoteError
|
|
258
|
+
# Ractor already finished
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
@runners.clear
|
|
262
|
+
@threads.clear
|
|
263
|
+
@ractors.clear
|
|
264
|
+
|
|
265
|
+
stop_event_listeners
|
|
266
|
+
@running = false
|
|
267
|
+
|
|
268
|
+
@logger.info('TaskHandler stopped')
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
self
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Wait for all worker threads to complete (blocking)
|
|
275
|
+
# @return [self]
|
|
276
|
+
def join
|
|
277
|
+
@threads.each(&:join)
|
|
278
|
+
self
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Check if handler is running
|
|
282
|
+
# @return [Boolean]
|
|
283
|
+
def running?
|
|
284
|
+
@running
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Get list of worker names
|
|
288
|
+
# @return [Array<String>]
|
|
289
|
+
def worker_names
|
|
290
|
+
@workers.map(&:task_definition_name)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Context manager pattern - execute block and stop on exit
|
|
294
|
+
# @yield [self]
|
|
295
|
+
# @return [Object] Block return value
|
|
296
|
+
def self.run(workers: nil, configuration: nil, **options)
|
|
297
|
+
handler = new(workers: workers, configuration: configuration, **options)
|
|
298
|
+
begin
|
|
299
|
+
yield handler if block_given?
|
|
300
|
+
ensure
|
|
301
|
+
handler.stop
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
# Create default logger
|
|
308
|
+
# @return [Logger]
|
|
309
|
+
def create_default_logger
|
|
310
|
+
logger = Logger.new($stdout)
|
|
311
|
+
logger.level = Logger::INFO
|
|
312
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
313
|
+
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} -- #{msg}\n"
|
|
314
|
+
end
|
|
315
|
+
logger
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Register event listeners
|
|
319
|
+
# @param listeners [Array<Object>] Listeners to register
|
|
320
|
+
def register_listeners(listeners)
|
|
321
|
+
listeners.each do |listener|
|
|
322
|
+
@event_listeners << listener
|
|
323
|
+
Events::ListenerRegistry.register_task_runner_listener(listener, @event_dispatcher)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def stop_event_listeners
|
|
328
|
+
@event_listeners.each do |listener|
|
|
329
|
+
listener.stop if listener.respond_to?(:stop)
|
|
330
|
+
rescue StandardError => e
|
|
331
|
+
@logger.debug { "Error stopping listener: #{e.class}: #{e.message}" }
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Import worker modules from file paths
|
|
336
|
+
# @param modules [Array<String>] File paths or module names to require
|
|
337
|
+
def import_worker_modules(modules)
|
|
338
|
+
modules.each do |mod|
|
|
339
|
+
require mod
|
|
340
|
+
rescue LoadError => e
|
|
341
|
+
@logger.warn("Failed to load module '#{mod}': #{e.message}")
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Discover workers from the global registry
|
|
346
|
+
def discover_registered_workers
|
|
347
|
+
WorkerRegistry.all.each do |definition|
|
|
348
|
+
worker = Worker.new(
|
|
349
|
+
definition[:task_definition_name],
|
|
350
|
+
definition[:execute_function],
|
|
351
|
+
**definition[:options]
|
|
352
|
+
)
|
|
353
|
+
@workers << worker
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
@logger.info("Discovered #{WorkerRegistry.count} workers from registry") if WorkerRegistry.count.positive?
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|