ruby_llm-agents 0.3.4 → 0.3.5

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +9 -1
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  21. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  22. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
  23. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  24. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  25. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  26. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  27. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
  29. data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
  30. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  32. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  33. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  34. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  35. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  36. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  37. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  39. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  41. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  42. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  43. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  44. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  45. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  46. data/lib/ruby_llm/agents/base/caching.rb +4 -7
  47. data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
  48. data/lib/ruby_llm/agents/base/execution.rb +61 -9
  49. data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
  50. data/lib/ruby_llm/agents/base.rb +26 -0
  51. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  52. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  53. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  54. data/lib/ruby_llm/agents/configuration.rb +40 -1
  55. data/lib/ruby_llm/agents/engine.rb +65 -1
  56. data/lib/ruby_llm/agents/inflections.rb +14 -0
  57. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  58. data/lib/ruby_llm/agents/reliability.rb +8 -2
  59. data/lib/ruby_llm/agents/version.rb +1 -1
  60. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  61. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  62. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  63. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  64. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  65. data/lib/ruby_llm/agents/workflow.rb +232 -0
  66. data/lib/ruby_llm/agents.rb +1 -0
  67. metadata +50 -60
  68. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  69. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
  70. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  71. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  72. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  73. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  74. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  75. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  76. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  77. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  78. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
  79. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
  80. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
  81. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  82. /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
  83. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
  84. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -74,7 +74,11 @@ module RubyLLM
74
74
  # @param days [Integer, nil] Number of days to filter by
75
75
  # @return [ActiveRecord::Relation] Filtered scope
76
76
  def apply_time_filter(scope, days)
77
- days.present? && days.positive? ? scope.where("created_at >= ?", days.days.ago) : scope
77
+ return scope unless days.present? && days.positive?
78
+
79
+ # Qualify column name to avoid ambiguity when joins are present
80
+ table_name = scope.model.table_name
81
+ scope.where("#{table_name}.created_at >= ?", days.days.ago)
78
82
  end
79
83
  end
80
84
  end
@@ -33,7 +33,8 @@ module RubyLLM
33
33
  per_page = RubyLLM::Agents.configuration.per_page
34
34
  offset = (page - 1) * per_page
35
35
 
36
- scope = scope.order(created_at: :desc) if ordered
36
+ # Qualify column name to avoid ambiguity when joins are present
37
+ scope = scope.order("#{scope.model.table_name}.created_at DESC") if ordered
37
38
  total_count = scope.count
38
39
 
39
40
  {
@@ -20,14 +20,33 @@ module RubyLLM
20
20
  #
21
21
  # Uses AgentRegistry to discover agents from both file system
22
22
  # and execution history, ensuring deleted agents with history
23
- # are still visible.
23
+ # are still visible. Separates agents and workflows for tabbed display.
24
24
  #
25
25
  # @return [void]
26
26
  def index
27
- @agents = AgentRegistry.all_with_details
27
+ all_items = AgentRegistry.all_with_details
28
+
29
+ # Separate agents and workflows
30
+ @agents = all_items.reject { |a| a[:is_workflow] }
31
+ @workflows = all_items.select { |a| a[:is_workflow] }
32
+
33
+ # Group workflows by type for sub-tabs
34
+ @workflows_by_type = {
35
+ pipeline: @workflows.select { |w| w[:workflow_type] == "pipeline" },
36
+ parallel: @workflows.select { |w| w[:workflow_type] == "parallel" },
37
+ router: @workflows.select { |w| w[:workflow_type] == "router" }
38
+ }
39
+
40
+ # Counts for tab badges
41
+ @agent_count = @agents.size
42
+ @workflow_count = @workflows.size
28
43
  rescue StandardError => e
29
44
  Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}")
30
45
  @agents = []
46
+ @workflows = []
47
+ @workflows_by_type = { pipeline: [], parallel: [], router: [] }
48
+ @agent_count = 0
49
+ @workflow_count = 0
31
50
  flash.now[:alert] = "Error loading agents list"
32
51
  end
33
52
 
@@ -20,12 +20,13 @@ module RubyLLM
20
20
  def index
21
21
  @selected_range = params[:range].presence || "today"
22
22
  @days = range_to_days(@selected_range)
