rails_ai_admin 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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Declares host adapters as names so development reload resolves fresh classes.
5
+ class Configuration
6
+ INTERNAL_PATH = /\A\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]*\z/.freeze
7
+ # Gems an opt-in feature needs in the host's own Gemfile. The gemspec does
8
+ # not depend on them, so a conventional install pulls no AI code; boot
9
+ # enforces the supported range instead (RAA-026).
10
+ OPTIONAL_GEMS = {
11
+ "rails_tool_views" => { constant: "::RailsToolViews::VERSION", requirement: [ "~> 1.0.2" ].freeze }.freeze,
12
+ "ruby_llm" => { constant: "::RubyLLM::VERSION", requirement: [ ">= 2.2.0", "< 3" ].freeze }.freeze
13
+ }.freeze
14
+
15
+ # Nil when the loaded version of an optional gem is supported, otherwise a
16
+ # message naming the feature, the range and what is loaded.
17
+ def self.optional_gem_problem(name, feature, version: OPTIONAL_GEMS.fetch(name).fetch(:constant).safe_constantize)
18
+ requirement = Gem::Requirement.new(*OPTIONAL_GEMS.fetch(name).fetch(:requirement))
19
+ return if version && requirement.satisfied_by?(Gem::Version.new(version))
20
+ "#{feature} needs #{name} #{requirement} in the host Gemfile (#{version ? "#{version} is loaded" : 'it is not loaded'})"
21
+ end
22
+
23
+ attr_accessor :context_adapter, :authorization_adapter, :resources, :title, :intent_ttl,
24
+ :work_handlers, :outbox_auto_dispatch, :collection_auto_dispatch, :field_types, :authentication_path,
25
+ # Opt-in rails_tool_views review Surfaces. The host loads rails_tool_views
26
+ # and owns its global configuration; the admin never changes it.
27
+ :surfaces,
28
+ # Opt-in admin Chat. The host loads ruby_llm and names an adapter that
29
+ # builds a RubyLLM::Context per admin Context; global RubyLLM settings
30
+ # are never read for credentials or changed.
31
+ :assistant, :assistant_adapter, :assistant_model, :assistant_provider, :assistant_protocol,
32
+ # Messages one actor may send the assistant in any 24 hours; nil removes
33
+ # the limit. Each message can cost several provider calls.
34
+ :assistant_daily_message_limit,
35
+ # Stream replies into the transcript (true by default); turn off for a
36
+ # provider endpoint that cannot stream.
37
+ :assistant_streaming,
38
+ # :model shows the host model's validation messages for writable fields;
39
+ # :generic shows "is invalid" for each, for hosts whose messages may
40
+ # carry values an editor should not see (RAA-011).
41
+ :validation_messages,
42
+ # Optional short header badge such as "Staging" (RAA-024); none by default.
43
+ :environment_label,
44
+ # Active Job queue for collection, outbox and assistant jobs (RAA-020).
45
+ # Workers with explicit queue lists must include it.
46
+ :queue_name,
47
+ # Most rows one CSV export may contain (1..100,000). A larger list asks
48
+ # the person to narrow it first.
49
+ :export_limit,
50
+ # Most data rows one CSV import may contain (1..5,000). Each row is its
51
+ # own synchronous create.
52
+ :import_limit,
53
+ # Opt-in MCP server for outside agents; requires the assistant setup.
54
+ :mcp
55
+
56
+ def initialize
57
+ @authentication_path = nil
58
+ @context_adapter = nil
59
+ @authorization_adapter = "RailsAiAdmin::Authorization::Adapter"
60
+ @resources = []
61
+ @title = "Administration"
62
+ @intent_ttl = 15.minutes
63
+ @work_handlers = {}
64
+ @outbox_auto_dispatch = false
65
+ @collection_auto_dispatch = false
66
+ @field_types = {}
67
+ @surfaces = false
68
+ @queue_name = "rails_ai_admin"
69
+ @validation_messages = :model
70
+ @assistant = false
71
+ @assistant_adapter = nil
72
+ @assistant_model = nil
73
+ @assistant_provider = nil
74
+ @assistant_protocol = nil
75
+ @assistant_daily_message_limit = 100
76
+ @assistant_streaming = true
77
+ @export_limit = 10_000
78
+ @import_limit = 500
79
+ @mcp = false
80
+ end
81
+
82
+ # Validates and freezes the boot configuration without reading domain tables.
83
+ def finalize!
84
+ [ context_adapter, authorization_adapter ].compact.each do |name|
85
+ raise ConfigurationError, "Adapters must be constant name strings" unless name.is_a?(String) && name.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
86
+ end
87
+ unless resources.is_a?(Array) && resources.all? { |name| name.is_a?(String) }
88
+ raise ConfigurationError, "resources must contain constant name strings"
89
+ end
90
+ raise ConfigurationError, "intent_ttl must be between 60 and 3600 seconds" unless (60..3600).cover?(intent_ttl)
91
+ unless work_handlers.is_a?(Hash) && work_handlers.all? { |kind, name| kind.is_a?(String) && kind.match?(/\A[a-z][a-z0-9_.-]{0,99}\z/) && name.is_a?(String) && name.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) } && [ true, false ].include?(outbox_auto_dispatch)
92
+ raise ConfigurationError, "work_handlers needs stable string keys/class names and outbox_auto_dispatch must be boolean"
93
+ end
94
+ raise ConfigurationError, "collection_auto_dispatch must be boolean" unless [ true, false ].include?(collection_auto_dispatch)
95
+ raise ConfigurationError, "surfaces must be boolean" unless [ true, false ].include?(surfaces)
96
+ unless environment_label.nil? || environment_label.is_a?(String) && environment_label.valid_encoding? && environment_label.length.between?(1, 40) && !environment_label.match?(/[[:cntrl:]]/)
97
+ raise ConfigurationError, "environment_label must be nil or 1 to 40 characters of plain text"
98
+ end
99
+ environment_label&.freeze
100
+ raise ConfigurationError, "validation_messages must be :model or :generic" unless %i[model generic].include?(validation_messages)
101
+ raise ConfigurationError, "export_limit must be an Integer from 1 to 100000" unless export_limit.is_a?(Integer) && export_limit.between?(1, 100_000)
102
+ raise ConfigurationError, "import_limit must be an Integer from 1 to 5000" unless import_limit.is_a?(Integer) && import_limit.between?(1, 5_000)
103
+ unless (queue_name.is_a?(String) || queue_name.is_a?(Symbol)) && queue_name.to_s.match?(/\A[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}\z/)
104
+ raise ConfigurationError, "queue_name must be a short queue name"
105
+ end
106
+ self.queue_name = queue_name.to_s.freeze
107
+ if surfaces && !defined?(::RailsToolViews::Engine)
108
+ raise ConfigurationError, "surfaces requires the host to load rails_tool_views (add it to the Gemfile and run its installer)"
109
+ end
110
+ if surfaces && (problem = self.class.optional_gem_problem("rails_tool_views", "surfaces"))
111
+ raise ConfigurationError, problem
112
+ end
113
+ if surfaces && (problem = surfaces_parent_controller_problem)
114
+ raise ConfigurationError, problem
115
+ end
116
+ validate_assistant!
117
+ raise ConfigurationError, "mcp must be boolean" unless [ true, false ].include?(mcp)
118
+ raise ConfigurationError, "assistant_streaming must be boolean" unless [ true, false ].include?(assistant_streaming)
119
+ raise ConfigurationError, "mcp requires config.assistant (its tables, ruby_llm and resolve_job)" if mcp && !assistant
120
+ unless authentication_path.nil? || authentication_path.is_a?(String) && authentication_path.valid_encoding? && authentication_path.ascii_only? && authentication_path.match?(INTERNAL_PATH)
121
+ raise ConfigurationError, "authentication_path must be a fixed internal path without query or fragment"
122
+ end
123
+ authentication_path&.freeze
124
+ Fields::Registry.new(field_types).validate!
125
+ field_types.each { |key, name| key.freeze; name.freeze }
126
+ field_types.freeze
127
+ work_handlers.each { |kind, name| kind.freeze; name.freeze }
128
+ work_handlers.freeze
129
+ resources.each(&:freeze)
130
+ resources.freeze
131
+ title.freeze
132
+ context_adapter&.freeze
133
+ authorization_adapter.freeze
134
+ [ assistant_adapter, assistant_model, assistant_provider, assistant_protocol ].each { |value| value&.freeze }
135
+ freeze
136
+ end
137
+
138
+ # rails_tool_views' own controller inherits its parent_controller when the
139
+ # host eager loads; a missing "::ApplicationController" (an API-only or
140
+ # minimal host) would otherwise fail production boot with a bare NameError
141
+ # (RAA-009, DEP-010). Returns nil when the parent resolves.
142
+ def surfaces_parent_controller_problem
143
+ return unless defined?(::RailsToolViews) && ::RailsToolViews.respond_to?(:configuration)
144
+ name = ::RailsToolViews.configuration.parent_controller.to_s
145
+ parent = name.safe_constantize
146
+ return if parent.is_a?(Class) && parent <= ::ActionController::Base
147
+ "surfaces needs rails_tool_views' parent_controller (#{name.inspect}) to name an ActionController::Base subclass. " \
148
+ "Add that controller, or set config.parent_controller in your RailsToolViews.configure block."
149
+ end
150
+
151
+ private
152
+
153
+ PROVIDER_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
154
+
155
+ def validate_assistant!
156
+ raise ConfigurationError, "assistant must be boolean" unless [ true, false ].include?(assistant)
157
+ return unless assistant
158
+ unless defined?(::RubyLLM::Railtie)
159
+ raise ConfigurationError, "assistant requires the host to load ruby_llm (add it to the Gemfile)"
160
+ end
161
+ if (problem = self.class.optional_gem_problem("ruby_llm", "assistant"))
162
+ raise ConfigurationError, problem
163
+ end
164
+ unless assistant_adapter.is_a?(String) && assistant_adapter.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
165
+ raise ConfigurationError, "assistant_adapter must be a constant name string"
166
+ end
167
+ unless assistant_model.is_a?(String) && assistant_model.match?(/\A[A-Za-z0-9][A-Za-z0-9._:\/-]{0,99}\z/)
168
+ raise ConfigurationError, "assistant_model must name one model"
169
+ end
170
+ unless assistant_daily_message_limit.nil? || assistant_daily_message_limit.is_a?(Integer) && assistant_daily_message_limit.between?(1, 100_000)
171
+ raise ConfigurationError, "assistant_daily_message_limit must be nil or a whole number from 1 to 100000"
172
+ end
173
+ [ assistant_provider, assistant_protocol ].each do |value|
174
+ raise ConfigurationError, "assistant_provider and assistant_protocol must be short lowercase names" unless value.nil? || value.is_a?(String) && value.match?(PROVIDER_NAME)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Holds authority resolved by the host for one request or job attempt.
5
+ # No process-global Current state or provider credentials are retained.
6
+ class Context
7
+ attr_reader :actor, :tenant, :actor_ref, :tenant_ref, :security_version, :time_zone
8
+
9
+ def initialize(actor:, tenant:, security_version:, time_zone: "UTC")
10
+ @actor, @tenant = actor, tenant
11
+ @actor_ref = reference("actor", actor)
12
+ @tenant_ref = reference("tenant", tenant)
13
+ @security_version = security_version.to_s.dup.freeze
14
+ raise AccessDenied, "UNAUTHENTICATED" if @security_version.empty? || @security_version.bytesize > 150
15
+ raise ConfigurationError, "Unknown time zone" unless ActiveSupport::TimeZone[time_zone]
16
+ @time_zone = time_zone.dup.freeze
17
+ freeze
18
+ end
19
+
20
+ private
21
+
22
+ def reference(kind, object)
23
+ raise AccessDenied, "UNAUTHENTICATED" unless object && object.class.name.present? && object.respond_to?(:id) && object.id.present?
24
+ ref = "raa:#{kind}:#{object.class.name}:#{object.id}"
25
+ raise AccessDenied, "UNAUTHENTICATED" if ref.bytesize > 150 || ref.match?(/[\x00-\x1f\x7f]/)
26
+ ref.freeze
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_ai_admin/schema_suggestions"
4
+ require "rails_ai_admin/templates/inspector"
5
+
6
+ module RailsAiAdmin
7
+ # Reports support boundaries without probing credentials or calling providers.
8
+ class Doctor
9
+ Check = Data.define(:name, :status, :message)
10
+
11
+ def initialize(configuration: RailsAiAdmin.configuration, registry: RailsAiAdmin.registry, application: Rails.application,
12
+ operation_registry: RailsAiAdmin.operation_registry, work_registry: RailsAiAdmin.work_registry)
13
+ @configuration, @registry, @application = configuration, registry, application
14
+ @operation_registry, @work_registry = operation_registry, work_registry
15
+ end
16
+
17
+ # Admin Surfaces never change the host-owned rails_tool_views runtime; this
18
+ # reports whether the opt-in can work, not whether it is authorized.
19
+ def surface_check
20
+ surfaces = @configuration.respond_to?(:surfaces) && @configuration.surfaces
21
+ unless surfaces
22
+ return Check.new("surfaces", :closed, "Admin review Surfaces are off. The host owns any rails_tool_views runtime; the admin does not configure it.")
23
+ end
24
+ require "rails_ai_admin/integrations/tool_views"
25
+ return Check.new("surfaces", :error, "surfaces is enabled but rails_tool_views is not loaded.") unless Integrations::ToolViews.available?
26
+ if @configuration.respond_to?(:surfaces_parent_controller_problem) && (problem = @configuration.surfaces_parent_controller_problem)
27
+ return Check.new("surfaces", :error, problem)
28
+ end
29
+ unless ::RailsToolViews::Surface.table_exists? && ::RailsToolViews::ActionExecution.table_exists?
30
+ return Check.new("surfaces", :error, "Run the rails_tool_views installer migrations before enabling admin Surfaces.")
31
+ end
32
+ Integrations::ToolViews.ensure_registered!
33
+ operations = @registry.resources.sum { |resource| resource.operation_declarations.keys.count { |key| resource.surface_operation?(key) } }
34
+ assistant = @configuration.respond_to?(:assistant) && @configuration.assistant
35
+ Check.new("surfaces", :review, "#{Integrations::ToolViews::KEY} is registered for #{operations} member operations through admin-owned endpoints. " \
36
+ "#{assistant ? 'Assistant proposals present it and confirmed outcomes continue their chat once.' : 'Assistant presentation is off.'}")
37
+ rescue ConfigurationError => error
38
+ Check.new("surfaces", :error, error.message)
39
+ end
40
+
41
+ # Reports whether the opt-in MCP endpoint can authenticate tokens.
42
+ def mcp_check
43
+ return Check.new("mcp", :closed, "The MCP endpoint is off.") unless @configuration.respond_to?(:mcp) && @configuration.mcp
44
+ require "rails_ai_admin/mcp"
45
+ return Check.new("mcp", :error, "Run rails_ai_admin:assistant and db:migrate; the access token table is missing.") unless Mcp::AccessToken.table_exists?
46
+ active = Mcp::AccessToken.active.count
47
+ Check.new("mcp", :review, "MCP clients use personal access tokens (#{active} active). Each request resolves the owner through resolve_job " \
48
+ "and applies the assistant? grant and the owner's current grants; proposals wait for confirmation in the admin. Serve it over HTTPS only.")
49
+ end
50
+
51
+ # Reports whether opt-in admin Chat can run. It never calls the adapter or
52
+ # a provider, so it cannot prove credentials, model access or grants.
53
+ def assistant_check
54
+ unless @configuration.respond_to?(:assistant) && @configuration.assistant
55
+ return Check.new("assistant", :closed, "Admin Chat is off. No RubyLLM code, tables or provider calls are used.")
56
+ end
57
+ require "rails_ai_admin/assistant"
58
+ missing = [ Assistant::Chat, Assistant::Message, Assistant::Turn, ::RubyLLM::ActiveRecord::ToolCall, ::RubyLLM::ActiveRecord::Usage ]
59
+ .reject(&:table_exists?).map(&:table_name)
60
+ return Check.new("assistant", :error, "Run rails_ai_admin:assistant and db:migrate; missing tables: #{missing.join(', ')}.") if missing.any?
61
+ adapter = @configuration.assistant_adapter.safe_constantize
62
+ unless adapter.is_a?(Class) && adapter.method_defined?(:llm_context) && adapter.instance_method(:llm_context).parameters.include?([ :keyreq, :context ])
63
+ return Check.new("assistant", :error, "#{@configuration.assistant_adapter} must define llm_context(context:) returning a RubyLLM::Context or nil.")
64
+ end
65
+ job = @configuration.context_adapter&.safe_constantize
66
+ unless job.is_a?(Class) && job.method_defined?(:resolve_job)
67
+ return Check.new("assistant", :error, "Assistant turns run in jobs; the context adapter must define resolve_job.")
68
+ end
69
+ Check.new("assistant", :review, "Model #{@configuration.assistant_model}; tools apply each actor's current grants and the independent assistant? grant. " \
70
+ "Schedule rails_ai_admin:assistant:scan, keep provider credentials in the adapter's own RubyLLM::Context, and verify real-provider behavior separately.")
71
+ rescue NameError, ConfigurationError => error
72
+ Check.new("assistant", :error, "Assistant configuration failed (#{error.class.name}).")
73
+ end
74
+
75
+ def checks
76
+ config = @configuration
77
+ collections = @registry.resources.sum { |resource| @operation_registry.custom(resource).count { |definition| definition.scope == :collection } }
78
+ result = [
79
+ adapter_check("context", config.context_adapter, { resolve: [ 1, [] ] }),
80
+ adapter_check("policy", config.authorization_adapter, { scope: [ 0, %i[context resource relation] ], allowed?: [ 0, %i[context action resource record] ], fields: [ 0, %i[context resource action record] ] }),
81
+ autoload_check,
82
+ rails_check,
83
+ assistant_check,
84
+ mcp_check,
85
+ Check.new("files", :unsupported, "Private attachment management is not implemented."),
86
+ selection_check,
87
+ Check.new("queue", @work_registry.definitions.empty? && collections.zero? ? :closed : :review,
88
+ "#{@work_registry.definitions.size} outbox handlers; #{collections} collection handlers; auto_dispatch=#{config.outbox_auto_dispatch}/#{config.collection_auto_dispatch}; " \
89
+ "jobs use the #{queue_name(config).inspect} queue. Schedule the pending scans, make sure a worker processes that queue, and verify persistent queues/downstream idempotency."),
90
+ backlog_check,
91
+ surface_check
92
+ ]
93
+ result.concat(override_checks)
94
+ if @work_registry.definitions.any? || collections > 0
95
+ result << adapter_check("job_context", config.context_adapter, { resolve_job: [ 0, %i[actor_ref tenant_ref security_version] ] })
96
+ result << Check.new("work_delivery_policy", :review, "Delivery and receipt grants are independent of command preconditions; default denies. Verify fresh tenant/epoch, record-version policy and one effect per item in host tests.")
97
+ end
98
+ @registry.resources.each do |resource|
99
+ begin
100
+ if config.authorization_adapter == "RailsAiAdmin::Authorization::PunditAdapter"
101
+ configured = Authorization::PunditAdapter.new.configured?(resource)
102
+ policy_status = resource.policy.nil? ? :closed : (configured ? :review : :error)
103
+ result << Check.new("policy:#{resource.key}", policy_status,
104
+ "Explicit Pundit policy and policy-owned Scope required; field grants are independent. Mapping complete=#{configured}; authorization still needs host tests.")
105
+ end
106
+ model = resource.model_class
107
+ same_pool = model.connection_pool.equal?(OperationRun.connection_pool) && model.connection_pool.equal?(AuditEvent.connection_pool) && model.connection_pool.equal?(WorkItem.connection_pool)
108
+ version = RecordVersion.strategy(model)
109
+ writable = !resource.readonly?
110
+ status = writable && (!same_pool || version.nil? || model.composite_primary_key?) ? :error : :ok
111
+ status = :error if writable && !%w[SQLite PostgreSQL].include?(model.connection.adapter_name)
112
+ message = "same_pool=#{same_pool}; version=#{version || "missing"}; readonly=#{resource.readonly?}. Host callbacks must be database-only. " \
113
+ "List pages omit in-scope records that deny show; check scope/show agreement with TestKit.assert_scope_matches_show."
114
+ result << Check.new("resource:#{resource.key}", status, message)
115
+ result.concat(schema_checks(resource, model))
116
+ @operation_registry.custom(resource).each do |definition|
117
+ models = definition.handler_class.atomic_models.map(&:constantize)
118
+ valid = models.all? { |related| related.is_a?(Class) && related < ActiveRecord::Base && related.connection_pool.equal?(model.connection_pool) }
119
+ result << Check.new("operation:#{resource.key}:#{definition.key}", valid ? :review : :error,
120
+ "Scope=#{definition.scope}; declared atomic models share pool=#{valid}; handler/input metadata validated. Trusted Ruby is not a sandbox: test callbacks, preflight purity and external-effect boundaries. Duration checks are cooperative, not a hard SQL/Ruby interrupt. Collection inputs persist once: minimize payloads, never submit credentials, and review retention.")
121
+ end
122
+ rescue ActiveRecord::ActiveRecordError, NameError, ConfigurationError => error
123
+ result << Check.new("resource:#{resource.key}", :error, "Inspect model/migrations (#{error.class.name}); no record content was printed.")
124
+ end
125
+ end
126
+ result
127
+ end
128
+
129
+ private
130
+
131
+ BACKLOG_AGE = 15.minutes
132
+
133
+ def count_of(fields, noun) = "#{fields.size} #{fields.size == 1 ? noun : noun.pluralize}"
134
+ def named(fields) = fields.empty? ? "" : " (#{fields.first(10).map(&:name).join(', ')}#{fields.size > 10 ? ', …' : ''})"
135
+
136
+ def queue_name(config) = config.respond_to?(:queue_name) ? config.queue_name.to_s : "rails_ai_admin"
137
+
138
+ # The gemspec admits Rails 8.0.5.2 and up, the patched 8.0 release, but
139
+ # cannot also exclude 8.1 releases older than their patch, 8.1.4.1.
140
+ PATCHED_RAILS = { "8.0" => "8.0.5.2", "8.1" => "8.1.4.1" }.freeze
141
+
142
+ def rails_check(version: Rails.gem_version)
143
+ series = version.segments.first(2).join(".")
144
+ patched = PATCHED_RAILS[series]
145
+ return Check.new("rails", :ok, "Rails #{version} is at or above the patched #{series} release.") if patched && version >= Gem::Version.new(patched)
146
+ Check.new("rails", :error, "Rails #{version} is older than the patched #{series} release (#{patched || 'unknown series'}). " \
147
+ "Update Rails; known advisories affect this version.")
148
+ end
149
+
150
+ # Work that has been due for a while means no worker or scan is running it
151
+ # (RAA-020). Counts only; no record content or payload is read.
152
+ def backlog_check(now: Time.current)
153
+ cutoff = now - BACKLOG_AGE
154
+ due = ->(relation, table) { relation.where(table[:next_attempt_at].eq(nil).or(table[:next_attempt_at].lteq(now))).where(table[:created_at].lt(cutoff)) }
155
+ collection = due.call(BatchItem.where(status: "queued"), BatchItem.arel_table).count
156
+ outbox = due.call(WorkItem.where(status: "pending"), WorkItem.arel_table).count
157
+ turns = 0
158
+ if @configuration.respond_to?(:assistant) && @configuration.assistant
159
+ require "rails_ai_admin/assistant"
160
+ turns = due.call(Assistant::Turn.where(status: "queued"), Assistant::Turn.arel_table).count if Assistant::Turn.table_exists?
161
+ end
162
+ total = collection + outbox + turns
163
+ return Check.new("backlog", :ok, "No admin work has waited longer than #{BACKLOG_AGE.inspect}.") if total.zero?
164
+ Check.new("backlog", :review, "#{collection} collection items, #{outbox} outbox items and #{turns} assistant turns have waited longer than #{BACKLOG_AGE.inspect}. " \
165
+ "Check that a worker processes the #{queue_name(@configuration).inspect} queue and that the rails_ai_admin scans are scheduled.")
166
+ rescue ActiveRecord::ActiveRecordError, NameError
167
+ Check.new("backlog", :review, "Cannot inspect queued admin work. Check database connectivity and pending engine migrations.")
168
+ end
169
+
170
+ def override_checks
171
+ return [ Check.new("overrides", :review, "Run doctor inside the host to inspect standard app/views override metadata.") ] unless @application
172
+ findings = Templates::Inspector.new(root: @application.root).checks
173
+ return [ Check.new("overrides", :ok, "No standard-path admin template overrides found; custom view paths require host review.") ] if findings.empty?
174
+ findings.map do |finding|
175
+ Check.new("override:#{finding.key.gsub(/[^a-zA-Z0-9_\/.-]/, '_')[0, 200]}", finding.status,
176
+ "#{finding.code}: #{finding.path.inspect}; source version=#{finding.source_version || 'untracked'}. Source/context metadata only; review host edits for authority, CSRF and escaping. Custom view paths are not inspected.")
177
+ end
178
+ rescue ConfigurationError, SystemCallError
179
+ [ Check.new("overrides", :error, "Cannot inspect standard host override paths; no template was evaluated.") ]
180
+ end
181
+
182
+ def selection_check
183
+ installed = [ Selection, SelectionItem, BatchItem ].all?(&:table_exists?)
184
+ Check.new("selections", installed ? :review : :error,
185
+ "Fixed selection tables installed=#{installed}; at most 100 targets with lock_version or updated_at in one pool. Separate select_records? grant, current scope/show/fields and expiry apply. Selection alone is not approval; collection admission/leases require current host policies and persistent queue scans.")
186
+ rescue ActiveRecord::ActiveRecordError
187
+ Check.new("selections", :error, "Cannot inspect selection tables. Check database connectivity and pending engine migrations.")
188
+ end
189
+
190
+ def adapter_check(kind, name, methods)
191
+ if name.nil? || name == "RailsAiAdmin::Authorization::Adapter"
192
+ return Check.new(kind, :closed, "Default denies access. Implement and test the host context, scopes, actions and independent field grants.")
193
+ end
194
+ klass = name.safe_constantize
195
+ complete = klass.is_a?(Class) && (methods.keys - klass.public_instance_methods).empty? &&
196
+ klass.respond_to?(:new) && accepts_arguments?(klass.method(:new), 0, []) &&
197
+ accepts_arguments?(klass.instance_method(:initialize), 0, []) &&
198
+ methods.all? { |method, (positional, keywords)| accepts_arguments?(klass.instance_method(method), positional, keywords) }
199
+ Check.new(kind, complete ? :review : :error,
200
+ "Adapter public interface complete=#{complete}. Metadata does not prove anonymous denial or authorization; run host TestKit and HTTP tests.")
201
+ rescue NameError, LoadError
202
+ Check.new(kind, :error, "Adapter could not be loaded. Inspect its constant name and autoload path; no exception content was printed.")
203
+ end
204
+
205
+ def accepts_arguments?(method, positional, keywords)
206
+ parameters = method.parameters
207
+ keyword_parameters = parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
208
+ return false if keywords.any? && parameters.any? { |kind, _| kind == :nokey }
209
+ positional += 1 if keywords.any? && !keyword_parameters
210
+ required = parameters.count { |kind, _| kind == :req }
211
+ maximum = parameters.any? { |kind, _| kind == :rest } ? Float::INFINITY : required + parameters.count { |kind, _| kind == :opt }
212
+ return false unless (required..maximum).cover?(positional)
213
+ return false unless (parameters.filter_map { |kind, name| name if kind == :keyreq } - keywords).empty?
214
+ parameters.any? { |kind, _| kind == :keyrest } || !keyword_parameters ||
215
+ (keywords - parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }).empty?
216
+ end
217
+
218
+ def autoload_check
219
+ unless @application
220
+ return Check.new("autoload", :review, "Run doctor inside the host Rails application to check app/rails_ai_admin loading.")
221
+ end
222
+ path = @application.root.join("app/rails_ai_admin").to_s
223
+ registered = @application.autoloaders.main.dirs.include?(path)
224
+ conventional = "rails_ai_admin".camelize == "RailsAiAdmin"
225
+ Check.new("autoload", conventional ? :review : :error,
226
+ "Default app/rails_ai_admin is an autoload root=#{registered}; RailsAiAdmin inflection preserved=#{conventional}. If the directory exists but is ignored, restore loading or verify explicit host loading. Custom host_paths configuration is not implemented.")
227
+ end
228
+
229
+ def schema_checks(resource, model)
230
+ columns = model.columns_hash
231
+ Fields::DependencyGraph.validate!(resource.fields)
232
+ physical_fields = resource.fields.reject(&:virtual?)
233
+ invalid = physical_fields.reject do |field|
234
+ column = columns[field.column]
235
+ column && (SchemaSuggestions::TYPES.key?(column.type) || column.type == :uuid) && !SchemaSuggestions.array?(column) ||
236
+ (field.scalar_type == :id && field.name == :id && model.primary_key.is_a?(String) && columns.key?(model.primary_key)) ||
237
+ # has_secure_password defines password= over a digest column.
238
+ (field.scalar_type == :password && model.method_defined?("#{field.column}=")) ||
239
+ (field.scalar_type == :file && model.respond_to?(:reflect_on_attachment) && model.reflect_on_attachment(field.column)&.macro == :has_one_attached) ||
240
+ (field.scalar_type == :files && model.respond_to?(:reflect_on_attachment) && model.reflect_on_attachment(field.column)&.macro == :has_many_attached) ||
241
+ (field.scalar_type == :rich_text && model.reflect_on_association("rich_text_#{field.column}").present?) ||
242
+ (field.scalar_type == :references && model.reflect_on_association(field.name)&.collection?)
243
+ end
244
+ result = [ Check.new("schema:#{resource.key}", invalid.empty? ? :ok : :error,
245
+ "#{invalid.size} declared fields lack supported scalar columns. Check field names/types; computed fields use declared dependency graphs. No records were sampled.") ]
246
+ mismatches = physical_fields.count do |field|
247
+ column = columns[field.column]
248
+ next false unless column
249
+ case field.scalar_type
250
+ when :belongs_to then !%i[integer string uuid].include?(column.type)
251
+ when :datetime then !%i[datetime timestamp].include?(column.type)
252
+ when :date then column.type != :date
253
+ when :boolean then column.type != :boolean
254
+ when :integer then column.type != :integer || model.defined_enums.key?(field.name.to_s)
255
+ when :decimal then column.type != :decimal
256
+ when :time then column.type != :time
257
+ when :json then !%i[json jsonb].include?(column.type)
258
+ when :email, :url, :text, :textarea then !%i[string text].include?(column.type)
259
+ else false
260
+ end
261
+ end
262
+ result << Check.new("field_types:#{resource.key}", mismatches.zero? ? :ok : :review,
263
+ "#{mismatches} declarations need scalar type compatibility review. Validate coercion and presentation with host data; metadata alone cannot prove them.")
264
+ # Each line names the declarations it counts (RAA-026); names are schema
265
+ # metadata, never record content.
266
+ sensitive = resource.fields.select { |field| SchemaSuggestions.sensitive?(model, field.name) }
267
+ result << Check.new("sensitivity:#{resource.key}", sensitive.empty? ? :ok : :review,
268
+ "Declarations matching sensitive names or encrypted attributes: #{count_of(sensitive, 'field')}#{named(sensitive)}. Review explicit grants, including the assistant? grant; naming heuristics cannot establish safety.")
269
+ indexes = model.connection.indexes(model.table_name)
270
+ missing = resource.fields.select do |field|
271
+ field.options[:sortable] && field.column != model.primary_key && !indexes.any? { |index| index.columns.is_a?(Array) && index.columns.first == field.column }
272
+ end
273
+ result << Check.new("sort_indexes:#{resource.key}", missing.empty? ? :ok : :review,
274
+ "Sortable declarations without a leading-column index: #{count_of(missing, 'field')}#{named(missing)}. Review query plans and tenant-composite indexes; this hint is not a performance verdict.")
275
+ unindexed_filters = resource.fields.select do |field|
276
+ !field.hidden? && field.filter_operators.any? && field.column != model.primary_key && !indexes.any? { |index| index.columns.is_a?(Array) && index.columns.include?(field.column) }
277
+ end
278
+ result << Check.new("filter_indexes:#{resource.key}", unindexed_filters.empty? ? :ok : :review,
279
+ "Filterable declarations in no index: #{count_of(unindexed_filters, 'field')}#{named(unindexed_filters)}. Review query plans with the tenant scope; low-cardinality filters may not need one.")
280
+ searchable = resource.fields.select { |field| field.options[:searchable] && !field.hidden? && !field.virtual? }
281
+ if searchable.any?
282
+ modes = searchable.map { |field| "#{field.name} (#{field.options[:searchable] == true ? :prefix : field.options[:searchable]})" }.join(", ")
283
+ result << Check.new("search:#{resource.key}", :review,
284
+ "Search uses case-insensitive LIKE on SQLite and ILIKE on PostgreSQL over #{modes}. A plain B-tree index cannot serve ILIKE or contains search; " \
285
+ "on PostgreSQL use a pg_trgm GIN index, or lower(column) text_pattern_ops for prefix search. SQLite folds ASCII case only, so non-ASCII case differs by database.")
286
+ end
287
+ references = resource.fields.select { |field| Field::REFERENCE_TYPES.include?(field.scalar_type) }
288
+ if references.any?
289
+ failures = references.filter_map do |field|
290
+ Fields::Association.validate!(resource, field, registry: @registry)
291
+ nil
292
+ rescue ConfigurationError => error
293
+ error.message
294
+ end
295
+ result << Check.new("associations:#{resource.key}", failures.empty? ? :review : :error,
296
+ failures.empty? ? "#{references.size} belongs_to declarations match model reflections. A changed reference must be visible in its target resource; verify target policies and foreign-key constraints." : failures.join("; "))
297
+ end
298
+ result
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Mountable Rails engine. AI integrations remain unloaded during this slice.
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RailsAiAdmin
7
+
8
+ initializer "rails_ai_admin.request_logging" do |app|
9
+ app.middleware.insert_before Rails::Rack::Logger, RequestLogFilter
10
+ end
11
+
12
+ initializer "rails_ai_admin.assets" do |app|
13
+ if app.config.respond_to?(:assets)
14
+ app.config.assets.paths << root.join("app/assets/builds").to_s
15
+ if app.config.assets.respond_to?(:precompile)
16
+ app.config.assets.precompile += %w[rails_ai_admin.css rails_ai_admin.js]
17
+ end
18
+ end
19
+ end
20
+
21
+ config.to_prepare { RailsAiAdmin.prepare! }
22
+ config.after_initialize { RailsAiAdmin.configuration.finalize! }
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Identifies a configuration that cannot safely serve administrative requests.
5
+ class ConfigurationError < StandardError; end
6
+
7
+ # Carries a stable, non-sensitive failure code across the execution boundary.
8
+ class AccessDenied < StandardError
9
+ attr_reader :code
10
+
11
+ def initialize(code = "NOT_FOUND")
12
+ @code = code
13
+ super(code)
14
+ end
15
+ end
16
+ end