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,633 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
module Operations
|
|
7
|
+
# Executes registered database operations through one atomic boundary.
|
|
8
|
+
class Runner
|
|
9
|
+
ORIGINS = %w[ui surface assistant].freeze
|
|
10
|
+
Proposal = Data.define(:intent, :values, :changes)
|
|
11
|
+
Preview = Data.define(:target_id, :expected_version, :values, :changes)
|
|
12
|
+
|
|
13
|
+
def prepare(...)
|
|
14
|
+
result = preflight(...)
|
|
15
|
+
raise AccessDenied, result.code if result.is_a?(Result)
|
|
16
|
+
result.intent
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# intent_id lets a replay-safe adapter (an assistant tool call) derive one
|
|
20
|
+
# stable intent per call; it must be a UUID and creates no authority.
|
|
21
|
+
def preflight(context:, resource:, operation:, target_id: nil, expected_version: nil, input: nil, origin: :ui, intent_id: nil)
|
|
22
|
+
unless intent_id.nil? || intent_id.is_a?(String) && intent_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
|
|
23
|
+
raise ConfigurationError, "intent_id must be a lowercase UUID"
|
|
24
|
+
end
|
|
25
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
|
|
26
|
+
if definition.custom? && definition.scope == :collection
|
|
27
|
+
raise ConfigurationError, "Collection intents require fixed-manifest preflight and admission"
|
|
28
|
+
end
|
|
29
|
+
result = preview(context: context, resource: resource, operation: operation, target_id: target_id,
|
|
30
|
+
expected_version: expected_version, input: input, origin: origin)
|
|
31
|
+
return result if result.is_a?(Result)
|
|
32
|
+
intent = OperationRun.create!(id: intent_id || SecureRandom.uuid, actor_ref: context.actor_ref,
|
|
33
|
+
tenant_ref: context.tenant_ref, security_version: context.security_version,
|
|
34
|
+
resource_key: resource.key.to_s, operation: operation.to_s,
|
|
35
|
+
operation_version: operation_version(resource, operation), target_id: result.target_id,
|
|
36
|
+
expected_version: result.expected_version, input_digest: input && InputSchema.digest(result.values),
|
|
37
|
+
origin: origin.to_s, expires_at: Time.current + RailsAiAdmin.configuration.intent_ttl)
|
|
38
|
+
Proposal.new(intent, result.values, result.changes)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
FormSession = Data.define(:intent, :record)
|
|
42
|
+
FORM_RECOVERY_WINDOW = 1.hour
|
|
43
|
+
# id is the future OperationRun id; ref is what the form submits.
|
|
44
|
+
FormReference = Data.define(:id, :ref)
|
|
45
|
+
|
|
46
|
+
# Native form pages run the same checks as prepare but write nothing.
|
|
47
|
+
# The signed reference becomes an intent on first submit (ADR 0025).
|
|
48
|
+
def issue_form_intent(context:, resource:, operation:, target_id: nil, expected_version: nil)
|
|
49
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
|
|
50
|
+
raise ConfigurationError, "Form intents cover built-in create, update and destroy" if definition.custom?
|
|
51
|
+
result = preview(context: context, resource: resource, operation: operation, target_id: target_id,
|
|
52
|
+
expected_version: expected_version, origin: :ui)
|
|
53
|
+
raise AccessDenied, result.code if result.is_a?(Result)
|
|
54
|
+
now = Time.current
|
|
55
|
+
id = SecureRandom.uuid
|
|
56
|
+
ref = FormIntent.generate({ id: id, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
|
|
57
|
+
security_version: context.security_version, resource_key: resource.key.to_s, operation: operation.to_s,
|
|
58
|
+
operation_version: operation_version(resource, operation), target_id: result.target_id,
|
|
59
|
+
expected_version: result.expected_version, created_at: now.utc.iso8601(6),
|
|
60
|
+
expires_at: (now + RailsAiAdmin.configuration.intent_ttl).utc.iso8601(6) }, recovery_window: FORM_RECOVERY_WINDOW)
|
|
61
|
+
FormReference.new(id, ref)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Turns a submitted form reference into its OperationRun id, creating the
|
|
65
|
+
# proposed row from the signed payload the first time. A UUID passes
|
|
66
|
+
# through unchanged; anything unverifiable, or another owner's reference,
|
|
67
|
+
# returns nil. The row commits on its own so a rejected submit keeps the
|
|
68
|
+
# same intent for the next attempt, exactly as a GET-created one did.
|
|
69
|
+
def materialize_form_intent(context:, reference:)
|
|
70
|
+
return reference if FormIntent.uuid?(reference)
|
|
71
|
+
payload = FormIntent.verify(reference)
|
|
72
|
+
return unless payload && context.is_a?(Context) && payload["actor_ref"] == context.actor_ref && payload["tenant_ref"] == context.tenant_ref
|
|
73
|
+
unless OperationRun.exists?(id: payload["id"])
|
|
74
|
+
OperationRun.transaction(requires_new: true) { OperationRun.create!(FormIntent.run_attributes(payload)) }
|
|
75
|
+
end
|
|
76
|
+
payload["id"]
|
|
77
|
+
rescue ActiveRecord::RecordNotUnique
|
|
78
|
+
payload["id"]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reopens an unconsumed native form after fresh authentication. No input,
|
|
82
|
+
# business write, approval or replacement intent is admitted here.
|
|
83
|
+
def resume_form(context:, resource:, intent_ref:)
|
|
84
|
+
raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
|
|
85
|
+
unless FormIntent.uuid?(intent_ref)
|
|
86
|
+
# An unsubmitted form has no row yet; only its own recoverable kind
|
|
87
|
+
# is materialized, then the same checks as a stored intent apply.
|
|
88
|
+
payload = FormIntent.verify(intent_ref)
|
|
89
|
+
raise AccessDenied unless payload && payload["resource_key"] == resource.key.to_s && %w[create update].include?(payload["operation"])
|
|
90
|
+
intent_ref = materialize_form_intent(context: context, reference: intent_ref) || raise(AccessDenied)
|
|
91
|
+
end
|
|
92
|
+
raise AccessDenied unless intent_ref.is_a?(String) && intent_ref.match?(/\A[0-9a-f-]{36}\z/)
|
|
93
|
+
OperationRun.transaction(requires_new: true) do
|
|
94
|
+
run = OperationRun.lock.find_by(id: intent_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
|
|
95
|
+
unless run && run.resource_key == resource.key.to_s && %w[create update].include?(run.operation) &&
|
|
96
|
+
run.origin == "ui" && run.status == "proposed" && run.parent_run_id.nil? && run.selection_id.nil? &&
|
|
97
|
+
run.input_digest.nil? && run.confirmed_at.nil? && run.approved_by_ref.nil? && run.approved_digest.nil?
|
|
98
|
+
raise AccessDenied
|
|
99
|
+
end
|
|
100
|
+
raise AccessDenied, "APPROVAL_EXPIRED" unless run.created_at && run.created_at > Time.current - FORM_RECOVERY_WINDOW
|
|
101
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
|
|
102
|
+
raise AccessDenied unless !definition.custom? && run.operation_version == operation_version(resource, run.operation)
|
|
103
|
+
ensure_connection!(resource, definition)
|
|
104
|
+
access = Authorization::Access.new(context, resource)
|
|
105
|
+
record = load_record(access, run, definition)
|
|
106
|
+
access.authorize!(run.operation, record)
|
|
107
|
+
if run.operation == "update" && run.expected_version != RecordVersion.of(record)
|
|
108
|
+
raise AccessDenied, "CONFLICT"
|
|
109
|
+
end
|
|
110
|
+
raise AccessDenied unless run.operation != "create" || run.target_id.nil? && run.expected_version.nil?
|
|
111
|
+
definition.fields(access, record)
|
|
112
|
+
run.update!(security_version: context.security_version,
|
|
113
|
+
expires_at: [ Time.current + RailsAiAdmin.configuration.intent_ttl, run.created_at + FORM_RECOVERY_WINDOW ].min)
|
|
114
|
+
FormSession.new(run, record)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Computes a safe typed preview without creating an intent or saving domain
|
|
119
|
+
# data. Collection admission and native member preparation share this path.
|
|
120
|
+
def preview(context:, resource:, operation:, target_id: nil, expected_version: nil, input: nil, origin: :ui)
|
|
121
|
+
operation = operation.to_s
|
|
122
|
+
check_origin!(origin)
|
|
123
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
|
|
124
|
+
access = Authorization::Access.new(context, resource)
|
|
125
|
+
ensure_connection!(resource, definition)
|
|
126
|
+
read_proposal = lambda do
|
|
127
|
+
record = operation == "create" ? access.relation.new : access.find!(target_id, action: operation)
|
|
128
|
+
access.authorize!(operation, record)
|
|
129
|
+
version = operation == "create" ? nil : parse_version(expected_version)
|
|
130
|
+
raise AccessDenied, "CONFLICT" if version && version != RecordVersion.of(record)
|
|
131
|
+
raise ConfigurationError, "Create must not have a target" if operation == "create" && target_id
|
|
132
|
+
raise ConfigurationError, "Custom operations require input-bound preflight" if definition.custom? && input.nil?
|
|
133
|
+
values = input.nil? ? nil : coerce!(access, record, definition, input)
|
|
134
|
+
changes = definition.custom? ? preview_changes(access, record, definition, values) : {}
|
|
135
|
+
[ record, version, values, changes ]
|
|
136
|
+
end
|
|
137
|
+
record, version, values, changes = definition.custom? ? ActiveRecord::Base.while_preventing_writes(&read_proposal) : read_proposal.call
|
|
138
|
+
Preview.new(operation == "create" ? nil : record.id.to_s, version, values || {}, changes)
|
|
139
|
+
rescue Rejection => error
|
|
140
|
+
error.result
|
|
141
|
+
rescue ExecutionContext::DeadlineExceeded
|
|
142
|
+
Result.new(code: "TIMEOUT")
|
|
143
|
+
rescue StandardError => error
|
|
144
|
+
raise if !definition&.custom? || error.is_a?(ConfigurationError) || error.is_a?(AccessDenied)
|
|
145
|
+
Result.new(code: "OPERATION_FAILED")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Reauthorizes and consumes a human event atomically with the domain write.
|
|
149
|
+
# human_confirmed comes only from a trusted HTTP/Surface adapter, never from
|
|
150
|
+
# an assistant tool parameter. Origin is audit metadata, not authority.
|
|
151
|
+
def execute(context:, intent_ref:, input:, human_confirmed: false, origin: :ui, binding: nil)
|
|
152
|
+
execute_intent(context: context, intent_ref: intent_ref, input: input, human_confirmed: human_confirmed, origin: origin, binding: binding)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Internal worker entry: its authority is the admitted parent and lease,
|
|
156
|
+
# never another synthetic human confirmation. No HTTP adapter calls it.
|
|
157
|
+
def execute_admitted(claim:)
|
|
158
|
+
raise ConfigurationError, "Expected a collection lease claim" unless claim.is_a?(Collections::Claim)
|
|
159
|
+
Collections::Contract.ensure_outside_transaction!
|
|
160
|
+
execute_intent(context: claim.context, intent_ref: claim.operation_run_id,
|
|
161
|
+
input: claim.payload, origin: claim.origin, admission: claim)
|
|
162
|
+
rescue AccessDenied => error
|
|
163
|
+
Result.new(code: error.code)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def execute_intent(context:, intent_ref:, input:, human_confirmed: false, origin: :ui, binding: nil, admission: nil)
|
|
167
|
+
run = access = definition = nil
|
|
168
|
+
transaction_body_finished = false
|
|
169
|
+
raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
|
|
170
|
+
reject!("NOT_FOUND") unless intent_ref.is_a?(String) && intent_ref.match?(/\A[0-9a-f-]{36}\z/)
|
|
171
|
+
result = OperationRun.transaction(requires_new: true) do
|
|
172
|
+
admission&.lock!(context)
|
|
173
|
+
run = OperationRun.lock.find_by(id: intent_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
|
|
174
|
+
reject!("NOT_FOUND") unless run
|
|
175
|
+
reject!("NOT_FOUND") if run.parent_run_id && !admission
|
|
176
|
+
check_origin!(origin)
|
|
177
|
+
reject!("APPROVAL_INVALIDATED") unless run.security_version == context.security_version
|
|
178
|
+
reject!("APPROVAL_EXPIRED") unless run.expires_at && run.expires_at > Time.current
|
|
179
|
+
check_binding!(run, binding) if binding
|
|
180
|
+
resource = RailsAiAdmin.registry.fetch(run.resource_key)
|
|
181
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
|
|
182
|
+
ensure_connection!(resource, definition)
|
|
183
|
+
reject!("NOT_FOUND") if definition.custom? && definition.scope == :collection && !admission
|
|
184
|
+
admission&.validate_child!(run, resource, definition)
|
|
185
|
+
reject!("APPROVAL_INVALIDATED") unless run.operation_version == operation_version(resource, run.operation)
|
|
186
|
+
access = Authorization::Access.new(context, resource)
|
|
187
|
+
record = load_record(access, run, definition)
|
|
188
|
+
if definition.custom? && run.status == "succeeded"
|
|
189
|
+
access.authorize_receipt!(run.operation, record)
|
|
190
|
+
else
|
|
191
|
+
access.authorize!(run.operation, record)
|
|
192
|
+
end
|
|
193
|
+
values = coerce!(access, record, definition, input, replay: run.status == "succeeded")
|
|
194
|
+
if run.operation == "create"
|
|
195
|
+
record.assign_attributes(column_attributes(resource, values))
|
|
196
|
+
access.authorize!(:create, record)
|
|
197
|
+
writable = access.fields(:write, record, operation: :create).map { |field| field.name.to_s }
|
|
198
|
+
reject!("NOT_FOUND") unless (values.keys - writable).empty?
|
|
199
|
+
end
|
|
200
|
+
digest = InputSchema.digest(values)
|
|
201
|
+
if run.input_digest && run.input_digest != digest
|
|
202
|
+
reject!(run.status == "succeeded" ? "IDEMPOTENCY_CONFLICT" : "APPROVAL_INVALIDATED")
|
|
203
|
+
end
|
|
204
|
+
if run.status == "succeeded"
|
|
205
|
+
if run.operation == "create"
|
|
206
|
+
record = access.relation.find_by(resource.model_class.primary_key => run.record_id)
|
|
207
|
+
reject!("NOT_FOUND") unless record
|
|
208
|
+
end
|
|
209
|
+
next projection(access, run, record, replayed: true)
|
|
210
|
+
end
|
|
211
|
+
reject!("CONFLICT") unless run.status == "proposed"
|
|
212
|
+
reject!("APPROVAL_REQUIRED") unless admission || human_confirmed == true
|
|
213
|
+
if run.operation != "create" && RecordVersion.of(record) != run.expected_version
|
|
214
|
+
reject!("CONFLICT")
|
|
215
|
+
end
|
|
216
|
+
# A write claim, not a process mutex, serializes SQLite writers too.
|
|
217
|
+
run.update!(status: "running", input_digest: digest)
|
|
218
|
+
if definition.custom?
|
|
219
|
+
before = record.attributes_before_type_cast.deep_dup
|
|
220
|
+
context_for_handler = ExecutionContext.new(context, resource, record, run: run, duration: definition.handler_class.max_duration)
|
|
221
|
+
outcome = call_handler(definition, context_for_handler, record, values)
|
|
222
|
+
unless outcome.is_a?(Operation::Outcome)
|
|
223
|
+
raise ConfigurationError, "Operation must return success, invalid or conflict"
|
|
224
|
+
end
|
|
225
|
+
raise ConfigurationError, "Unknown operation outcome" unless %w[OK VALIDATION_FAILED CONFLICT].include?(outcome.code)
|
|
226
|
+
if outcome.code != "OK"
|
|
227
|
+
names = outcome.fields & definition.fields(access, record).map { |field| field.name.to_s }
|
|
228
|
+
reject!(outcome.code, errors: names.any? ? names.to_h { |name| [ name, [ Field.error_message(:invalid, "is invalid") ] ] } : { "base" => [ Field.error_message(:operation_rejected, "operation was rejected") ] })
|
|
229
|
+
end
|
|
230
|
+
unless record.persisted? && !record.destroyed? && !record.has_changes_to_save? && outcome.record_id == run.target_id
|
|
231
|
+
reject!("VALIDATION_FAILED", errors: { "base" => [ "operation did not persist its target" ] })
|
|
232
|
+
end
|
|
233
|
+
record.reload
|
|
234
|
+
if before != record.attributes_before_type_cast && RecordVersion.of(record) == run.expected_version
|
|
235
|
+
raise ConfigurationError, "Custom writes must advance the target lock_version or updated_at"
|
|
236
|
+
end
|
|
237
|
+
changed_fields = resource.fields.select { |field| before[field.column] != record.attributes_before_type_cast[field.column] }.map { |field| field.name.to_s }
|
|
238
|
+
WorkItem.where(operation_run_id: run.id).update_all(expected_version: RecordVersion.of(record))
|
|
239
|
+
else
|
|
240
|
+
lists = resource.fields.select { |field| field.scalar_type == :references && values.key?(field.name.to_s) }
|
|
241
|
+
before_lists = lists.to_h { |field| [ field.name.to_s, stored_references(record, field) ] }
|
|
242
|
+
record.assign_attributes(column_attributes(resource, unchanged_precision(resource, record, values))) if run.operation == "update"
|
|
243
|
+
names = field_names_by_column(resource)
|
|
244
|
+
changed_fields = record.changes.keys.map { |column| names.fetch(column, column) } & values.keys
|
|
245
|
+
# A password writer changes a digest column the resource never names.
|
|
246
|
+
changed_fields |= resource.fields.select { |field| field.scalar_type == :password && !values[field.name.to_s].nil? }.map { |field| field.name.to_s }
|
|
247
|
+
# Action Text keeps the content in its own record.
|
|
248
|
+
changed_fields |= resource.fields.select { |field| field.scalar_type == :rich_text && values.key?(field.name.to_s) && record.public_send(field.column)&.changed? }.map { |field| field.name.to_s }
|
|
249
|
+
# Active Storage keeps attachment changes outside the attributes.
|
|
250
|
+
if record.respond_to?(:attachment_changes)
|
|
251
|
+
changed_fields |= resource.fields.select { |field| field.scalar_type == :file && values.key?(field.name.to_s) && record.attachment_changes.key?(field.column) }.map { |field| field.name.to_s }
|
|
252
|
+
end
|
|
253
|
+
changed_files = apply_files(access, record, values)
|
|
254
|
+
changed_fields |= changed_files
|
|
255
|
+
# Join rows change no owner column, so a changed list touches the
|
|
256
|
+
# record: its version advances and a concurrent form conflicts.
|
|
257
|
+
changed_lists = lists.map { |field| field.name.to_s }.reject { |name| before_lists.fetch(name) == values.fetch(name) }
|
|
258
|
+
changed_fields |= changed_lists
|
|
259
|
+
persisted = definition.handler_class.new.perform(nil, record, values)
|
|
260
|
+
reject!("VALIDATION_FAILED", errors: { "base" => [ "could not be saved" ] }) unless persisted
|
|
261
|
+
record.touch if run.operation == "update" && (changed_lists.any? || changed_files.any?)
|
|
262
|
+
end
|
|
263
|
+
unless run.operation == "destroy" || access.relation.where(resource.model_class.primary_key => record.id).exists?
|
|
264
|
+
reject!("NOT_FOUND")
|
|
265
|
+
end
|
|
266
|
+
audit = AuditEvent.create!(actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
|
|
267
|
+
resource_key: run.resource_key, operation: run.operation, status: "succeeded", code: "OK",
|
|
268
|
+
record_id: record.id.to_s, correlation_ref: run.id, origin: origin.to_s,
|
|
269
|
+
changed_fields: changed_fields, created_at: Time.current)
|
|
270
|
+
attributes = { status: "succeeded", record_id: record.id.to_s, audit_event_id: audit.id, completed_at: Time.current }
|
|
271
|
+
attributes.merge!(approved_by_ref: context.actor_ref, approved_digest: digest, confirmed_at: Time.current) unless admission
|
|
272
|
+
run.update!(attributes)
|
|
273
|
+
receipt = projection(access, run, record)
|
|
274
|
+
admission&.complete!(run)
|
|
275
|
+
transaction_body_finished = true
|
|
276
|
+
receipt
|
|
277
|
+
end
|
|
278
|
+
result || committed_callback_result(context, run, transaction_body_finished) || reject!("VALIDATION_FAILED", errors: { "base" => [ "transaction was rolled back" ] })
|
|
279
|
+
rescue StandardError => error
|
|
280
|
+
committed = committed_callback_result(context, run, transaction_body_finished)
|
|
281
|
+
return committed if committed
|
|
282
|
+
case error
|
|
283
|
+
when Rejection
|
|
284
|
+
audit_failure(context, run, error.result.code, origin) unless admission
|
|
285
|
+
error.result
|
|
286
|
+
when AccessDenied
|
|
287
|
+
audit_failure(context, run, error.code, origin) unless admission
|
|
288
|
+
Result.new(code: error.code)
|
|
289
|
+
when ActiveRecord::RecordInvalid
|
|
290
|
+
allowed = if definition&.custom?
|
|
291
|
+
access&.operation_fields(run.operation, error.record)&.map { |field| field.name.to_s } || []
|
|
292
|
+
else
|
|
293
|
+
access&.fields(:write, error.record, operation: run.operation)&.map { |field| field.name.to_s } || []
|
|
294
|
+
end
|
|
295
|
+
names = definition&.custom? || !access ? {} : field_names_by_column(access.resource)
|
|
296
|
+
errors = validation_errors(error.record.errors, names, allowed)
|
|
297
|
+
audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
|
|
298
|
+
Result.new(code: "VALIDATION_FAILED", errors: errors.presence || { "base" => [ Field.error_message(:invalid, "is invalid") ] })
|
|
299
|
+
when ActiveRecord::RecordNotDestroyed
|
|
300
|
+
if run&.operation == "destroy" && !definition&.custom?
|
|
301
|
+
# dependent: :restrict_with_error (or a before_destroy abort) says
|
|
302
|
+
# why; it is a lasting state, not a stale form (RAA-012).
|
|
303
|
+
audit_failure(context, run, "RESTRICTED", origin) unless admission
|
|
304
|
+
Result.new(code: "RESTRICTED", errors: { "base" => restriction_messages(error.record) })
|
|
305
|
+
else
|
|
306
|
+
audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
|
|
307
|
+
Result.new(code: "VALIDATION_FAILED", errors: { "base" => [ "operation was not saved" ] })
|
|
308
|
+
end
|
|
309
|
+
when ActiveRecord::RecordNotSaved
|
|
310
|
+
audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
|
|
311
|
+
Result.new(code: "VALIDATION_FAILED", errors: { "base" => [ "operation was not saved" ] })
|
|
312
|
+
when ActiveRecord::InvalidForeignKey
|
|
313
|
+
# On delete, a foreign key means other rows still reference the
|
|
314
|
+
# record; elsewhere a referenced target vanished after admission.
|
|
315
|
+
if run&.operation == "destroy" && !definition&.custom?
|
|
316
|
+
audit_failure(context, run, "RESTRICTED", origin) unless admission
|
|
317
|
+
Result.new(code: "RESTRICTED", errors: { "base" => [ RESTRICTED_MESSAGE ] })
|
|
318
|
+
else
|
|
319
|
+
Result.new(code: "CONFLICT")
|
|
320
|
+
end
|
|
321
|
+
when ActiveRecord::RangeError
|
|
322
|
+
audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
|
|
323
|
+
Result.new(code: "VALIDATION_FAILED", errors: { "base" => [ "value is out of range" ] })
|
|
324
|
+
when ExecutionContext::DeadlineExceeded
|
|
325
|
+
audit_failure(context, run, "TIMEOUT", origin) unless admission
|
|
326
|
+
Result.new(code: "TIMEOUT")
|
|
327
|
+
when ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
|
|
328
|
+
# A references target deleted after its check fails the id writer.
|
|
329
|
+
ActiveRecord::RecordNotFound
|
|
330
|
+
Result.new(code: "CONFLICT")
|
|
331
|
+
when ActiveRecord::StatementInvalid
|
|
332
|
+
if defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException)
|
|
333
|
+
Result.new(code: "CONFLICT")
|
|
334
|
+
elsif definition&.custom?
|
|
335
|
+
audit_failure(context, run, "OPERATION_FAILED", origin) unless admission
|
|
336
|
+
Result.new(code: "OPERATION_FAILED")
|
|
337
|
+
else
|
|
338
|
+
raise
|
|
339
|
+
end
|
|
340
|
+
else
|
|
341
|
+
if definition&.custom? && !error.is_a?(ConfigurationError)
|
|
342
|
+
audit_failure(context, run, "OPERATION_FAILED", origin) unless admission
|
|
343
|
+
Result.new(code: "OPERATION_FAILED")
|
|
344
|
+
else
|
|
345
|
+
raise
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
private :execute_intent
|
|
351
|
+
|
|
352
|
+
private
|
|
353
|
+
|
|
354
|
+
class Rejection < StandardError # :nodoc:
|
|
355
|
+
attr_reader :result
|
|
356
|
+
def initialize(result)
|
|
357
|
+
@result = result
|
|
358
|
+
super(result.code)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# The host model's own messages for fields this actor may write, and its
|
|
363
|
+
# base messages (RAA-011). config.validation_messages = :generic keeps
|
|
364
|
+
# the earlier "is invalid" for every field instead.
|
|
365
|
+
def validation_errors(model_errors, names, allowed)
|
|
366
|
+
generic = RailsAiAdmin.configuration.validation_messages == :generic
|
|
367
|
+
errors = model_errors.attribute_names.each_with_object({}) do |attribute, result|
|
|
368
|
+
next if attribute == :base
|
|
369
|
+
name = names.fetch(attribute.to_s, attribute.to_s)
|
|
370
|
+
next unless allowed.include?(name)
|
|
371
|
+
result[name] = generic ? [ Field.error_message(:invalid, "is invalid") ] : bounded_messages(model_errors.messages_for(attribute))
|
|
372
|
+
end
|
|
373
|
+
base = generic ? [] : bounded_messages(model_errors.messages_for(:base), fallback: false)
|
|
374
|
+
errors["base"] = base if base.any?
|
|
375
|
+
errors
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def bounded_messages(messages, fallback: true)
|
|
379
|
+
kept = Array(messages).select { |message| message.is_a?(String) && message.valid_encoding? }
|
|
380
|
+
.map { |message| message.gsub(/[[:cntrl:]]/, " ").squish.truncate(200) }.reject(&:empty?).uniq.first(3)
|
|
381
|
+
kept.empty? && fallback ? [ Field.error_message(:invalid, "is invalid") ] : kept
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
RESTRICTED_MESSAGE = "This record is still referenced by other records and cannot be deleted."
|
|
385
|
+
|
|
386
|
+
# Host base messages from restrict_with_error are model-authored text
|
|
387
|
+
# about the record itself; keep at most three, bounded and plain.
|
|
388
|
+
def restriction_messages(record)
|
|
389
|
+
messages = Array(record&.errors&.[](:base)).select { |message| message.is_a?(String) && message.valid_encoding? }
|
|
390
|
+
.map { |message| message.gsub(/[[:cntrl:]]/, " ").squish.truncate(200) }.reject(&:empty?).first(3)
|
|
391
|
+
messages.presence || [ RESTRICTED_MESSAGE ]
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def reject!(code, errors: {})
|
|
395
|
+
raise Rejection, Result.new(code: code, errors: errors)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def check_origin!(origin)
|
|
399
|
+
raise ConfigurationError, "Unsupported origin" unless ORIGINS.include?(origin.to_s)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def check_binding!(run, binding)
|
|
403
|
+
expected = { resource_key: run.resource_key, operation: run.operation, target_id: run.target_id }
|
|
404
|
+
reject!("NOT_FOUND") unless binding.transform_values { |value| value&.to_s } == expected
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def parse_version(value)
|
|
408
|
+
raise AccessDenied, "CONFLICT" unless value.is_a?(Integer) || value.to_s.match?(/\A\d+\z/)
|
|
409
|
+
value.is_a?(String) ? Integer(value, 10) : value
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def ensure_connection!(resource, definition)
|
|
413
|
+
model = resource.model_class
|
|
414
|
+
unless model.connection_pool.equal?(OperationRun.connection_pool) && model.connection_pool.equal?(AuditEvent.connection_pool) && model.connection_pool.equal?(WorkItem.connection_pool) && model.connection_pool.equal?(BatchItem.connection_pool)
|
|
415
|
+
raise ConfigurationError, "Domain, intent and audit must use the same connection pool"
|
|
416
|
+
end
|
|
417
|
+
unless RecordVersion.supported?(model)
|
|
418
|
+
raise ConfigurationError, "Writes require lock_version or an updated_at timestamp, and a single primary key"
|
|
419
|
+
end
|
|
420
|
+
version_column = RecordVersion.column(model)
|
|
421
|
+
if resource.fields.any? { |field| field.column == version_column && (field.writable?(:create) || field.writable?(:update)) }
|
|
422
|
+
raise ConfigurationError, "#{version_column} carries the record version and cannot be a writable field"
|
|
423
|
+
end
|
|
424
|
+
unless %w[SQLite PostgreSQL].include?(model.connection.adapter_name)
|
|
425
|
+
raise ConfigurationError, "Only SQLite and PostgreSQL are supported for writes"
|
|
426
|
+
end
|
|
427
|
+
if definition.custom?
|
|
428
|
+
definition.handler_class.atomic_models.each do |name|
|
|
429
|
+
related = name.constantize
|
|
430
|
+
unless related.is_a?(Class) && related < ActiveRecord::Base && related.connection_pool.equal?(model.connection_pool)
|
|
431
|
+
raise ConfigurationError, "Every declared atomic model must use the operation connection pool"
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def operation_version(resource, operation)
|
|
438
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
|
|
439
|
+
RunnerFingerprint.call(resource, definition)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def load_record(access, run, definition)
|
|
443
|
+
if run.status == "succeeded" && run.operation == "destroy"
|
|
444
|
+
# Deleted records have no current projection. Require a fresh
|
|
445
|
+
# collection-level destroy decision before returning even a receipt.
|
|
446
|
+
nil
|
|
447
|
+
elsif run.operation == "create"
|
|
448
|
+
access.relation.new
|
|
449
|
+
elsif definition.custom? && run.status == "succeeded"
|
|
450
|
+
access.relation.lock.find_by(access.resource.model_class.primary_key => run.record_id).tap { |record| reject!("NOT_FOUND") unless record }
|
|
451
|
+
else
|
|
452
|
+
access.find!(run.target_id, action: run.operation, lock: true)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def coerce!(access, record, definition, input, replay: false)
|
|
457
|
+
fields = replay && definition.custom? ? definition.handler_class.input : definition.fields(access, record)
|
|
458
|
+
result = InputSchema.new(fields, partial: definition.key == "update" && !definition.custom?).coerce(input, time_zone: access.context.time_zone)
|
|
459
|
+
reject!(replay ? "IDEMPOTENCY_CONFLICT" : "VALIDATION_FAILED", errors: replay ? {} : result.errors) unless result.valid?
|
|
460
|
+
check_references!(access, record, fields, result.values) unless replay
|
|
461
|
+
retain_hidden_references(access, record, fields, result.values)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def stored_references(record, field)
|
|
465
|
+
return [] unless record&.persisted?
|
|
466
|
+
record.public_send(field.column).map(&:to_s).uniq.sort
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# A list keeps the stored targets this actor cannot see: a person can
|
|
470
|
+
# neither remove nor learn about records hidden by the target's policy.
|
|
471
|
+
def retain_hidden_references(access, record, fields, values)
|
|
472
|
+
lists = fields.select { |field| field.scalar_type == :references && values.key?(field.name.to_s) }
|
|
473
|
+
return values if lists.empty?
|
|
474
|
+
resolver = Fields::Association.new(access.context, access.resource)
|
|
475
|
+
merged = values.dup
|
|
476
|
+
lists.each do |field|
|
|
477
|
+
stored = stored_references(record, field)
|
|
478
|
+
if stored.size > Field::MAX_REFERENCES
|
|
479
|
+
reject!("VALIDATION_FAILED", errors: { field.name.to_s => [ Field.error_message(:too_many, "can name at most %{count} records", count: Field::MAX_REFERENCES) ] })
|
|
480
|
+
end
|
|
481
|
+
resolver.prime(field, stored)
|
|
482
|
+
hidden = stored.reject { |id| resolver.admissible?(field, id) }
|
|
483
|
+
merged[field.name.to_s] = (values.fetch(field.name.to_s) | hidden).sort.freeze
|
|
484
|
+
end
|
|
485
|
+
merged.freeze
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# A changed reference must name a target this actor can currently see in
|
|
489
|
+
# the target resource. Keeping the stored key is not a new attachment, so
|
|
490
|
+
# an edit never forces clearing a reference the actor cannot see.
|
|
491
|
+
def check_references!(access, record, fields, values)
|
|
492
|
+
references = fields.select { |field| Field::REFERENCE_TYPES.include?(field.scalar_type) && !values[field.name.to_s].nil? }
|
|
493
|
+
return if references.empty?
|
|
494
|
+
resolver = Fields::Association.new(access.context, access.resource)
|
|
495
|
+
errors = references.each_with_object({}) do |field, result|
|
|
496
|
+
value = values.fetch(field.name.to_s)
|
|
497
|
+
if field.scalar_type == :references
|
|
498
|
+
# Only newly added targets need to be visible now.
|
|
499
|
+
added = value - stored_references(record, field)
|
|
500
|
+
resolver.prime(field, added)
|
|
501
|
+
result[field.name.to_s] = [ Field.error_message(:unavailable_choice, Fields::Association::UNAVAILABLE) ] unless added.all? { |id| resolver.admissible?(field, id) }
|
|
502
|
+
next
|
|
503
|
+
end
|
|
504
|
+
stored = record&.persisted? && record.has_attribute?(field.column) ? record.read_attribute(field.column) : nil
|
|
505
|
+
next if !stored.nil? && stored.to_s == value
|
|
506
|
+
result[field.name.to_s] = [ Field.error_message(:unavailable_choice, Fields::Association::UNAVAILABLE) ] unless resolver.admissible?(field, value)
|
|
507
|
+
end
|
|
508
|
+
reject!("VALIDATION_FAILED", errors: errors) if errors.any?
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# A form shows a timestamp to the second. Submitting it back unchanged
|
|
512
|
+
# must not drop the stored fraction (RAA-002/RAA-015), so a whole-second
|
|
513
|
+
# value equal to the stored one truncated keeps the stored one.
|
|
514
|
+
def unchanged_precision(resource, record, values)
|
|
515
|
+
fields = resource.fields.index_by { |field| field.name.to_s }
|
|
516
|
+
values.to_h do |name, value|
|
|
517
|
+
field = fields[name]
|
|
518
|
+
stored = field && field.scalar_type == :datetime && record.has_attribute?(field.column) ? record.read_attribute(field.column) : nil
|
|
519
|
+
kept = stored.respond_to?(:usec) && value.respond_to?(:usec) && value.usec.zero? && stored.usec.nonzero? && stored.change(usec: 0) == value
|
|
520
|
+
[ name, kept ? stored : value ]
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# files changes are applied by apply_files, never assigned as a list.
|
|
525
|
+
def column_attributes(resource, values)
|
|
526
|
+
columns = resource.fields.to_h { |field| [ field.name.to_s, field.column ] }
|
|
527
|
+
lists = resource.fields.select { |field| field.scalar_type == :files }.map { |field| field.name.to_s }
|
|
528
|
+
values.except(*lists).transform_keys { |name| columns.fetch(name, name) }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Removes the named attachments of this record and attaches the new
|
|
532
|
+
# blobs. Returns the names of files fields that changed.
|
|
533
|
+
def apply_files(access, record, values)
|
|
534
|
+
access.resource.fields.select { |field| field.scalar_type == :files && values.key?(field.name.to_s) }.filter_map do |field|
|
|
535
|
+
change = values.fetch(field.name.to_s) || { "add" => [], "remove" => [] }
|
|
536
|
+
attached = record.public_send(field.column)
|
|
537
|
+
stored = record.persisted? ? access.stored_attachments(record, field) : nil
|
|
538
|
+
removed = stored ? stored.where(id: change["remove"]).to_a : []
|
|
539
|
+
unless removed.size == change["remove"].size
|
|
540
|
+
reject!("VALIDATION_FAILED", errors: { field.name.to_s => [ Field.error_message(:unavailable_choice, Fields::Association::UNAVAILABLE) ] })
|
|
541
|
+
end
|
|
542
|
+
total = (stored ? stored.count : 0) - removed.size + change["add"].size
|
|
543
|
+
if total > field.options[:max_files]
|
|
544
|
+
reject!("VALIDATION_FAILED", errors: { field.name.to_s => [ Field.error_message(:too_many_files, "can hold at most %{count} files", count: field.options[:max_files]) ] })
|
|
545
|
+
end
|
|
546
|
+
# The attachment rows go with this transaction; their blobs are
|
|
547
|
+
# purged only after it commits, so a rollback loses no file.
|
|
548
|
+
blobs = removed.map(&:blob)
|
|
549
|
+
removed.each(&:delete)
|
|
550
|
+
ActiveRecord.after_all_transactions_commit { blobs.each { |blob| blob.purge_later unless blob.attachments.exists? } } if blobs.any?
|
|
551
|
+
attached.attach(*change["add"]) if change["add"].any?
|
|
552
|
+
field.name.to_s if removed.any? || change["add"].any?
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def field_names_by_column(resource) = resource.fields.to_h { |field| [ field.column, field.name.to_s ] }
|
|
557
|
+
|
|
558
|
+
def preview_changes(access, record, definition, values)
|
|
559
|
+
handler_context = ExecutionContext.new(access.context, access.resource, record, duration: definition.handler_class.max_duration)
|
|
560
|
+
changes = ActiveRecord::Base.while_preventing_writes { definition.handler_class.new.preflight(handler_context, record, values) }
|
|
561
|
+
handler_context.check_deadline!
|
|
562
|
+
if changes.is_a?(Operation::Outcome) && %w[VALIDATION_FAILED CONFLICT].include?(changes.code)
|
|
563
|
+
names = changes.fields & definition.fields(access, record).map { |field| field.name.to_s }
|
|
564
|
+
reject!(changes.code, errors: names.any? ? names.to_h { |name| [ name, [ Field.error_message(:invalid, "is invalid") ] ] } : { "base" => [ Field.error_message(:operation_rejected, "operation was rejected") ] })
|
|
565
|
+
end
|
|
566
|
+
raise ConfigurationError, "Preflight must return a field-value hash without mutating its target" unless changes.is_a?(Hash) && !record.has_changes_to_save?
|
|
567
|
+
readable = access.fields(:read, record, view: :show)
|
|
568
|
+
original = begin
|
|
569
|
+
access.project(record, view: :show)
|
|
570
|
+
rescue AccessDenied
|
|
571
|
+
{}
|
|
572
|
+
end
|
|
573
|
+
readable.filter_map do |field|
|
|
574
|
+
name = field.name.to_s
|
|
575
|
+
next if field.virtual?
|
|
576
|
+
next unless original.key?(name) && (changes.key?(name) || changes.key?(field.name))
|
|
577
|
+
value = changes.key?(name) ? changes[name] : changes[field.name]
|
|
578
|
+
value = field.coerce_output(value, time_zone: access.context.time_zone)
|
|
579
|
+
[ name, { "before" => original[name], "after" => value } ]
|
|
580
|
+
end.to_h
|
|
581
|
+
rescue ArgumentError, ActiveRecord::ReadOnlyError
|
|
582
|
+
raise ConfigurationError, "Preflight violated its pure typed preview contract", cause: nil
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def call_handler(definition, context, record, values)
|
|
586
|
+
nested_savepoint = false
|
|
587
|
+
connection = OperationRun.connection
|
|
588
|
+
listener = ->(_event, _start, _finish, _id, payload) { nested_savepoint = true if payload[:connection].equal?(connection) && payload[:sql].match?(/\A(?:SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT)\b/i) }
|
|
589
|
+
context.check_deadline!
|
|
590
|
+
outcome = ActiveSupport::Notifications.subscribed(listener, "sql.active_record") { definition.handler_class.new.perform(context, record, values) }
|
|
591
|
+
raise ConfigurationError, "Custom handlers must not open requires_new transactions" if nested_savepoint
|
|
592
|
+
context.check_deadline!
|
|
593
|
+
outcome
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def projection(access, run, record, replayed: false)
|
|
597
|
+
values = if run.operation == "destroy"
|
|
598
|
+
{}
|
|
599
|
+
else
|
|
600
|
+
begin
|
|
601
|
+
access.project(record)
|
|
602
|
+
rescue AccessDenied
|
|
603
|
+
{}
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
Result.new(code: "OK", record_id: run.record_id, audit_ref: run.audit_event_id, values: values, replayed: replayed,
|
|
607
|
+
warning: run.warning_code, work_refs: WorkItem.where(operation_run_id: run.id).pluck(:id))
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def committed_callback_result(context, run, body_finished)
|
|
611
|
+
return unless body_finished && run && !OperationRun.connection.transaction_open?
|
|
612
|
+
committed = OperationRun.uncached do
|
|
613
|
+
OperationRun.find_by(id: run.id, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref, status: "succeeded")
|
|
614
|
+
end
|
|
615
|
+
return unless committed
|
|
616
|
+
committed.update!(warning_code: "POST_COMMIT_CALLBACK_FAILED")
|
|
617
|
+
# after_commit exceptions cannot turn an already committed business write
|
|
618
|
+
# into a failure. Keep its receipt, without running callbacks or exposing
|
|
619
|
+
# record content again. External-effect reconciliation belongs to host.
|
|
620
|
+
Result.new(code: "OK", record_id: committed.record_id, audit_ref: committed.audit_event_id,
|
|
621
|
+
warning: "POST_COMMIT_CALLBACK_FAILED", work_refs: WorkItem.where(operation_run_id: committed.id).pluck(:id))
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def audit_failure(context, run, code, origin)
|
|
625
|
+
return unless run && run.actor_ref == context.actor_ref && run.tenant_ref == context.tenant_ref
|
|
626
|
+
return if run.parent_run_id || run.selection_id
|
|
627
|
+
AuditEvent.create!(actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
|
|
628
|
+
resource_key: run.resource_key, operation: run.operation, status: "rejected", code: code,
|
|
629
|
+
record_id: run.target_id, correlation_ref: run.id, origin: origin.to_s, changed_fields: [], created_at: Time.current)
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Operations
|
|
5
|
+
module RunnerFingerprint
|
|
6
|
+
def self.call(resource, definition)
|
|
7
|
+
"operation:1:#{InputSchema.digest(identity: ResourceIdentity.call(resource), policy: resource.policy,
|
|
8
|
+
fields: resource.fields.map(&:contract), operation: definition.key, definition: definition.signature)}"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|