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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ module RailsAiAdmin
9
+ # Compiles explicitly declared fields into typed inputs and canonical digests.
10
+ class InputSchema
11
+ Coercion = Data.define(:values, :errors) do
12
+ def valid? = errors.empty?
13
+ end
14
+
15
+ def initialize(fields, partial: false)
16
+ raise ConfigurationError, "Computed fields cannot be input fields" if fields.any?(&:virtual?)
17
+ @fields = fields.index_by { |field| field.name.to_s }
18
+ @partial = partial
19
+ end
20
+
21
+ def coerce(input, time_zone: "UTC")
22
+ return Coercion.new({}, { "base" => [ Field.error_message(:not_object, "must be an object") ] }) unless input.is_a?(Hash)
23
+ values, errors = {}, {}
24
+ input = input.stringify_keys
25
+ errors["base"] = [ Field.error_message(:undeclared_fields, "contains undeclared fields") ] if (input.keys - @fields.keys).any?
26
+ @fields.each do |name, field|
27
+ # An empty password box means "keep the current password".
28
+ input_value = input[name]
29
+ if !input.key?(name) || field.scalar_type == :password && input_value.is_a?(String) && input_value.match?(Field::BLANK)
30
+ errors[name] = [ Field.error_message(:required, "is required") ] if !@partial && field.options[:required]
31
+ next
32
+ end
33
+ begin
34
+ values[name] = field.coerce(input[name], time_zone: time_zone)
35
+ rescue ArgumentError => error
36
+ errors[name] = [ error.message ]
37
+ end
38
+ end
39
+ Coercion.new(values.freeze, errors.freeze)
40
+ end
41
+
42
+ def json_schema
43
+ required = @partial ? [] : @fields.values.select { |field| field.options[:required] }.map { |field| field.name.to_s }
44
+ { "type" => "object", "properties" => @fields.transform_values(&:json_schema), "required" => required, "additionalProperties" => false }
45
+ end
46
+
47
+ # Serializes semantic values independently of hash insertion order.
48
+ def self.digest(input)
49
+ Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
50
+ end
51
+
52
+ # Keyed so a stored digest is not an unsalted hash of a password. Hosts
53
+ # derive it from secret_key_base; without an application (plain library
54
+ # tests) it is random per process.
55
+ def self.secret_key
56
+ @secret_key ||= if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.respond_to?(:key_generator) && Rails.application.secret_key_base.present?
57
+ Rails.application.key_generator.generate_key("rails_ai_admin/input_digest", 32)
58
+ else
59
+ SecureRandom.bytes(32)
60
+ end
61
+ end
62
+
63
+ def self.canonical(value)
64
+ case value
65
+ when Hash then value.stringify_keys.sort.to_h.transform_values { |entry| canonical(entry) }
66
+ when Array then value.map { |entry| canonical(entry) }
67
+ when BigDecimal then { "$decimal" => value.to_s("F") }
68
+ when Time, ActiveSupport::TimeWithZone then { "$time" => value.utc.iso8601(6) }
69
+ when Date then { "$date" => value.iso8601 }
70
+ when Fields::FileValue, Fields::RejectedUpload then value.to_h.transform_keys(&:to_s)
71
+ when Field::Secret then { "$secret" => OpenSSL::HMAC.hexdigest("SHA256", secret_key, value) }
72
+ else value
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/digest/uuid"
4
+ require "ruby_llm"
5
+ require "rails_ai_admin/integrations/tool_views"
6
+
7
+ module RailsAiAdmin
8
+ module Integrations
9
+ # A RubyLLM tool that lets an assistant propose an opted-in member operation.
10
+ #
11
+ # The model chooses a resource, record, operation and input; all of it is
12
+ # untrusted. The server re-runs the normal preflight under the current admin
13
+ # Context (policy scope, action and input grants, typed coercion) and then
14
+ # presents the review Surface. The tool cannot confirm or execute anything:
15
+ # only the human's own Surface submission does, through the shared Runner.
16
+ #
17
+ # Each provider tool call maps to one deterministic intent and one Surface,
18
+ # so a replayed call (for example after a crash before its result was saved)
19
+ # never creates a second approval request.
20
+ class ProposeOperation < ::RubyLLM::Tool
21
+ NAMESPACE = "9b2f2f41-2a4e-4d7b-9bde-8f3f6a1d0c55"
22
+ CONVERSATION = /\Araa:conversation:[A-Za-z0-9_-]{1,100}\z/
23
+ RECORD_REF = /\A[A-Za-z0-9_-]{1,64}\z/
24
+
25
+ def self.tool_name = "raa_propose_operation"
26
+
27
+ description "Propose an admin operation on one record. Nothing changes until the signed-in user reviews and confirms it in the interactive review this presents. Never tell the user the operation is done."
28
+
29
+ attr_reader :catalog
30
+
31
+ # context: the authenticated admin Context; conversation: a server-owned
32
+ # "raa:conversation:<id>" reference. Neither may come from model output.
33
+ def initialize(context:, conversation:)
34
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
35
+ unless conversation.is_a?(String) && conversation.match?(CONVERSATION)
36
+ raise ConfigurationError, "conversation must be a raa:conversation reference"
37
+ end
38
+ raise ConfigurationError, "Assistant proposals require config.surfaces" unless RailsAiAdmin.configuration.surfaces
39
+ @context, @conversation = context, conversation.dup.freeze
40
+ @catalog = build_catalog.freeze
41
+ super()
42
+ end
43
+
44
+ def description
45
+ operations = @catalog.map do |entry|
46
+ fields = entry[:definition].handler_class.input.map do |field|
47
+ values = field.choice_values.any? ? " (#{field.choice_values.join('|')})" : ""
48
+ "#{field.name}: #{field.scalar_type}#{values}#{field.options[:required] ? ', required' : ''}"
49
+ end
50
+ "#{entry[:resource].key}.#{entry[:definition].key} — #{entry[:definition].description}; input #{fields.join('; ')}"
51
+ end
52
+ "#{self.class.description} Available: #{operations.join(' | ')}"
53
+ end
54
+
55
+ def parameters_schema
56
+ {
57
+ "type" => "object", "additionalProperties" => false, "required" => %w[resource record operation input],
58
+ "properties" => {
59
+ "resource" => { "type" => "string", "enum" => @catalog.map { |entry| entry[:resource].key.to_s }.uniq },
60
+ "record" => { "type" => "string", "maxLength" => 64, "description" => "The record id shown in the admin" },
61
+ "operation" => { "type" => "string", "enum" => @catalog.map { |entry| entry[:definition].key.to_s }.uniq },
62
+ "input" => { "type" => "object", "description" => "Operation input fields by name" }
63
+ }
64
+ }
65
+ end
66
+
67
+ def execute(resource:, record:, operation:, input:, tool_call: nil)
68
+ raise AccessDenied, "INVALID_TOOL_CALL" unless tool_call.is_a?(::RubyLLM::ToolCall) && tool_call.name == name && tool_call.id.is_a?(String) && tool_call.id.bytesize.between?(1, 150)
69
+ entry = @catalog.find { |item| item[:resource].key.to_s == resource.to_s && item[:definition].key.to_s == operation.to_s }
70
+ raise AccessDenied unless entry && record.is_a?(String) && record.match?(RECORD_REF) && input.is_a?(Hash)
71
+ resource, definition = entry[:resource], entry[:definition]
72
+ intent_id = Digest::UUID.uuid_v5(NAMESPACE, [ @context.actor_ref, @context.tenant_ref, @conversation, tool_call.id ].join("\n"))
73
+ # A replay of an already confirmed call reports it before re-checking the
74
+ # command grant, which a completed change may legitimately withdraw.
75
+ done = OperationRun.find_by(id: intent_id, actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref, status: "succeeded")
76
+ if done && done.origin == "assistant" && done.resource_key == resource.key.to_s && done.operation == definition.key.to_s
77
+ return { status: "reviewed", message: "The user already confirmed this proposal. Do not propose it again." }
78
+ end
79
+ access = Authorization::Access.new(@context, resource)
80
+ target = access.find!(record, action: definition.key)
81
+ values = proposal_values(resource, definition, target, input, intent_id)
82
+ return values if values.key?(:status)
83
+ surface = ToolViews.present(context: @context, resource: resource, definition: definition, record: target,
84
+ intent_ref: intent_id, input: ToolViews.wire_input(values),
85
+ assistant_call: { call_id: tool_call.id, conversation: @conversation })
86
+ { status: "presented", surface_id: surface.id,
87
+ message: "An interactive review was shown to the user. Nothing has changed; the user must confirm it." }
88
+ rescue AccessDenied => error
89
+ rejection(error.code)
90
+ rescue ::RailsToolViews::Error => error
91
+ rejection(error.code == "IDEMPOTENCY_CONFLICT" ? "IDEMPOTENCY_CONFLICT" : "NOT_FOUND")
92
+ end
93
+
94
+ private
95
+
96
+ # Opted-in operations on resources the actor may currently list.
97
+ def build_catalog
98
+ RailsAiAdmin.registry.resources.flat_map do |resource|
99
+ access = Authorization::Access.new(@context, resource)
100
+ begin
101
+ access.authorize!(:index)
102
+ rescue AccessDenied
103
+ next []
104
+ end
105
+ resource.assistant_operations.filter_map do |key|
106
+ definition = RailsAiAdmin.operation_registry.fetch(resource, key)
107
+ { resource: resource, definition: definition } if definition.custom? && definition.scope == :member
108
+ end
109
+ end
110
+ end
111
+
112
+ # Typed values of a new or replayed intent for this exact tool call.
113
+ def proposal_values(resource, definition, target, input, intent_id)
114
+ existing = OperationRun.find_by(id: intent_id, actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref)
115
+ unless existing
116
+ begin
117
+ proposal = Operations::Runner.new.preflight(context: @context, resource: resource, operation: definition.key,
118
+ target_id: target.id, expected_version: RecordVersion.of(target), input: input, origin: :assistant, intent_id: intent_id)
119
+ rescue ActiveRecord::RecordNotUnique
120
+ existing = OperationRun.find_by(id: intent_id, actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref)
121
+ end
122
+ if proposal.is_a?(Operations::Result)
123
+ declared = definition.handler_class.input.map { |field| field.name.to_s }
124
+ return { status: "rejected", code: proposal.code, fields: (proposal.errors.keys & declared).sort }
125
+ end
126
+ return proposal.values if proposal
127
+ end
128
+ raise AccessDenied unless existing && existing.origin == "assistant" && existing.resource_key == resource.key.to_s &&
129
+ existing.operation == definition.key.to_s && existing.target_id == target.id.to_s
130
+ raise AccessDenied, "IDEMPOTENCY_CONFLICT" unless existing.status == "proposed"
131
+ result = InputSchema.new(definition.fields(Authorization::Access.new(@context, resource), target)).coerce(input, time_zone: @context.time_zone)
132
+ raise AccessDenied, "IDEMPOTENCY_CONFLICT" unless result.valid? && InputSchema.digest(result.values) == existing.input_digest
133
+ result.values
134
+ end
135
+
136
+ MESSAGES = {
137
+ "NOT_FOUND" => "That record or operation is not available to this user.",
138
+ "VALIDATION_FAILED" => "The proposed input is invalid for this operation.",
139
+ "CONFLICT" => "The record changed; look it up again before proposing.",
140
+ "IDEMPOTENCY_CONFLICT" => "This tool call was already used for a different proposal.",
141
+ "INVALID_TOOL_CALL" => "This proposal must come from a tool call."
142
+ }.freeze
143
+
144
+ def rejection(code)
145
+ code = MESSAGES.key?(code) ? code : "NOT_FOUND"
146
+ { status: "rejected", code: code, message: MESSAGES.fetch(code) }
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAiAdmin
6
+ module Integrations
7
+ # Admin review Surfaces on the host-owned rails_tool_views runtime.
8
+ #
9
+ # Required only when `config.surfaces` is enabled, so conventional boot never
10
+ # loads rails_tool_views. The admin reads none of the host's global
11
+ # rails_tool_views settings (resolver, parent controller, adapter,
12
+ # auto_deliver) and changes none of them. Admin endpoints pass an explicit
13
+ # `raa:` Scope and bind the current admin Context for the duration of one
14
+ # call; policy and handler deny when no matching Context is bound, so another
15
+ # route (such as the host's product engine mount) can never act on them.
16
+ module ToolViews
17
+ KEY = "raa_operation_review"
18
+ VERSION = "1"
19
+ PARTIAL = "rails_ai_admin/surfaces/operation_review"
20
+ CONTEXT_KEY = :rails_ai_admin_surface_context
21
+ UUID = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
22
+ MAX_INPUT_BYTES = 16_384
23
+ # Props hold only a server intent reference and its canonical wire input,
24
+ # so the component digest never depends on reloadable host code.
25
+ SCHEMA = {
26
+ type: "object", additionalProperties: false, required: %w[intent input],
27
+ properties: { intent: { type: "string", pattern: UUID, maxLength: 36 }, input: { type: "string", maxLength: MAX_INPUT_BYTES } }
28
+ }.freeze
29
+ CONFIRM_SCHEMA = { type: "object", additionalProperties: false, properties: {} }.freeze
30
+ # Business outcomes are recorded on the Surface with their failure audit.
31
+ TERMINAL = {
32
+ "CONFLICT" => "The record changed or was busy. Start a new review from the record.",
33
+ "APPROVAL_EXPIRED" => "This review expired. Start a new review from the record.",
34
+ "APPROVAL_INVALIDATED" => "Access or the operation changed. Start a new review from the record.",
35
+ "APPROVAL_REQUIRED" => "This operation needs a new review.",
36
+ "VALIDATION_FAILED" => "The operation rejected this input. Start a new review from the record.",
37
+ "IDEMPOTENCY_CONFLICT" => "This review no longer matches the recorded operation."
38
+ }.freeze
39
+ Review = ::Data.define(:run, :resource, :definition, :access, :record)
40
+ ASSISTANT_ADAPTER = "raa_assistant"
41
+
42
+ class << self
43
+ def available?
44
+ defined?(::RailsToolViews::Registry) ? true : false
45
+ end
46
+
47
+ # Re-registers after a host `reset_registry!`. The contract is constant,
48
+ # so an existing registration must be exactly this one.
49
+ def ensure_registered!
50
+ raise ConfigurationError, "rails_tool_views is not loaded" unless available?
51
+ component = begin
52
+ ::RailsToolViews.registry.fetch(KEY)
53
+ rescue ::RailsToolViews::Error => error
54
+ raise unless error.code == "UNKNOWN_COMPONENT"
55
+ register
56
+ end
57
+ unless component.policy.equal?(POLICY) && component.actions.fetch("confirm").handler.equal?(CONFIRM) &&
58
+ component.actions["dismiss"]&.handler.equal?(DISMISS)
59
+ raise ConfigurationError, "#{KEY} is registered by other code"
60
+ end
61
+ component
62
+ end
63
+
64
+ def scope_for(context)
65
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
66
+ ::RailsToolViews::Scope.new(actor: context.actor_ref, tenant: context.tenant_ref)
67
+ end
68
+
69
+ # Binds the current admin Context for one admin endpoint call.
70
+ def with_context(context)
71
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
72
+ state = ActiveSupport::IsolatedExecutionState
73
+ previous = state[CONTEXT_KEY]
74
+ state[CONTEXT_KEY] = context
75
+ yield
76
+ ensure
77
+ state[CONTEXT_KEY] = previous if state
78
+ end
79
+
80
+ def current_context = ActiveSupport::IsolatedExecutionState[CONTEXT_KEY]
81
+
82
+ # Presents (or replays) the review Surface for an unconsumed intent. A
83
+ # native intent replays per intent; an assistant intent belongs to its
84
+ # chat and replays per persisted tool call.
85
+ def present(context:, resource:, definition:, record:, intent_ref:, input:, chat: nil, call_id: nil, assistant_call: nil)
86
+ ensure_registered!
87
+ if definition.custom?
88
+ raise AccessDenied unless resource.surface_operation?(definition.key) && definition.scope == :member
89
+ else
90
+ # A native create, update or delete is reviewed here only when the
91
+ # assistant proposed it and the resource allows that change.
92
+ raise AccessDenied unless (chat || assistant_call) && resource.assistant_change?(definition.key)
93
+ end
94
+ raise AccessDenied if chat && assistant_call
95
+ origin = chat || assistant_call ? "assistant" : "ui"
96
+ raise AccessDenied if assistant_call && definition.custom? && !resource.assistant_operation?(definition.key)
97
+ raise AccessDenied unless intent_ref.is_a?(String) && intent_ref.match?(/\A[0-9a-f-]{36}\z/)
98
+ raise AccessDenied if chat && !(call_id.is_a?(String) && chat.current_for?(context) && chat.actor_ref == context.actor_ref && chat.tenant_ref == context.tenant_ref)
99
+ run = OperationRun.find_by(id: intent_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
100
+ unless run && run.id == intent_ref && run.resource_key == resource.key.to_s && run.operation == definition.key.to_s &&
101
+ run.target_id == record.id&.to_s && run.status == "proposed" && run.origin == origin && run.parent_run_id.nil?
102
+ raise AccessDenied
103
+ end
104
+ raise AccessDenied, "APPROVAL_EXPIRED" unless run.expires_at && run.expires_at > Time.current
105
+ raise AccessDenied, "APPROVAL_INVALIDATED" unless run.security_version == context.security_version
106
+ wire = canonical_input(input)
107
+ raise AccessDenied, "APPROVAL_INVALIDATED" unless run.input_digest && run.input_digest == InputSchema.digest(input_values(context, resource, definition, record, wire))
108
+ reference, correlation = if chat
109
+ [ chat.context_reference, { adapter: ASSISTANT_ADAPTER, call_id: call_id, context: chat.context_reference } ]
110
+ elsif assistant_call
111
+ [ assistant_call.fetch(:conversation), { adapter: "raa_assistant", call_id: assistant_call.fetch(:call_id), context: assistant_call.fetch(:conversation) } ]
112
+ else
113
+ [ "raa:intent:#{run.id}", { adapter: "raa_native_review", call_id: run.id, context: "raa:intent:#{run.id}" } ]
114
+ end
115
+ with_context(context) do
116
+ ::RailsToolViews.present(KEY, props: { "intent" => run.id, "input" => wire }, scope: scope_for(context),
117
+ context: reference, correlation: correlation)
118
+ end
119
+ end
120
+
121
+ # The Surface an earlier attempt presented for this persisted tool call,
122
+ # so a retried tool does not prepare a second intent. Byte-exact match.
123
+ def assistant_surface(context, chat, call_id)
124
+ lookup = { adapter: ASSISTANT_ADAPTER, call_id: call_id, context_ref: chat.context_reference,
125
+ actor_ref: context.actor_ref, tenant_ref: context.tenant_ref }
126
+ prior = ::RailsToolViews::Presentation.find_by(lookup)
127
+ return unless prior && lookup.all? { |attribute, value| prior.public_send(attribute) == value }
128
+ with_context(context) { ::RailsToolViews.restore(prior.surface_id, scope: scope_for(context)) }
129
+ rescue ::RailsToolViews::Error
130
+ nil
131
+ end
132
+
133
+ # Canonical wire strings for typed operation values, as native forms send.
134
+ def wire_input(values)
135
+ values.to_h do |name, value|
136
+ wire = case value
137
+ when BigDecimal then value.to_s("F")
138
+ when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
139
+ when Date then value.iso8601
140
+ else value
141
+ end
142
+ [ name.to_s, wire ]
143
+ end
144
+ end
145
+
146
+ # Current authorized review for props, or nil. Never trusts the Surface.
147
+ def review(context, props)
148
+ return unless context.is_a?(Context) && props.is_a?(Hash) && props["intent"].is_a?(String)
149
+ run = OperationRun.find_by(id: props["intent"], actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
150
+ return unless run && run.id == props["intent"] && run.parent_run_id.nil? && run.selection_id.nil?
151
+ resource = RailsAiAdmin.registry.fetch(run.resource_key)
152
+ definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
153
+ if definition.custom?
154
+ return unless definition.scope == :member && resource.surface_operation?(definition.key)
155
+ else
156
+ return unless run.origin == "assistant" && resource.assistant_change?(definition.key)
157
+ end
158
+ access = Authorization::Access.new(context, resource)
159
+ Review.new(run, resource, definition, access, review_record(access, run))
160
+ rescue AccessDenied, ConfigurationError, ActiveRecord::RecordNotFound
161
+ nil
162
+ end
163
+
164
+ def authorize(access)
165
+ context = current_context
166
+ return false unless context.is_a?(Context) && access.actor == context.actor_ref && access.tenant == context.tenant_ref
167
+ review = review(context, access.props)
168
+ return false unless review
169
+ case access.operation
170
+ when :present then review.run.status == "proposed"
171
+ # A completed review is a receipt: like native replay it needs the
172
+ # independent receipt grant, not the (possibly lapsed) command grant.
173
+ when :read then review.run.status != "succeeded" || receipt?(review)
174
+ when :action then %w[confirm dismiss].include?(access.action)
175
+ else false
176
+ end
177
+ end
178
+
179
+ # The record a review is about: a new one for a proposed create, the
180
+ # created one afterwards, and none once a delete succeeded.
181
+ def review_record(access, run)
182
+ case run.operation
183
+ when "create" then run.status == "succeeded" ? access.find!(run.record_id) : access.relation.new
184
+ when "destroy" then run.status == "succeeded" ? nil : access.find!(run.target_id)
185
+ else access.find!(run.target_id)
186
+ end
187
+ end
188
+
189
+ # How a review names its record in the conversation and on the card.
190
+ def subject(review)
191
+ singular = review.resource.label.singularize
192
+ return "#{singular} ##{review.run.target_id}" if review.record.nil?
193
+ return "a new #{singular.downcase_first}" unless review.record.persisted?
194
+ review.access.record_label(review.record)
195
+ end
196
+
197
+ def receipt?(review)
198
+ # A native change's result is the record itself, behind its list grant.
199
+ return review.access.authorize!(:index) unless review.definition.custom?
200
+ review.access.authorize_receipt!(review.definition.key, review.record)
201
+ rescue AccessDenied
202
+ false
203
+ end
204
+
205
+ # The human's own submission of the Surface form is the confirmation.
206
+ def confirm(request)
207
+ context = current_context
208
+ review = review(context, request.props) if context && request.actor == context.actor_ref && request.tenant == context.tenant_ref
209
+ raise ::RailsToolViews::Error.new("NOT_ALLOWED", status: 403) unless review
210
+ run = review.run
211
+ result = Operations::Runner.new.execute(context: context, intent_ref: run.id, input: JSON.parse(request.props.fetch("input")),
212
+ human_confirmed: true, origin: :surface, binding: { resource_key: run.resource_key, operation: run.operation, target_id: run.target_id })
213
+ if result.success?
214
+ message = result.replayed ? "This operation was already completed. No second change was made." : "Operation completed."
215
+ applied = run.operation == "create" ? "The record was created with id #{result.record_id}." : "The change was applied."
216
+ continue_chat(context, request, review, result.replayed ? "It had already been applied; no second change was made." : applied)
217
+ ::RailsToolViews::Transition.new(state: { "outcome" => "succeeded", "message" => message }, agent: false)
218
+ elsif TERMINAL.key?(result.code)
219
+ continue_chat(context, request, review, "It was not applied (#{result.code}): #{TERMINAL.fetch(result.code)}")
220
+ ::RailsToolViews::Transition.new(state: { "outcome" => "rejected", "code" => result.code, "message" => TERMINAL.fetch(result.code) }, agent: false)
221
+ elsif result.code == "NOT_FOUND"
222
+ raise ::RailsToolViews::Error.new("NOT_ALLOWED", status: 403)
223
+ else
224
+ # Nothing committed; the Surface revision is unchanged and retry is safe.
225
+ raise ::RailsToolViews::Error.new("OPERATION_FAILED", status: 503, message: "The operation could not complete. Nothing was changed; try again.")
226
+ end
227
+ end
228
+
229
+ # Declining is a decision too (RAA-019): the unapproved intent expires
230
+ # at once, nothing else changes, and a chat hears about it once.
231
+ def dismiss(request)
232
+ context = current_context
233
+ review = review(context, request.props) if context && request.actor == context.actor_ref && request.tenant == context.tenant_ref
234
+ raise ::RailsToolViews::Error.new("NOT_ALLOWED", status: 403) unless review
235
+ expired = OperationRun.where(id: review.run.id, status: "proposed", approved_by_ref: nil)
236
+ .update_all(expires_at: Time.current, updated_at: Time.current)
237
+ raise ::RailsToolViews::Error.new("CONFLICT", status: 409, message: "This review was already decided.") unless expired == 1
238
+ continue_chat(context, request, review, "Nothing was changed.", decision: "dismissed")
239
+ ::RailsToolViews::Transition.new(state: { "outcome" => "dismissed", "message" => "Dismissed. Nothing was changed." }, agent: false)
240
+ end
241
+
242
+ # The visible before/after values the operation's pure preflight
243
+ # proposes, as the native confirmation page shows them. Empty once the
244
+ # review cannot be confirmed or the preview is no longer valid.
245
+ def proposed_changes(review, wire)
246
+ return [] unless review.run.status == "proposed"
247
+ return native_changes(review, wire) unless review.definition.custom?
248
+ context = review.access.context
249
+ preview = Operations::Runner.new.preview(context: context, resource: review.resource, operation: review.definition.key,
250
+ target_id: review.record.id, expected_version: review.run.expected_version, input: JSON.parse(wire), origin: :surface)
251
+ return [] unless preview.is_a?(Operations::Runner::Preview)
252
+ readable = review.access.fields(:read, review.record, view: :show).index_by { |field| field.name.to_s }
253
+ preview.changes.filter_map do |name, change|
254
+ field = readable[name]
255
+ next unless field
256
+ [ field, field.format(change["before"], time_zone: context.time_zone), field.format(change["after"], time_zone: context.time_zone) ]
257
+ end
258
+ rescue AccessDenied, ConfigurationError, JSON::ParserError
259
+ []
260
+ end
261
+
262
+ # Typed display values. While proposed, only the actor's current input
263
+ # grants; once committed, the recorded input through the operation's
264
+ # declared contract (as receipt replay does), behind the receipt grant.
265
+ def display_values(review, wire)
266
+ # Native changes show before and after instead (native_changes).
267
+ return [] unless review.definition.custom?
268
+ values = JSON.parse(wire)
269
+ fields = if review.run.status == "succeeded"
270
+ review.definition.handler_class.input
271
+ else
272
+ begin
273
+ review.definition.fields(review.access, review.record)
274
+ rescue AccessDenied
275
+ []
276
+ end
277
+ end
278
+ fields.filter_map do |field|
279
+ next unless values.key?(field.name.to_s)
280
+ text = begin
281
+ field.format(field.coerce(values[field.name.to_s], time_zone: review.access.context.time_zone), time_zone: review.access.context.time_zone)
282
+ rescue ArgumentError
283
+ "Unavailable"
284
+ end
285
+ [ field, text ]
286
+ end
287
+ rescue JSON::ParserError
288
+ []
289
+ end
290
+
291
+ # Before and after for a native create or update, through the actor's
292
+ # read grants; references show their labels. A field the actor may
293
+ # write but not read shows only that it will be set.
294
+ def native_changes(review, wire)
295
+ return [] if review.run.operation == "destroy"
296
+ context = review.access.context
297
+ values = input_values(context, review.resource, review.definition, review.record, wire)
298
+ persisted = review.record.persisted?
299
+ readable = review.access.fields(:read, persisted ? review.record : nil, view: :show).index_by { |field| field.name.to_s }
300
+ current = persisted ? review.access.project(review.record, view: :show) : {}
301
+ resolver = Fields::Association.new(context, review.resource)
302
+ fields = review.resource.fields.index_by { |field| field.name.to_s }
303
+ values.filter_map do |name, after|
304
+ field = fields[name]
305
+ next unless field
306
+ before = current[name]
307
+ next if persisted && before == after
308
+ show = ->(value) { readable.key?(name) ? native_text(field, value, resolver, context) : I18n.t("rails_ai_admin.surfaces.hidden_value") }
309
+ [ field, persisted ? show.call(before) : "—", show.call(after) ]
310
+ end
311
+ rescue AccessDenied, ConfigurationError, JSON::ParserError
312
+ []
313
+ end
314
+
315
+ def native_text(field, value, resolver, context)
316
+ return "—" if value.nil?
317
+ case field.scalar_type
318
+ when :belongs_to then resolver.reference(field, value.to_s).label.to_s
319
+ when :references then Array(value).map { |id| resolver.reference(field, id).label.to_s }.join(", ").presence || "—"
320
+ else field.format(value, time_zone: context.time_zone)
321
+ end
322
+ end
323
+
324
+ private
325
+
326
+ # Same transaction as the Surface revision; nothing is admitted when
327
+ # the confirmation rolls back.
328
+ def continue_chat(context, request, review, outcome, decision: "confirmed")
329
+ return unless RailsAiAdmin.configuration.assistant
330
+ require "rails_ai_admin/assistant"
331
+ Assistant::Continuation.admit!(context: context, context_ref: request.context_ref, review: review, outcome: outcome, decision: decision)
332
+ rescue AccessDenied
333
+ # A closed chat never blocks the human's committed business outcome.
334
+ nil
335
+ end
336
+
337
+ def register
338
+ ::RailsToolViews.register(KEY, description: "Admin operation review", version: VERSION, schema: SCHEMA, partial: PARTIAL,
339
+ policy: POLICY, actions: { confirm: { schema: CONFIRM_SCHEMA, policy: POLICY, handler: CONFIRM },
340
+ dismiss: { schema: CONFIRM_SCHEMA, policy: POLICY, handler: DISMISS } }, expose_to: [])
341
+ rescue ::RailsToolViews::Error => error
342
+ raise unless error.code == "DUPLICATE_COMPONENT"
343
+ ::RailsToolViews.registry.fetch(KEY)
344
+ end
345
+
346
+ def canonical_input(input)
347
+ raise AccessDenied, "INVALID_QUERY" unless input.is_a?(Hash)
348
+ stringified = input.to_h { |key, value| [ key.to_s, value ] }
349
+ wire = JSON.generate(stringified.sort.to_h)
350
+ raise AccessDenied, "INVALID_QUERY" if wire.bytesize > MAX_INPUT_BYTES
351
+ wire
352
+ end
353
+
354
+ def input_values(context, resource, definition, record, wire)
355
+ access = Authorization::Access.new(context, resource)
356
+ result = InputSchema.new(definition.fields(access, record), partial: definition.key == "update" && !definition.custom?).coerce(JSON.parse(wire), time_zone: context.time_zone)
357
+ raise AccessDenied, "APPROVAL_INVALIDATED" unless result.valid?
358
+ result.values
359
+ end
360
+ end
361
+
362
+ POLICY = ->(access) { ToolViews.authorize(access) }
363
+ CONFIRM = ->(request) { ToolViews.confirm(request) }
364
+ DISMISS = ->(request) { ToolViews.dismiss(request) }
365
+ end
366
+ end
367
+ end