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,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
|