a2a-ruby 1.0.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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +137 -0
  4. data/.simplecov +46 -0
  5. data/.yardopts +10 -0
  6. data/CHANGELOG.md +33 -0
  7. data/CODE_OF_CONDUCT.md +128 -0
  8. data/CONTRIBUTING.md +165 -0
  9. data/Gemfile +43 -0
  10. data/Guardfile +34 -0
  11. data/LICENSE.txt +21 -0
  12. data/PUBLISHING_CHECKLIST.md +214 -0
  13. data/README.md +171 -0
  14. data/Rakefile +165 -0
  15. data/docs/agent_execution.md +309 -0
  16. data/docs/api_reference.md +792 -0
  17. data/docs/configuration.md +780 -0
  18. data/docs/events.md +475 -0
  19. data/docs/getting_started.md +668 -0
  20. data/docs/integration.md +262 -0
  21. data/docs/server_apps.md +621 -0
  22. data/docs/troubleshooting.md +765 -0
  23. data/lib/a2a/client/api_methods.rb +263 -0
  24. data/lib/a2a/client/auth/api_key.rb +161 -0
  25. data/lib/a2a/client/auth/interceptor.rb +288 -0
  26. data/lib/a2a/client/auth/jwt.rb +189 -0
  27. data/lib/a2a/client/auth/oauth2.rb +146 -0
  28. data/lib/a2a/client/auth.rb +137 -0
  29. data/lib/a2a/client/base.rb +316 -0
  30. data/lib/a2a/client/config.rb +210 -0
  31. data/lib/a2a/client/connection_pool.rb +233 -0
  32. data/lib/a2a/client/http_client.rb +524 -0
  33. data/lib/a2a/client/json_rpc_handler.rb +136 -0
  34. data/lib/a2a/client/middleware/circuit_breaker_interceptor.rb +245 -0
  35. data/lib/a2a/client/middleware/logging_interceptor.rb +371 -0
  36. data/lib/a2a/client/middleware/rate_limit_interceptor.rb +142 -0
  37. data/lib/a2a/client/middleware/retry_interceptor.rb +161 -0
  38. data/lib/a2a/client/middleware.rb +116 -0
  39. data/lib/a2a/client/performance_tracker.rb +60 -0
  40. data/lib/a2a/configuration/defaults.rb +34 -0
  41. data/lib/a2a/configuration/environment_loader.rb +76 -0
  42. data/lib/a2a/configuration/file_loader.rb +115 -0
  43. data/lib/a2a/configuration/inheritance.rb +101 -0
  44. data/lib/a2a/configuration/validator.rb +180 -0
  45. data/lib/a2a/configuration.rb +201 -0
  46. data/lib/a2a/errors.rb +291 -0
  47. data/lib/a2a/modules.rb +50 -0
  48. data/lib/a2a/monitoring/alerting.rb +490 -0
  49. data/lib/a2a/monitoring/distributed_tracing.rb +398 -0
  50. data/lib/a2a/monitoring/health_endpoints.rb +204 -0
  51. data/lib/a2a/monitoring/metrics_collector.rb +438 -0
  52. data/lib/a2a/monitoring.rb +463 -0
  53. data/lib/a2a/plugin.rb +358 -0
  54. data/lib/a2a/plugin_manager.rb +159 -0
  55. data/lib/a2a/plugins/example_auth.rb +81 -0
  56. data/lib/a2a/plugins/example_middleware.rb +118 -0
  57. data/lib/a2a/plugins/example_transport.rb +76 -0
  58. data/lib/a2a/protocol/agent_card.rb +8 -0
  59. data/lib/a2a/protocol/agent_card_server.rb +584 -0
  60. data/lib/a2a/protocol/capability.rb +496 -0
  61. data/lib/a2a/protocol/json_rpc.rb +254 -0
  62. data/lib/a2a/protocol/message.rb +8 -0
  63. data/lib/a2a/protocol/task.rb +8 -0
  64. data/lib/a2a/rails/a2a_controller.rb +258 -0
  65. data/lib/a2a/rails/controller_helpers.rb +499 -0
  66. data/lib/a2a/rails/engine.rb +167 -0
  67. data/lib/a2a/rails/generators/agent_generator.rb +311 -0
  68. data/lib/a2a/rails/generators/install_generator.rb +209 -0
  69. data/lib/a2a/rails/generators/migration_generator.rb +232 -0
  70. data/lib/a2a/rails/generators/templates/add_a2a_indexes.rb +57 -0
  71. data/lib/a2a/rails/generators/templates/agent_controller.rb +122 -0
  72. data/lib/a2a/rails/generators/templates/agent_controller_spec.rb +160 -0
  73. data/lib/a2a/rails/generators/templates/agent_readme.md +200 -0
  74. data/lib/a2a/rails/generators/templates/create_a2a_push_notification_configs.rb +68 -0
  75. data/lib/a2a/rails/generators/templates/create_a2a_tasks.rb +83 -0
  76. data/lib/a2a/rails/generators/templates/example_agent_controller.rb +228 -0
  77. data/lib/a2a/rails/generators/templates/initializer.rb +108 -0
  78. data/lib/a2a/rails/generators/templates/push_notification_config_model.rb +228 -0
  79. data/lib/a2a/rails/generators/templates/task_model.rb +200 -0
  80. data/lib/a2a/rails/tasks/a2a.rake +228 -0
  81. data/lib/a2a/server/a2a_methods.rb +520 -0
  82. data/lib/a2a/server/agent.rb +537 -0
  83. data/lib/a2a/server/agent_execution/agent_executor.rb +279 -0
  84. data/lib/a2a/server/agent_execution/request_context.rb +219 -0
  85. data/lib/a2a/server/apps/rack_app.rb +311 -0
  86. data/lib/a2a/server/apps/sinatra_app.rb +261 -0
  87. data/lib/a2a/server/default_request_handler.rb +350 -0
  88. data/lib/a2a/server/events/event_consumer.rb +116 -0
  89. data/lib/a2a/server/events/event_queue.rb +226 -0
  90. data/lib/a2a/server/example_agent.rb +248 -0
  91. data/lib/a2a/server/handler.rb +281 -0
  92. data/lib/a2a/server/middleware/authentication_middleware.rb +212 -0
  93. data/lib/a2a/server/middleware/cors_middleware.rb +171 -0
  94. data/lib/a2a/server/middleware/logging_middleware.rb +362 -0
  95. data/lib/a2a/server/middleware/rate_limit_middleware.rb +382 -0
  96. data/lib/a2a/server/middleware.rb +213 -0
  97. data/lib/a2a/server/push_notification_manager.rb +327 -0
  98. data/lib/a2a/server/request_handler.rb +136 -0
  99. data/lib/a2a/server/storage/base.rb +141 -0
  100. data/lib/a2a/server/storage/database.rb +266 -0
  101. data/lib/a2a/server/storage/memory.rb +274 -0
  102. data/lib/a2a/server/storage/redis.rb +320 -0
  103. data/lib/a2a/server/storage.rb +38 -0
  104. data/lib/a2a/server/task_manager.rb +534 -0
  105. data/lib/a2a/transport/grpc.rb +481 -0
  106. data/lib/a2a/transport/http.rb +415 -0
  107. data/lib/a2a/transport/sse.rb +499 -0
  108. data/lib/a2a/types/agent_card.rb +540 -0
  109. data/lib/a2a/types/artifact.rb +99 -0
  110. data/lib/a2a/types/base_model.rb +223 -0
  111. data/lib/a2a/types/events.rb +117 -0
  112. data/lib/a2a/types/message.rb +106 -0
  113. data/lib/a2a/types/part.rb +288 -0
  114. data/lib/a2a/types/push_notification.rb +139 -0
  115. data/lib/a2a/types/security.rb +167 -0
  116. data/lib/a2a/types/task.rb +154 -0
  117. data/lib/a2a/types.rb +88 -0
  118. data/lib/a2a/utils/helpers.rb +245 -0
  119. data/lib/a2a/utils/message_buffer.rb +278 -0
  120. data/lib/a2a/utils/performance.rb +247 -0
  121. data/lib/a2a/utils/rails_detection.rb +97 -0
  122. data/lib/a2a/utils/structured_logger.rb +306 -0
  123. data/lib/a2a/utils/time_helpers.rb +167 -0
  124. data/lib/a2a/utils/validation.rb +8 -0
  125. data/lib/a2a/version.rb +6 -0
  126. data/lib/a2a-rails.rb +58 -0
  127. data/lib/a2a.rb +198 -0
  128. metadata +437 -0
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "active_record"
5
+ rescue LoadError
6
+ # ActiveRecord is optional - only load if available
7
+ end
8
+
9
+ module A2A
10
+ module Server
11
+ module Storage
12
+ end
13
+ end
14
+ end
15
+
16
+ # Only define the class if ActiveRecord is available
17
+ if defined?(ActiveRecord)
18
+ ##
19
+ # Database storage backend for tasks using ActiveRecord
20
+ #
21
+ # This storage backend persists tasks to a database using ActiveRecord.
22
+ # It requires ActiveRecord to be available and properly configured.
23
+ #
24
+ module A2A
25
+ module Server
26
+ module Storage
27
+ class Database < A2A::Server::Storage::Base
28
+ ##
29
+ # ActiveRecord model for task persistence
30
+ #
31
+ class TaskRecord < (defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base)
32
+ self.table_name = "a2a_tasks"
33
+
34
+ validates :task_id, presence: true, uniqueness: true
35
+ validates :context_id, presence: true
36
+ validates :task_data, presence: true
37
+
38
+ # Serialize task data as JSON
39
+ serialize :task_data, coder: JSON
40
+
41
+ # Indexes for efficient querying
42
+ # These should be created in a migration:
43
+ # add_index :a2a_tasks, :task_id, unique: true
44
+ # add_index :a2a_tasks, :context_id
45
+ # add_index :a2a_tasks, :created_at
46
+ # add_index :a2a_tasks, :updated_at
47
+ end
48
+
49
+ ##
50
+ # Initialize the database storage
51
+ #
52
+ # @param connection [ActiveRecord::Base, nil] Optional AR connection
53
+ # @raise [LoadError] If ActiveRecord is not available
54
+ def initialize(connection: nil)
55
+ unless defined?(ActiveRecord)
56
+ raise LoadError, "ActiveRecord is required for database storage. Add 'activerecord' to your Gemfile."
57
+ end
58
+
59
+ @connection = connection || ActiveRecord::Base
60
+ ensure_table_exists!
61
+ end
62
+
63
+ ##
64
+ # Save a task to the database
65
+ #
66
+ # @param task [A2A::Types::Task] The task to save
67
+ # @return [void]
68
+ def save_task(task)
69
+ task_data = serialize_task(task)
70
+
71
+ record = TaskRecord.find_or_initialize_by(task_id: task.id)
72
+ record.assign_attributes(
73
+ context_id: task.context_id,
74
+ task_data: task_data,
75
+ updated_at: Time.now
76
+ )
77
+
78
+ record.created_at = Time.now if record.new_record?
79
+
80
+ record.save!
81
+ end
82
+
83
+ ##
84
+ # Get a task by ID
85
+ #
86
+ # @param task_id [String] The task ID
87
+ # @return [A2A::Types::Task, nil] The task or nil if not found
88
+ def get_task(task_id)
89
+ record = TaskRecord.find_by(task_id: task_id)
90
+ return nil unless record
91
+
92
+ deserialize_task(record.task_data)
93
+ end
94
+
95
+ ##
96
+ # Delete a task by ID
97
+ #
98
+ # @param task_id [String] The task ID
99
+ # @return [Boolean] True if task was deleted, false if not found
100
+ def delete_task(task_id)
101
+ deleted_count = TaskRecord.where(task_id: task_id).delete_all
102
+ deleted_count.positive?
103
+ end
104
+
105
+ ##
106
+ # List all tasks for a given context ID
107
+ #
108
+ # @param context_id [String] The context ID
109
+ # @return [Array<A2A::Types::Task>] Tasks in the context
110
+ def list_tasks_by_context(context_id)
111
+ records = TaskRecord.where(context_id: context_id).order(:created_at)
112
+ records.map { |record| deserialize_task(record.task_data) }
113
+ end
114
+
115
+ ##
116
+ # List all tasks
117
+ #
118
+ # @return [Array<A2A::Types::Task>] All tasks
119
+ def list_all_tasks
120
+ records = TaskRecord.order(:created_at)
121
+ records.map { |record| deserialize_task(record.task_data) }
122
+ end
123
+
124
+ ##
125
+ # List tasks with optional filtering
126
+ #
127
+ # @param filters [Hash] Optional filters (state, context_id, etc.)
128
+ # @return [Array<A2A::Types::Task>] Filtered tasks
129
+ def list_tasks(**filters)
130
+ query = build_base_query(filters)
131
+ tasks = query.map { |record| deserialize_task(record.task_data) }
132
+
133
+ apply_post_query_filters(tasks, filters)
134
+ end
135
+
136
+ ##
137
+ # Clear all tasks
138
+ #
139
+ # @return [void]
140
+ def clear_all_tasks
141
+ TaskRecord.delete_all
142
+ end
143
+
144
+ ##
145
+ # Get the number of stored tasks
146
+ #
147
+ # @return [Integer] Number of tasks
148
+ def task_count
149
+ TaskRecord.count
150
+ end
151
+
152
+ ##
153
+ # Create the tasks table if it doesn't exist
154
+ #
155
+ # This is a convenience method for development/testing.
156
+ # In production, use proper migrations.
157
+ #
158
+ # @return [void]
159
+ def create_table!
160
+ return if table_exists?
161
+
162
+ @connection.connection.create_table :a2a_tasks, force: true do |t|
163
+ t.string :task_id, null: false, limit: 255
164
+ t.string :context_id, null: false, limit: 255
165
+ t.text :task_data, null: false
166
+ t.timestamps null: false
167
+
168
+ t.index :task_id, unique: true
169
+ t.index :context_id
170
+ t.index :created_at
171
+ t.index :updated_at
172
+ end
173
+ end
174
+
175
+ ##
176
+ # Drop the tasks table
177
+ #
178
+ # @return [void]
179
+ def drop_table!
180
+ @connection.connection.drop_table :a2a_tasks if table_exists?
181
+ end
182
+
183
+ ##
184
+ # Check if the tasks table exists
185
+ #
186
+ # @return [Boolean] True if table exists
187
+ def table_exists?
188
+ @connection.connection.table_exists?(:a2a_tasks)
189
+ end
190
+
191
+ ##
192
+ # Ensure the tasks table exists
193
+ #
194
+ # @return [void]
195
+ def ensure_table_exists!
196
+ return if table_exists?
197
+
198
+ warn "A2A tasks table does not exist. Creating it automatically."
199
+ warn "In production, you should create this table using a proper migration."
200
+ create_table!
201
+ end
202
+
203
+ ##
204
+ # Serialize a task to a hash for database storage
205
+ #
206
+ # @param task [A2A::Types::Task] The task to serialize
207
+ # @return [Hash] Serialized task data
208
+ def serialize_task(task)
209
+ task.to_h
210
+ end
211
+
212
+ ##
213
+ # Deserialize a task from database storage
214
+ #
215
+ # @param task_data [Hash] Serialized task data
216
+ # @return [A2A::Types::Task] The deserialized task
217
+ def deserialize_task(task_data)
218
+ A2A::Types::Task.from_h(task_data)
219
+ end
220
+
221
+ private
222
+
223
+ def build_base_query(filters)
224
+ query = TaskRecord.order(:created_at)
225
+ query = query.where(context_id: filters[:context_id]) if filters[:context_id]
226
+ query
227
+ end
228
+
229
+ def apply_post_query_filters(tasks, filters)
230
+ tasks = tasks.select { |task| task.status&.state == filters[:state] } if filters[:state]
231
+ apply_metadata_filters(tasks, filters)
232
+ end
233
+
234
+ def apply_metadata_filters(tasks, filters)
235
+ filters.each do |key, value|
236
+ next if %i[state context_id].include?(key)
237
+
238
+ tasks = tasks.select { |task| apply_single_filter(task, key, value) }
239
+ end
240
+ tasks
241
+ end
242
+
243
+ def apply_single_filter(task, key, value)
244
+ case key
245
+ when :task_type
246
+ task.metadata&.dig(:type) == value || task.metadata&.dig("type") == value
247
+ when :created_after
248
+ created_at = get_created_at(task)
249
+ created_at && Time.parse(created_at) > value
250
+ when :created_before
251
+ created_at = get_created_at(task)
252
+ created_at && Time.parse(created_at) < value
253
+ else
254
+ # Generic metadata filter
255
+ task.metadata&.dig(key) == value || task.metadata&.dig(key.to_s) == value
256
+ end
257
+ end
258
+
259
+ def get_created_at(task)
260
+ task.metadata&.dig(:created_at) || task.metadata&.dig("created_at")
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # In-memory storage backend for tasks
5
+ #
6
+ # This storage backend keeps all tasks in memory using a simple hash.
7
+ # It's suitable for development, testing, and single-process deployments
8
+ # where persistence across restarts is not required.
9
+ #
10
+ module A2A
11
+ module Server
12
+ module Storage
13
+ class Memory < A2A::Server::Storage::Base
14
+ ##
15
+ # Initialize the memory storage with performance optimizations
16
+ def initialize
17
+ @tasks = {}
18
+ @push_configs = {}
19
+ @context_index = {} # Index for faster context-based lookups
20
+ @mutex = Mutex.new
21
+ @stats = {
22
+ reads: 0,
23
+ writes: 0,
24
+ cache_hits: 0,
25
+ cache_misses: 0
26
+ }
27
+ end
28
+
29
+ ##
30
+ # Save a task to memory with context indexing
31
+ #
32
+ # @param task [A2A::Types::Task] The task to save
33
+ # @return [void]
34
+ def save_task(task)
35
+ @mutex.synchronize do
36
+ @tasks[task.id] = task
37
+
38
+ # Update context index for faster lookups
39
+ @context_index[task.context_id] ||= []
40
+ @context_index[task.context_id] << task.id unless @context_index[task.context_id].include?(task.id)
41
+
42
+ @stats[:writes] += 1
43
+ end
44
+ end
45
+
46
+ ##
47
+ # Get a task by ID with performance tracking
48
+ #
49
+ # @param task_id [String] The task ID
50
+ # @return [A2A::Types::Task, nil] The task or nil if not found
51
+ def get_task(task_id)
52
+ @mutex.synchronize do
53
+ @stats[:reads] += 1
54
+ task = @tasks[task_id]
55
+ if task
56
+ @stats[:cache_hits] += 1
57
+ else
58
+ @stats[:cache_misses] += 1
59
+ end
60
+ task
61
+ end
62
+ end
63
+
64
+ ##
65
+ # Delete a task by ID
66
+ #
67
+ # @param task_id [String] The task ID
68
+ # @return [Boolean] True if task was deleted, false if not found
69
+ def delete_task(task_id)
70
+ @mutex.synchronize do
71
+ !@tasks.delete(task_id).nil?
72
+ end
73
+ end
74
+
75
+ ##
76
+ # List all tasks for a given context ID using optimized index
77
+ #
78
+ # @param context_id [String] The context ID
79
+ # @return [Array<A2A::Types::Task>] Tasks in the context
80
+ def list_tasks_by_context(context_id)
81
+ @mutex.synchronize do
82
+ @stats[:reads] += 1
83
+
84
+ # Use context index for faster lookups
85
+ task_ids = @context_index[context_id] || []
86
+ tasks = task_ids.filter_map { |id| @tasks[id] }
87
+
88
+ if tasks.any?
89
+ @stats[:cache_hits] += 1
90
+ else
91
+ @stats[:cache_misses] += 1
92
+ end
93
+
94
+ tasks
95
+ end
96
+ end
97
+
98
+ ##
99
+ # List all tasks
100
+ #
101
+ # @return [Array<A2A::Types::Task>] All tasks
102
+ def list_all_tasks
103
+ @mutex.synchronize do
104
+ @tasks.values.dup
105
+ end
106
+ end
107
+
108
+ ##
109
+ # List tasks with optional filtering
110
+ #
111
+ # @param filters [Hash] Optional filters (state, context_id, etc.)
112
+ # @return [Array<A2A::Types::Task>] Filtered tasks
113
+ def list_tasks(**filters)
114
+ @mutex.synchronize do
115
+ @stats[:reads] += 1
116
+ tasks = @tasks.values
117
+
118
+ tasks = apply_basic_filters(tasks, filters)
119
+ tasks = apply_metadata_filters(tasks, filters)
120
+
121
+ tasks.dup
122
+ end
123
+ end
124
+
125
+ ##
126
+ # Clear all tasks
127
+ #
128
+ # @return [void]
129
+ def clear_all_tasks
130
+ @mutex.synchronize do
131
+ @tasks.clear
132
+ end
133
+ end
134
+
135
+ ##
136
+ # Get the number of stored tasks
137
+ #
138
+ # @return [Integer] Number of tasks
139
+ def task_count
140
+ @mutex.synchronize do
141
+ @tasks.size
142
+ end
143
+ end
144
+
145
+ ##
146
+ # Save a push notification config
147
+ #
148
+ # @param config [A2A::Types::TaskPushNotificationConfig] The config to save
149
+ # @return [void]
150
+ def save_push_notification_config(config)
151
+ @mutex.synchronize do
152
+ @push_configs[config.task_id] ||= {}
153
+ @push_configs[config.task_id][config.push_notification_config.id] = config
154
+ end
155
+ end
156
+
157
+ ##
158
+ # Get a push notification config by task and config ID
159
+ #
160
+ # @param task_id [String] The task ID
161
+ # @param config_id [String] The config ID
162
+ # @return [A2A::Types::TaskPushNotificationConfig, nil] The config or nil if not found
163
+ def get_push_notification_config_by_id(task_id, config_id)
164
+ @mutex.synchronize do
165
+ @push_configs.dig(task_id, config_id)
166
+ end
167
+ end
168
+
169
+ ##
170
+ # List all push notification configs for a task
171
+ #
172
+ # @param task_id [String] The task ID
173
+ # @return [Array<A2A::Types::TaskPushNotificationConfig>] List of configs
174
+ def list_push_notification_configs(task_id)
175
+ @mutex.synchronize do
176
+ (@push_configs[task_id] || {}).values
177
+ end
178
+ end
179
+
180
+ ##
181
+ # Delete a push notification config
182
+ #
183
+ # @param task_id [String] The task ID
184
+ # @param config_id [String] The config ID
185
+ # @return [Boolean] True if deleted, false if not found
186
+ def delete_push_notification_config(task_id, config_id)
187
+ @mutex.synchronize do
188
+ task_configs = @push_configs[task_id]
189
+ return false unless task_configs
190
+
191
+ deleted = !task_configs.delete(config_id).nil?
192
+
193
+ # Clean up empty task entries
194
+ @push_configs.delete(task_id) if task_configs.empty?
195
+
196
+ deleted
197
+ end
198
+ end
199
+
200
+ ##
201
+ # Get storage performance statistics
202
+ #
203
+ # @return [Hash] Performance statistics
204
+ def performance_stats
205
+ @mutex.synchronize { @stats.dup }
206
+ end
207
+
208
+ ##
209
+ # Reset performance statistics
210
+ #
211
+ def reset_performance_stats!
212
+ @mutex.synchronize do
213
+ @stats = {
214
+ reads: 0,
215
+ writes: 0,
216
+ cache_hits: 0,
217
+ cache_misses: 0
218
+ }
219
+ end
220
+ end
221
+
222
+ ##
223
+ # Get cache hit ratio
224
+ #
225
+ # @return [Float] Cache hit ratio (0.0 to 1.0)
226
+ def cache_hit_ratio
227
+ @mutex.synchronize do
228
+ total_reads = @stats[:cache_hits] + @stats[:cache_misses]
229
+ return 0.0 if total_reads.zero?
230
+
231
+ @stats[:cache_hits].to_f / total_reads
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ def apply_basic_filters(tasks, filters)
238
+ tasks = tasks.select { |task| task.status&.state == filters[:state] } if filters[:state]
239
+ tasks = tasks.select { |task| task.context_id == filters[:context_id] } if filters[:context_id]
240
+ tasks
241
+ end
242
+
243
+ def apply_metadata_filters(tasks, filters)
244
+ filters.each do |key, value|
245
+ next if %i[state context_id].include?(key)
246
+
247
+ tasks = tasks.select { |task| apply_single_filter(task, key, value) }
248
+ end
249
+ tasks
250
+ end
251
+
252
+ def apply_single_filter(task, key, value)
253
+ case key
254
+ when :task_type
255
+ task.metadata&.dig(:type) == value || task.metadata&.dig("type") == value
256
+ when :created_after
257
+ created_at = get_created_at(task)
258
+ created_at && Time.parse(created_at) > value
259
+ when :created_before
260
+ created_at = get_created_at(task)
261
+ created_at && Time.parse(created_at) < value
262
+ else
263
+ # Generic metadata filter
264
+ task.metadata&.dig(key) == value || task.metadata&.dig(key.to_s) == value
265
+ end
266
+ end
267
+
268
+ def get_created_at(task)
269
+ task.metadata&.dig(:created_at) || task.metadata&.dig("created_at")
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end