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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- 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
|