23
- @now_strip = Execution.now_strip_data(range: @selected_range)
24
- @critical_alerts = load_critical_alerts
25
- @hourly_activity = Execution.hourly_activity_chart
26
- @recent_executions = Execution.recent(10)
27
- @agent_stats = build_agent_comparison
28
- @top_errors = build_top_errors
23
+ base_scope = tenant_scoped_executions
24
+ @now_strip = base_scope.now_strip_data(range: @selected_range)
25
+ @critical_alerts = load_critical_alerts(base_scope)
26
+ @recent_executions = base_scope.recent(10)
27
+ @agent_stats = build_agent_comparison(base_scope)
28
+ @top_errors = build_top_errors(base_scope)
29
+ @tenant_budget = load_tenant_budget(base_scope)
29
30
  end
30
31
 
31
32
  # Returns chart data as JSON for live updates
@@ -34,7 +35,7 @@ module RubyLLM
34
35
  # @return [JSON] Chart data with series
35
36
  def chart_data
36
37
  range = params[:range].presence || "today"
37
- render json: Execution.activity_chart_json(range: range)
38
+ render json: tenant_scoped_executions.activity_chart_json(range: range)
38
39
  end
39
40
 
40
41
  private
@@ -54,33 +55,64 @@ module RubyLLM
54
55
 
55
56
  # Builds per-agent comparison statistics
56
57
  #
57
- # @return [Array<Hash>] Array of agent stats sorted by cost descending
58
- def build_agent_comparison
59
- scope = Execution.last_n_days(@days)
58
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
59
+ # @return [Array<Hash>] Array of all stats sorted by cost descending
60
+ def build_agent_comparison(base_scope = Execution)
61
+ scope = base_scope.last_n_days(@days)
60
62
  agent_types = scope.distinct.pluck(:agent_type)
61
63
 
62
- agent_types.map do |agent_type|
64
+ all_stats = agent_types.map do |agent_type|
63
65
  agent_scope = scope.where(agent_type: agent_type)
64
66
  count = agent_scope.count
65
67
  total_cost = agent_scope.sum(:total_cost) || 0
66
68
  successful = agent_scope.successful.count
67
69
 
70
+ # Detect if this is a workflow
71
+ agent_class = AgentRegistry.find(agent_type)
72
+ is_workflow = agent_class&.ancestors&.any? { |a| a.name&.include?("Workflow") }
73
+ workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil
74
+
68
75
  {
69
76
  agent_type: agent_type,
70
77
  executions: count,
71
78
  total_cost: total_cost,
72
79
  avg_cost: count > 0 ? (total_cost / count).round(6) : 0,
73
80
  avg_duration_ms: agent_scope.average(:duration_ms)&.round || 0,
74
- success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0
81
+ success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0,
82
+ is_workflow: is_workflow,
83
+ workflow_type: workflow_type
75
84
  }
76
85
  end.sort_by { |a| -(a[:total_cost] || 0) }
86
+
87
+ # Split into agents and workflows for tabbed display
88
+ @workflow_stats = all_stats.select { |a| a[:is_workflow] }
89
+ all_stats.reject { |a| a[:is_workflow] }
90
+ end
91
+
92
+ # Detects workflow type from class hierarchy
93
+ #
94
+ # @param agent_class [Class] The agent class
95
+ # @return [String, nil] "pipeline", "parallel", "router", or nil
96
+ def detect_workflow_type(agent_class)
97
+ return nil unless agent_class
98
+
99
+ ancestors = agent_class.ancestors.map { |a| a.name.to_s }
100
+
101
+ if ancestors.include?("RubyLLM::Agents::Workflow::Pipeline")
102
+ "pipeline"
103
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Parallel")
104
+ "parallel"
105
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Router")
106
+ "router"
107
+ end
77
108
  end
78
109
 
79
110
  # Builds top errors list
80
111
  #
112
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
81
113
  # @return [Array<Hash>] Top 5 error classes with counts
82
- def build_top_errors
83
- scope = Execution.last_n_days(@days).where(status: "error")
114
+ def build_top_errors(base_scope = Execution)
115
+ scope = base_scope.last_n_days(@days).where(status: "error")
84
116
  total_errors = scope.count
85
117
 
86
118
  scope.group(:error_class)
@@ -187,6 +219,37 @@ module RubyLLM
187
219
  []
188
220
  end
189
221
 
