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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Upload a CSV file, check it, then create one record per row.
5
+ class ImportsController < ApplicationController
6
+ before_action :load_import
7
+
8
+ def new; end
9
+
10
+ # A header row naming every importable field; no data and no audit.
11
+ def template
12
+ send_data @import.template, filename: "#{@resource.key}-import.csv", type: "text/csv; charset=utf-8", disposition: "attachment"
13
+ end
14
+
15
+ def create
16
+ file = params[:file]
17
+ unless file.is_a?(ActionDispatch::Http::UploadedFile)
18
+ @problem = t("rails_ai_admin.imports.choose_file")
19
+ return render(:new, status: :unprocessable_content)
20
+ end
21
+ raise AccessDenied, "INVALID_QUERY" unless params[:mode].nil? || %w[check import].include?(params[:mode])
22
+ if file.size > Presentation::CsvImport::MAX_BYTES
23
+ @problem = t("rails_ai_admin.imports.too_large")
24
+ return render(:new, status: :unprocessable_content)
25
+ end
26
+ @report = @import.call(file.read, write: params[:mode] == "import")
27
+ render :new, status: @report.failed.any? ? :unprocessable_content : :ok
28
+ rescue Presentation::CsvImport::Invalid => error
29
+ @problem = error.message
30
+ render :new, status: :unprocessable_content
31
+ end
32
+
33
+ private
34
+
35
+ def load_import
36
+ @resource = RailsAiAdmin.registry.fetch(params[:resource])
37
+ Authorization::Access.new(@context, @resource).authorize_import!
38
+ @import = Presentation::CsvImport.new(@context, @resource)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # The MCP endpoint. It reads no cookie or session: a bearer token names the
5
+ # owner, and the host's resolve_job turns that into a fresh Context.
6
+ class McpController < ActionController::API
7
+ before_action :prepare_headers
8
+
9
+ def handle
10
+ return head(:forbidden) unless same_origin?
11
+ context, token = authenticate
12
+ return unauthorized unless context
13
+ require "rails_ai_admin/mcp"
14
+ raw = request.raw_post.to_s
15
+ return render(json: rpc_error(nil, -32600, "Request too large"), status: :payload_too_large) if raw.bytesize > Mcp::MAX_BODY
16
+ message = begin
17
+ JSON.parse(raw)
18
+ rescue JSON::ParserError
19
+ return render(json: rpc_error(nil, -32700, "Parse error"), status: :bad_request)
20
+ end
21
+ return render(json: rpc_error(nil, -32600, "Batches are not supported"), status: :bad_request) if message.is_a?(Array)
22
+ server = Mcp::Server.new(context: context, token: token,
23
+ review_url: ->(surface_id) { admin_surface_url(surface_id: surface_id) })
24
+ status, body = if RailsAiAdmin.configuration.surfaces
25
+ Integrations::ToolViews.with_context(context) { server.handle(message) }
26
+ else
27
+ server.handle(message)
28
+ end
29
+ body ? render(json: body, status: status) : head(status)
30
+ end
31
+
32
+ # No server-initiated stream and no session to end.
33
+ def unsupported = head(:method_not_allowed)
34
+
35
+ private
36
+
37
+ def prepare_headers
38
+ response.headers["Cache-Control"] = "no-store"
39
+ response.headers["X-Content-Type-Options"] = "nosniff"
40
+ end
41
+
42
+ # MCP requires rejecting cross-site browser requests (DNS rebinding).
43
+ def same_origin?
44
+ origin = request.headers["Origin"]
45
+ return true if origin.blank?
46
+ URI.parse(origin).then { |uri| uri.host == request.host && (uri.port || 80) == request.port }
47
+ rescue URI::InvalidURIError
48
+ false
49
+ end
50
+
51
+ def authenticate
52
+ raise AccessDenied unless RailsAiAdmin.configuration.mcp
53
+ require "rails_ai_admin/assistant"
54
+ require "rails_ai_admin/mcp"
55
+ require "rails_ai_admin/integrations/tool_views" if RailsAiAdmin.configuration.surfaces
56
+ header = request.headers["Authorization"].to_s
57
+ token = Mcp::AccessToken.authenticate(header.delete_prefix("Bearer ").strip) if header.start_with?("Bearer ")
58
+ return [ nil, nil ] unless token
59
+ adapter = RailsAiAdmin.configuration.context_adapter&.constantize&.new
60
+ return [ nil, nil ] unless adapter.respond_to?(:resolve_job)
61
+ context = ActiveRecord::Base.while_preventing_writes do
62
+ adapter.resolve_job(actor_ref: token.actor_ref, tenant_ref: token.tenant_ref, security_version: token.security_version)
63
+ end
64
+ unless context.is_a?(Context) && context.actor_ref == token.actor_ref && context.tenant_ref == token.tenant_ref &&
65
+ context.security_version == token.security_version
66
+ return [ nil, nil ]
67
+ end
68
+ token.update_columns(last_used_at: Time.current) if token.last_used_at.nil? || token.last_used_at < 1.minute.ago
69
+ [ context, token ]
70
+ rescue AccessDenied, ActiveRecord::ReadOnlyError
71
+ [ nil, nil ]
72
+ end
73
+
74
+ def unauthorized
75
+ response.headers["WWW-Authenticate"] = %(Bearer realm="rails_ai_admin")
76
+ render json: rpc_error(nil, -32001, "A valid admin access token is required"), status: :unauthorized
77
+ end
78
+
79
+ def rpc_error(id, code, message) = { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
80
+ end
81
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Personal access tokens for MCP clients. A new token is shown once, in the
5
+ # response that created it; the list shows names and dates only.
6
+ class McpTokensController < ApplicationController
7
+ before_action :require_mcp
8
+
9
+ def index
10
+ load_tokens
11
+ end
12
+
13
+ def create
14
+ @record, @token = Mcp::AccessToken.issue!(@context, name: params[:name], lifetime: params[:lifetime])
15
+ load_tokens
16
+ render :index, status: :created
17
+ rescue AccessDenied => error
18
+ raise unless error.code == "INVALID_QUERY"
19
+ @problem = t("rails_ai_admin.mcp.invalid")
20
+ load_tokens
21
+ render :index, status: :unprocessable_content
22
+ end
23
+
24
+ def revoke
25
+ token = Mcp::AccessToken.for_owner(@context).find_by(id: params[:id])
26
+ raise AccessDenied unless token
27
+ token.update!(revoked_at: Time.current) if token.revoked_at.nil?
28
+ redirect_to mcp_tokens_path, status: :see_other, notice: t("rails_ai_admin.mcp.revoked")
29
+ end
30
+
31
+ private
32
+
33
+ def require_mcp
34
+ raise AccessDenied unless RailsAiAdmin.configuration.mcp
35
+ require "rails_ai_admin/assistant"
36
+ require "rails_ai_admin/mcp"
37
+ raise AccessDenied unless Assistant.available?(@context)
38
+ end
39
+
40
+ def load_tokens
41
+ @tokens = Mcp::AccessToken.for_owner(@context).order(created_at: :desc).limit(50).to_a
42
+ @endpoint = mcp_url
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Human input -> pure preview -> input-bound confirmation -> shared Runner.
5
+ class OperationsController < ApplicationController
6
+ before_action :resolve_operation
7
+
8
+ def new
9
+ load_form
10
+ @form_values, @errors = {}, {}
11
+ render :form
12
+ end
13
+
14
+ def preflight
15
+ load_form
16
+ input = submitted_input
17
+ proposal = Operations::Runner.new.preflight(context: @context, resource: @resource, operation: @definition.key,
18
+ target_id: @record.id, expected_version: params[:expected_version], input: input)
19
+ if proposal.is_a?(Operations::Result)
20
+ render_failure(proposal, input)
21
+ else
22
+ @proposal = proposal
23
+ @form_values = proposal.values
24
+ @wire_input = JSON.generate(proposal.values.transform_values { |value| wire_value(value) })
25
+ if request.format.turbo_stream?
26
+ render turbo_stream: turbo_stream.replace(@flow_id, template: "rails_ai_admin/operations/confirmation", layout: false)
27
+ else
28
+ render :confirmation
29
+ end
30
+ end
31
+ end
32
+
33
+ def execute
34
+ input = confirmed_input
35
+ result = Operations::Runner.new.execute(context: @context, intent_ref: params[:intent_ref], input: input,
36
+ human_confirmed: true, binding: { resource_key: @resource.key, operation: @definition.key, target_id: params[:id] })
37
+ if result.success?
38
+ notice = result.work_refs.any? ? t("rails_ai_admin.operations.completed_with_work") : t("rails_ai_admin.operations.completed")
39
+ notice += " A host callback needs review." if result.warning
40
+ redirect_to resource_path(resource: @resource.key, id: result.record_id, **navigation_params), status: :see_other, notice: notice
41
+ elsif result.code == "NOT_FOUND"
42
+ raise AccessDenied
43
+ elsif result.code == "VALIDATION_FAILED"
44
+ load_form
45
+ render_failure(result, input)
46
+ else
47
+ @failure_code = result.code
48
+ render :failure, status: %w[OPERATION_FAILED TIMEOUT].include?(result.code) ? :service_unavailable : :conflict
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_operation
55
+ @resource = RailsAiAdmin.registry.fetch(params[:resource])
56
+ @definition = RailsAiAdmin.operation_registry.fetch(@resource, params[:operation])
57
+ raise AccessDenied unless @definition.custom? && @definition.scope == :member
58
+ @access = Authorization::Access.new(@context, @resource)
59
+ raise AccessDenied unless @access.canonical_id?(params[:id])
60
+ resolve_list_navigation
61
+ @flow_id = "raa_operation_#{@resource.key}_#{params[:id]}_#{@definition.key}"
62
+ end
63
+
64
+ def load_form
65
+ @record = @access.find!(params[:id], action: @definition.key)
66
+ @flow_id = "raa_operation_#{@resource.key}_#{@record.id}_#{@definition.key}"
67
+ @form_fields = @definition.fields(@access, @record)
68
+ end
69
+
70
+ def submitted_input
71
+ input = params[:input]
72
+ raise AccessDenied, "INVALID_QUERY" unless input.nil? || input.is_a?(ActionController::Parameters)
73
+ input ? input.to_unsafe_h : {}
74
+ end
75
+
76
+ def confirmed_input
77
+ value = params[:confirmed_input]
78
+ raise AccessDenied, "INVALID_QUERY" unless value.is_a?(String) && value.bytesize <= 16_384
79
+ JSON.parse(value)
80
+ rescue JSON::ParserError
81
+ raise AccessDenied, "INVALID_QUERY"
82
+ end
83
+
84
+ def wire_value(value)
85
+ case value
86
+ when BigDecimal then value.to_s("F")
87
+ when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
88
+ when Date then value.iso8601
89
+ else value
90
+ end
91
+ end
92
+
93
+ def render_failure(result, input)
94
+ names = @form_fields.map { |field| field.name.to_s }
95
+ @form_values = input.is_a?(Hash) ? input.stringify_keys.slice(*names).reject { |_, value| value.is_a?(Array) || value.is_a?(Hash) } : {}
96
+ @errors, @failure_code = result.errors, result.code
97
+ status = case result.code
98
+ when "VALIDATION_FAILED" then :unprocessable_content
99
+ when "CONFLICT" then :conflict
100
+ else :service_unavailable
101
+ end
102
+ render :form, status: status
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Picker search for reference inputs whose choices exceed the bounded
5
+ # select. :resource is the target resource; the reply holds only what that
6
+ # resource's own index would show this actor for the same term.
7
+ class ReferencesController < ApplicationController
8
+ def search
9
+ target = RailsAiAdmin.registry.fetch(params[:resource])
10
+ term = params[:q]
11
+ raise AccessDenied, "INVALID_QUERY" unless term.nil? || term.is_a?(String)
12
+ results = Fields::Association.search(@context, target, term)
13
+ render json: { results: results.map { |label, id| { id: id, label: label } } }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Conventional pages use the same operation runner as other trusted adapters.
5
+ class ResourcesController < ApplicationController
6
+ before_action :resolve_resource
7
+
8
+ def index
9
+ raw = params.to_unsafe_h.slice(*Navigation::ListState::KEYS)
10
+ @list_navigation = Navigation::ListState.new(@access, raw, strict: true)
11
+ if return_state_discarded?
12
+ redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.resources.index.settings_restored")
13
+ return
14
+ end
15
+ # A native filter form submits every input; share only the canonical
16
+ # nonblank conditions. This changes no grant and adds no notice.
17
+ if raw.key?("filter") && raw["filter"] != navigation_state["filter"]
18
+ redirect_to list_return_path, status: :see_other
19
+ return
20
+ end
21
+ @page = Queries::List.new(@context, @resource).call(search: navigation_state["q"], sort: navigation_state["sort"], direction: navigation_state.fetch("direction", "asc"), page: navigation_state.fetch("page", "1"), recover_empty_page: true, scope: navigation_state["scope"], filters: navigation_state["filter"])
22
+ if @page.number != navigation_state.fetch("page", "1").to_i
23
+ @list_navigation = Navigation::ListState.new(@access, navigation_state.merge("page" => @page.number.to_s))
24
+ redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.resources.index.page_empty")
25
+ return
26
+ end
27
+ @sortable_fields = @access.fields(:sort)
28
+ @list_scopes = @access.named_scopes
29
+ @filter_fields = @access.fields(:filter)
30
+ @current_scope = @page.scope
31
+ @columns = @resource.index.order(@access.fields(:read, view: :index))
32
+ computed = @columns.map(&:name) | [ @resource.title ]
33
+ # A record inside the policy scope whose own show check fails is left out
34
+ # of the page, like an unavailable reference, instead of failing the
35
+ # whole list (RAA-001). The notice says rows were left out, never which.
36
+ @rows = @page.records.filter_map do |record|
37
+ values = @access.project(record, view: :index, only: computed)
38
+ { id: record.id, label: record_label(@resource, record, values: values, view: :index), values: values,
39
+ editable: allowed_action?(@resource, :update, record) }
40
+ rescue AccessDenied
41
+ @omitted_rows = true
42
+ nil
43
+ end
44
+ # One target query per reference column instead of one per row.
45
+ @columns.select { |field| field.scalar_type == :belongs_to }.each do |field|
46
+ admin_associations.prime(field, @rows.filter_map { |row| row[:values][field.name.to_s] })
47
+ end
48
+ @searchable = @access.fields(:search).any?
49
+ @omitted_rows ||= false
50
+ @selectable = begin
51
+ @access.authorize_selection!
52
+ rescue AccessDenied
53
+ false
54
+ end
55
+ @importable = begin
56
+ @access.authorize_import!
57
+ rescue AccessDenied
58
+ false
59
+ end
60
+ @exportable = begin
61
+ @access.authorize_export!
62
+ rescue AccessDenied
63
+ false
64
+ end
65
+ end
66
+
67
+ def show
68
+ @record = @access.find!(params[:id])
69
+ @values = @access.project(@record, view: :show)
70
+ @fields = @access.fields(:read, @record, view: :show)
71
+ @association_panels = Presentation::AssociationPanel.build(@context, @resource, @record)
72
+ @operations = RailsAiAdmin.operation_registry.custom(@resource).select { |definition| definition.scope == :member }.select do |definition|
73
+ @access.authorize!(definition.key, @record)
74
+ definition.fields(@access, @record)
75
+ true
76
+ rescue AccessDenied
77
+ false
78
+ end
79
+ end
80
+
81
+ # Who changed this record, when and which fields, newest first.
82
+ def history
83
+ @record = @access.find!(params[:id])
84
+ before = params[:before]
85
+ raise AccessDenied, "INVALID_QUERY" unless before.nil? || before.is_a?(String)
86
+ @history = Presentation::History.new(@access, @record).page(before: before)
87
+ @actor_labels = actor_labels(@history.entries.map(&:actor_ref).uniq)
88
+ end
89
+
90
+ def new
91
+ prepare_form(:create)
92
+ end
93
+
94
+ def edit
95
+ prepare_form(:update)
96
+ end
97
+
98
+ def recover_form
99
+ response.headers["Vary"] = [ response.headers["Vary"], "Cookie", "X-RailsAiAdmin-Recovery-Intent", "X-RailsAiAdmin-Return-State" ].compact.join(", ")
100
+ @intent_ref = request.headers["X-RailsAiAdmin-Recovery-Intent"]
101
+ session = runner.resume_form(context: @context, resource: @resource, intent_ref: @intent_ref)
102
+ @intent, @record = session.intent, session.record
103
+ @operation = @intent.operation.to_sym
104
+ build_form_plan(@operation)
105
+ @errors = {}
106
+ render @operation == :create ? :new : :edit
107
+ response.headers["X-RailsAiAdmin-Form-Recovery"] = "ready"
108
+ end
109
+
110
+ def confirm_destroy
111
+ prepare_form(:destroy)
112
+ end
113
+
114
+ def create = submit(:create)
115
+ def update = submit(:update)
116
+ def destroy = submit(:destroy)
117
+
118
+ private
119
+
120
+ def resolve_resource
121
+ @resource = RailsAiAdmin.registry.fetch(params[:resource])
122
+ @access = Authorization::Access.new(@context, @resource)
123
+ raise AccessDenied if params.key?(:id) && !@access.canonical_id?(params[:id])
124
+ resolve_list_navigation unless action_name == "index"
125
+ end
126
+
127
+ def runner
128
+ @runner ||= Operations::Runner.new
129
+ end
130
+
131
+ def prepare_form(operation)
132
+ @operation = operation
133
+ @record = operation == :create ? @access.relation.new : @access.find!(params[:id], action: operation)
134
+ @access.authorize!(operation, @record)
135
+ build_form_plan(operation, operation == :create ? prefill_values : nil) unless operation == :destroy
136
+ @errors = {}
137
+ # GET stays read-only: the form carries a signed reference that becomes
138
+ # an intent on submit (RAA-005, ADR 0025).
139
+ @intent_ref = runner.issue_form_intent(context: @context, resource: @resource, operation: operation,
140
+ target_id: operation == :create ? nil : @record.id, expected_version: operation == :create ? nil : RecordVersion.of(@record)).ref
141
+ render operation == :destroy ? :confirm_destroy : operation == :create ? :new : :edit
142
+ end
143
+
144
+ # prefill[<belongs_to field>]=<key> starts a new record pointing at a
145
+ # parent, as the "Add" link of an association panel does. Only writable
146
+ # references to targets the actor can see are taken; anything else is
147
+ # ignored, and the person still submits the form.
148
+ def prefill_values
149
+ raw = params[:prefill]
150
+ return nil unless raw.is_a?(ActionController::Parameters)
151
+ raw = raw.to_unsafe_h
152
+ references = @access.fields(:write, @record, operation: :create).select { |field| field.scalar_type == :belongs_to }
153
+ resolver = Fields::Association.new(@context, @resource)
154
+ references.each_with_object({}) do |field, values|
155
+ value = raw[field.name.to_s]
156
+ next unless value.is_a?(String)
157
+ key = field.coerce(value)
158
+ values[field.name.to_s] = key if key && resolver.admissible?(field, key)
159
+ rescue ArgumentError
160
+ next
161
+ end
162
+ end
163
+
164
+ def submit(operation)
165
+ input = operation == :destroy ? {} : submitted_input
166
+ @intent_ref = params[:intent_ref]
167
+ intent_id = runner.materialize_form_intent(context: @context, reference: @intent_ref)
168
+ upload_files(operation, input) unless operation == :destroy
169
+ result = runner.execute(context: @context, intent_ref: intent_id, input: input,
170
+ human_confirmed: true, origin: :ui,
171
+ binding: { resource_key: @resource.key, operation: operation, target_id: params[:id] })
172
+ discard_uploads unless result.success?
173
+ if result.success?
174
+ target = operation == :destroy ? list_return_path : resource_path(resource: @resource.key, id: result.record_id, **navigation_params)
175
+ redirect_to target, status: :see_other, notice: result.warning ? t("rails_ai_admin.resources.saved_with_warning", operation: intent_id) : t("rails_ai_admin.resources.saved")
176
+ elsif result.code == "NOT_FOUND"
177
+ raise AccessDenied
178
+ else
179
+ @operation = operation
180
+ @record = operation == :create ? @access.relation.new : @access.find!(params[:id], action: operation)
181
+ @access.authorize!(operation, @record)
182
+ @intent = OperationRun.find_by!(id: intent_id, actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref)
183
+ build_form_plan(operation, input) unless operation == :destroy
184
+ names = operation == :destroy ? [] : @form_fields.map { |field| field.name.to_s }
185
+ @errors = result.errors.slice("base", *names)
186
+ @errors["base"] ||= [ t("rails_ai_admin.resources.form.inputs_unavailable") ] if (result.errors.keys - [ "base" ] - names).any?
187
+ @failure_code = result.code
188
+ # A restricted delete can succeed once the references are gone.
189
+ @needs_reload = !%w[VALIDATION_FAILED RESTRICTED].include?(result.code)
190
+ render operation == :destroy ? :confirm_destroy : operation == :create ? :new : :edit,
191
+ status: %w[VALIDATION_FAILED RESTRICTED].include?(result.code) ? :unprocessable_content : :conflict
192
+ end
193
+ rescue ActiveRecord::RecordNotFound
194
+ raise AccessDenied
195
+ end
196
+
197
+ # A chosen file becomes a new blob before the write, and only for a file
198
+ # field the actor may write now. Pages never pass blob ids: a text value in
199
+ # a file slot is refused. remove_files[] clears a file that is not required.
200
+ def upload_files(operation, input)
201
+ @uploaded_blobs = []
202
+ record = operation == :create ? @access.relation.new : @access.find!(params[:id], action: operation)
203
+ writable = @access.fields(:write, record, operation: operation)
204
+ upload_lists(writable.select { |field| field.scalar_type == :files }, input)
205
+ files = writable.select { |field| field.scalar_type == :file }.index_by { |field| field.name.to_s }
206
+ input.each_key do |name|
207
+ value = input[name]
208
+ next if writable.any? { |field| field.scalar_type == :files && field.name.to_s == name }
209
+ next unless files.key?(name) || value.is_a?(ActionDispatch::Http::UploadedFile)
210
+ field = files[name] || raise(AccessDenied, "INVALID_QUERY")
211
+ if value == ""
212
+ input.delete(name)
213
+ elsif !value.is_a?(ActionDispatch::Http::UploadedFile)
214
+ raise AccessDenied, "INVALID_QUERY"
215
+ elsif value.size > field.options[:max_size]
216
+ input[name] = Fields::RejectedUpload.new("too_large", field.options[:max_size])
217
+ else
218
+ input[name] = store_upload(value)
219
+ end
220
+ end
221
+ removals = params[:remove_files]
222
+ return if removals.nil?
223
+ raise AccessDenied, "INVALID_QUERY" unless removals.is_a?(Array) && removals.size <= files.size && removals.all? { |name| name.is_a?(String) && files.key?(name) }
224
+ removals.each { |name| input[name] = nil unless input.key?(name) }
225
+ end
226
+
227
+ def store_upload(value)
228
+ raise ConfigurationError, "file fields need Active Storage" unless defined?(::ActiveStorage::Blob)
229
+ blob = ::ActiveStorage::Blob.create_and_upload!(io: value, filename: value.original_filename.presence || "upload",
230
+ content_type: value.content_type, identify: true)
231
+ @uploaded_blobs << blob
232
+ blob.signed_id
233
+ end
234
+
235
+ # files fields: record[photos][] carries new uploads and
236
+ # remove_attachments[photos][] the attachment ids to remove.
237
+ def upload_lists(fields, input)
238
+ removals = params[:remove_attachments]
239
+ removals = removals.to_unsafe_h if removals.is_a?(ActionController::Parameters)
240
+ raise AccessDenied, "INVALID_QUERY" unless removals.nil? || removals.is_a?(Hash)
241
+ removals ||= {}
242
+ raise AccessDenied, "INVALID_QUERY" unless (removals.keys - fields.map { |field| field.name.to_s }).empty?
243
+ fields.each do |field|
244
+ name = field.name.to_s
245
+ uploads = input.key?(name) ? input[name] : []
246
+ remove = removals.fetch(name, [])
247
+ raise AccessDenied, "INVALID_QUERY" unless uploads.is_a?(Array) && remove.is_a?(Array) && remove.all? { |id| id.is_a?(String) }
248
+ uploads = uploads.reject { |value| value == "" }
249
+ raise AccessDenied, "INVALID_QUERY" unless uploads.all? { |value| value.is_a?(ActionDispatch::Http::UploadedFile) }
250
+ added = uploads.map { |value| value.size > field.options[:max_size] ? Fields::RejectedUpload.new("too_large", field.options[:max_size]) : store_upload(value) }
251
+ if added.empty? && remove.empty?
252
+ input.delete(name)
253
+ else
254
+ input[name] = { "add" => added, "remove" => remove }
255
+ end
256
+ end
257
+ end
258
+
259
+ # A refused write keeps no new upload; the person chooses the file again.
260
+ def discard_uploads
261
+ Array(@uploaded_blobs).each { |blob| blob.purge_later unless blob.attachments.exists? }
262
+ end
263
+
264
+ def submitted_input
265
+ value = params[:record]
266
+ raise AccessDenied, "INVALID_QUERY" unless value.nil? || value.is_a?(ActionController::Parameters)
267
+ value.is_a?(ActionController::Parameters) ? value.to_unsafe_h : {}
268
+ end
269
+
270
+ def build_form_plan(operation, input = nil)
271
+ view = operation == :create ? :create : :edit
272
+ @form_fields = @access.fields(:write, @record, operation: operation)
273
+ readable_values = begin
274
+ @access.project(@record, view: view)
275
+ rescue AccessDenied
276
+ {}
277
+ end
278
+ names = @form_fields.map { |field| field.name.to_s }
279
+ @form_values = readable_values.slice(*names)
280
+ if input.is_a?(Hash)
281
+ # A refused file is chosen again; the form keeps showing the stored one.
282
+ files = @form_fields.select { |field| Field::FILE_TYPES.include?(field.scalar_type) }.map { |field| field.name.to_s }
283
+ @form_values.merge!(input.stringify_keys.slice(*(names - files)).reject { |_, value| value.is_a?(Hash) || value.is_a?(Array) })
284
+ end
285
+ readonly_fields = @access.fields(:read, @record, view: view).select { |field| readable_values.key?(field.name.to_s) && !names.include?(field.name.to_s) }
286
+ readonly_values = readable_values.slice(*readonly_fields.map { |field| field.name.to_s })
287
+ @form_plan = Forms::Plan.new(fields: @form_fields, values: @form_values, readonly_fields: readonly_fields, readonly_values: readonly_values, order: @resource.fields)
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # One search box over every resource the actor may list and search. Each
5
+ # resource runs its own list query (policy relation, default list tab,
6
+ # granted search fields) and each row its own show grant.
7
+ class SearchController < ApplicationController
8
+ PER_RESOURCE = 5
9
+ # A bound on list queries for one search.
10
+ MAX_RESOURCES = 30
11
+ Section = Data.define(:resource, :rows, :more)
12
+ Row = Data.define(:id, :label)
13
+
14
+ def index
15
+ query = params[:q]
16
+ raise AccessDenied, "INVALID_QUERY" unless query.nil? || query.is_a?(String)
17
+ @query = query.to_s.strip
18
+ raise AccessDenied, "INVALID_QUERY" unless @query.valid_encoding? && @query.length <= 200
19
+ @sections = @query.empty? ? [] : searchable_resources.first(MAX_RESOURCES).filter_map { |resource| section(resource) }
20
+ end
21
+
22
+ private
23
+
24
+ def section(resource)
25
+ access = Authorization::Access.new(@context, resource)
26
+ page = Queries::List.new(@context, resource).call(search: @query, page_size: PER_RESOURCE)
27
+ rows = page.records.filter_map do |record|
28
+ access.authorize!(:show, record)
29
+ Row.new(record.id.to_s, access.record_label(record, view: :index))
30
+ rescue AccessDenied
31
+ nil
32
+ end
33
+ return if rows.empty?
34
+ Section.new(resource, rows, page.has_next)
35
+ rescue AccessDenied
36
+ nil
37
+ end
38
+ end
39
+ end