222
+ # Loads tenant budget info for the current tenant
223
+ #
224
+ # @param base_scope [ActiveRecord::Relation] Base scope for usage calculation
225
+ # @return [Hash, nil] Tenant budget data with usage info, or nil if not applicable
226
+ def load_tenant_budget(base_scope)
227
+ return nil unless tenant_filter_enabled? && current_tenant_id.present?
228
+ return nil unless TenantBudget.table_exists?
229
+
230
+ budget = TenantBudget.for_tenant(current_tenant_id)
231
+ return nil unless budget
232
+
233
+ # Calculate current usage
234
+ today_scope = base_scope.where("created_at >= ?", Time.current.beginning_of_day)
235
+ month_scope = base_scope.where("created_at >= ?", Time.current.beginning_of_month)
236
+
237
+ daily_spend = today_scope.sum(:total_cost) || 0
238
+ monthly_spend = month_scope.sum(:total_cost) || 0
239
+
240
+ {
241
+ tenant_id: current_tenant_id,
242
+ daily_limit: budget.effective_daily_limit,
243
+ monthly_limit: budget.effective_monthly_limit,
244
+ daily_spend: daily_spend,
245
+ monthly_spend: monthly_spend,
246
+ daily_percentage: budget.effective_daily_limit.to_f > 0 ? (daily_spend / budget.effective_daily_limit * 100).round(1) : 0,
247
+ monthly_percentage: budget.effective_monthly_limit.to_f > 0 ? (monthly_spend / budget.effective_monthly_limit * 100).round(1) : 0,
248
+ enforcement: budget.effective_enforcement,
249
+ per_agent_daily: budget.per_agent_daily || {}
250
+ }
251
+ end
252
+
190
253
  # Loads recent alerts from cache
191
254
  #
192
255
  # @return [Array<Hash>] Array of recent alert events
@@ -204,8 +267,9 @@ module RubyLLM
204
267
  # Combines open circuit breakers, budget breaches, and error spikes
205
268
  # into a single prioritized list (max 3 items).
206
269
  #
270
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
207
271
  # @return [Array<Hash>] Critical alerts with type and data
208
- def load_critical_alerts
272
+ def load_critical_alerts(base_scope = Execution)
209
273
  alerts = []
210
274
 
211
275
  # Open circuit breakers
@@ -241,7 +305,7 @@ module RubyLLM
241
305
  end
242
306
 
243
307
  # Error spike detection (>5 errors in last 15 minutes)
244
- error_count_15m = Execution.status_error.where("created_at > ?", 15.minutes.ago).count
308
+ error_count_15m = base_scope.status_error.where("created_at > ?", 15.minutes.ago).count
245
309
  if error_count_15m >= 5
246
310
  alerts << { type: :error_spike, data: { count: error_count_15m } }
247
311
  end
@@ -155,32 +155,53 @@ module RubyLLM
155
155
  # Loads available options for filter dropdowns
156
156
  #
157
157
  # Populates @agent_types with all agent types that have executions,
158
- # @model_ids with all distinct models used, and @statuses with all
159
- # possible status values.
158
+ # @model_ids with all distinct models used, @workflow_types with
159
+ # workflow patterns used, and @statuses with all possible status values.
160
160
  #
161
161
  # @return [void]
162
162
  def load_filter_options
163
163
  @agent_types = available_agent_types
164
164
  @model_ids = available_model_ids
165
+ @workflow_types = available_workflow_types
165
166
  @statuses = Execution.statuses.keys
166
167
  end
167
168
 
168
169
  # Returns distinct agent types from execution history
169
170
  #
170
171
  # Memoized to avoid duplicate queries within a request.
172
+ # Uses tenant_scoped_executions to respect multi-tenancy filtering.
171
173
  #
172
174
  # @return [Array<String>] Agent type names
173
175
  def available_agent_types
174
- @available_agent_types ||= Execution.distinct.pluck(:agent_type)
176
+ @available_agent_types ||= tenant_scoped_executions.distinct.pluck(:agent_type)
175
177
  end
176
178
 
177
179
  # Returns distinct model IDs from execution history
178
180
  #
179
181
  # Memoized to avoid duplicate queries within a request.
182
+ # Uses tenant_scoped_executions to respect multi-tenancy filtering.
180
183
  #
181
184
  # @return [Array<String>] Model IDs
182
185
  def available_model_ids
183
- @available_model_ids ||= Execution.where.not(model_id: nil).distinct.pluck(:model_id).sort
186
+ @available_model_ids ||= tenant_scoped_executions.where.not(model_id: nil).distinct.pluck(:model_id).sort
187
+ end
188
+
189
+ # Returns distinct workflow types from execution history
190
+ #
191
+ # Memoized to avoid duplicate queries within a request.
192
+ # Returns empty array if workflow_type column doesn't exist yet.
193
+ # Uses tenant_scoped_executions to respect multi-tenancy filtering.
194
+ #
195
+ # @return [Array<String>] Workflow types (pipeline, parallel, router)
196
+ def available_workflow_types
197
+ return @available_workflow_types if defined?(@available_workflow_types)
198
+
199
+ @available_workflow_types = if Execution.column_names.include?("workflow_type")
200
+ tenant_scoped_executions.where.not(workflow_type: [nil, ""])
201
+ .distinct.pluck(:workflow_type).sort
202
+ else
203
+ []
204
+ end
184
205
  end
185
206
 
186
207
  # Loads paginated executions and associated statistics
@@ -215,7 +236,7 @@ module RubyLLM
215
236
  #
216
237
  # @return [ActiveRecord::Relation] Filtered execution scope
217
238
  def filtered_executions
218
- scope = Execution.all
239
+ scope = tenant_scoped_executions
219
240
 
220
241
  # Apply search filter
221
242
  scope = scope.search(params[:q]) if params[:q].present?
@@ -244,8 +265,65 @@ module RubyLLM
244
265
  model_ids = parse_array_param(:model_ids)
245
266
  scope = scope.where(model_id: model_ids) if model_ids.any?
246
267
 
268
+ # Apply workflow type filter (only if column exists)
269
+ if Execution.column_names.include?("workflow_type")
270
+ workflow_types = parse_array_param(:workflow_types)
271
+ if workflow_types.any?
272
+ includes_single = workflow_types.include?("single")
273
+ other_types = workflow_types - ["single"]
274
+
275
+ if includes_single && other_types.any?
276
+ # Include both single (null workflow_type) and specific workflow types
277
+ scope = scope.where(workflow_type: [nil, ""] + other_types)
278
+ elsif includes_single
279
+ # Only single executions (non-workflow)
280
+ scope = scope.where(workflow_type: [nil, ""])
281
+ else
282
+ # Only specific workflow types
283
+ scope = scope.where(workflow_type: workflow_types)
284
+ end
285
+ end
286
+ end
287
+
288
+ # Apply execution type tab filter (agents vs workflows)
289
+ scope = apply_execution_type_filter(scope)
290
+
291
+ # Only show root executions (not workflow children) - children are nested under parents
292
+ scope = scope.where(parent_execution_id: nil)
293
+
294
+ # Eager load children for workflow grouping
295
+ scope = scope.includes(:child_executions)
296
+
247
297
  scope
248
298
  end
299
+
300
+ # Applies execution type tab filter (all, agents, workflows)
301
+ #
302
+ # @param scope [ActiveRecord::Relation] The current scope
303
+ # @return [ActiveRecord::Relation] Filtered scope
304
+ def apply_execution_type_filter(scope)
305
+ return scope unless Execution.column_names.include?("workflow_type")
306
+
307
+ execution_type = params[:execution_type]
308
+ case execution_type
309
+ when "agents"
310
+ # Only show executions where workflow_type is null/empty (regular agents)
311
+ scope.where(workflow_type: [nil, ""])
312
+ when "workflows"
313
+ # Only show executions with a workflow_type
314
+ workflow_scope = scope.where.not(workflow_type: [nil, ""])
315
+
316
+ # Apply workflow type sub-filter if specified
317
+ workflow_type_tab = params[:workflow_type_tab]
318
+ if workflow_type_tab.present? && %w[pipeline parallel router].include?(workflow_type_tab)
319
+ workflow_scope = workflow_scope.where(workflow_type: workflow_type_tab)
320
+ end
321
+
322
+ workflow_scope
323
+ else
324
+ scope
325
+ end
326
+ end
249
327
  end
250
328
  end
251
329
  end
@@ -190,20 +190,15 @@ module RubyLLM
190
190
 
191
191
  # Builds hourly chart data for last 24 hours
192
192
  # Optimized: Single GROUP BY query instead of 72 individual queries
193
+ # Database-agnostic: works with both PostgreSQL and SQLite
193
194
  def build_hourly_chart_data
194
195
  reference_time = Time.current.beginning_of_hour
195
196
  start_time = reference_time - 23.hours
196
197
 
197
- # Single query with GROUP BY - reduces 72 queries to 1
198
+ # Use database-agnostic aggregation with Ruby post-processing
198
199
  results = where(created_at: start_time..(reference_time + 1.hour))
199
- .group(Arel.sql("DATE_TRUNC('hour', created_at)"))
200
- .select(
201
- Arel.sql("DATE_TRUNC('hour', created_at) as time_bucket"),
202
- Arel.sql("COUNT(*) FILTER (WHERE status = 'success') as success_count"),
203
- Arel.sql("COUNT(*) FILTER (WHERE status IN ('error', 'timeout')) as failed_count"),
204
- Arel.sql("COALESCE(SUM(total_cost), 0) as total_cost")
205
- )
206
- .index_by { |r| r.time_bucket.to_time.beginning_of_hour }
200
+ .select(:status, :total_cost, :created_at)
201
+ .group_by { |r| r.created_at.beginning_of_hour }
207
202
 
208
203
  # Build arrays for all 24 hours (fill missing with zeros)
209
204
  success_data = []
@@ -215,11 +210,11 @@ module RubyLLM
215
210
 
216
211
  (23.downto(0)).each do |hours_ago|
217
212
  bucket_time = (reference_time - hours_ago.hours).beginning_of_hour
218
- row = results[bucket_time]
213
+ rows = results[bucket_time] || []
219
214
 
220
- s = row&.success_count.to_i
221
- f = row&.failed_count.to_i
222
- c = row&.total_cost.to_f
215
+ s = rows.count { |r| r.status == "success" }
216
+ f = rows.count { |r| r.status.in?(%w[error timeout]) }
217
+ c = rows.sum { |r| r.total_cost.to_f }
223
218
 
224
219
  success_data << s
225
220
  failed_data << f
@@ -242,21 +237,16 @@ module RubyLLM
242
237
  end
243
238
 
244
239
  # Builds daily chart data for specified number of days
245
- # Optimized: Single GROUP BY query instead of 3*days individual queries
240
+ # Optimized: Single query instead of 3*days individual queries
241
+ # Database-agnostic: works with both PostgreSQL and SQLite
246
242
  def build_daily_chart_data(days)
247
243
  end_date = Date.current
248
244
  start_date = (days - 1).days.ago.to_date
249
245
 
250
- # Single query with GROUP BY - reduces 3*days queries to 1
246
+ # Use database-agnostic aggregation with Ruby post-processing
251
247
  results = where(created_at: start_date.beginning_of_day..end_date.end_of_day)
252
- .group(Arel.sql("DATE_TRUNC('day', created_at)"))
253
- .select(
254
- Arel.sql("DATE_TRUNC('day', created_at) as time_bucket"),
255
- Arel.sql("COUNT(*) FILTER (WHERE status = 'success') as success_count"),
256
- Arel.sql("COUNT(*) FILTER (WHERE status IN ('error', 'timeout')) as failed_count"),
257
- Arel.sql("COALESCE(SUM(total_cost), 0) as total_cost")
258
- )
259
- .index_by { |r| r.time_bucket.to_date }
248
+ .select(:status, :total_cost, :created_at)
249
+ .group_by { |r| r.created_at.to_date }
260
250
 
261
251
  # Build arrays for all days (fill missing with zeros)
262
252
  success_data = []
@@ -268,11 +258,11 @@ module RubyLLM
268
258
 
269
259
  (days - 1).downto(0).each do |days_ago|
270
260
  date = days_ago.days.ago.to_date
271
- row = results[date]
261
+ rows = results[date] || []
272
262
 
273
- s = row&.success_count.to_i
274
- f = row&.failed_count.to_i
275
- c = row&.total_cost.to_f
263
+ s = rows.count { |r| r.status == "success" }
264
+ f = rows.count { |r| r.status.in?(%w[error timeout]) }
265
+ c = rows.sum { |r| r.total_cost.to_f }
276
266
 
277
267
  success_data << s
278
268
  failed_data << f
@@ -47,6 +47,31 @@ module RubyLLM
47
47
 
48
48
  # @!endgroup
49
49
 
50
+ # @!group Tenant Scopes
51
+
52
+ # @!method by_tenant(tenant_id)
53
+ # Filters to a specific tenant
54
+ # @param tenant_id [String] The tenant identifier
55
+ # @return [ActiveRecord::Relation]
56
+
57
+ # @!method for_current_tenant
58
+ # Filters to the current tenant from the resolver
59
+ # @return [ActiveRecord::Relation]
60
+ scope :by_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
61
+ scope :without_tenant, -> { where(tenant_id: nil) }
62
+ scope :with_tenant, -> { where.not(tenant_id: nil) }
63
+ scope :for_current_tenant, -> {
64
+ config = RubyLLM::Agents.configuration
65
+ if config.multi_tenancy_enabled?
66
+ tenant_id = config.tenant_resolver&.call
67
+ tenant_id ? where(tenant_id: tenant_id) : all
68
+ else
69
+ all
70
+ end
71
+ }
72
+
73
+ # @!endgroup
74
+
50
75
  # @!group Agent-based Scopes
51
76
 
52
77
  # @!method by_agent(agent_type)