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,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Collections
|
|
5
|
+
module Contract
|
|
6
|
+
ACTIVE = %w[queued running].freeze
|
|
7
|
+
TERMINAL = %w[succeeded failed partially_succeeded cancelled reconciliation_required].freeze
|
|
8
|
+
|
|
9
|
+
def self.owned(context, ref, lock: false)
|
|
10
|
+
raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
|
|
11
|
+
raise AccessDenied unless ref.is_a?(String) && ref.match?(/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/)
|
|
12
|
+
relation = lock ? OperationRun.lock : OperationRun.all
|
|
13
|
+
run = relation.where.not(selection_id: nil).find_by(id: ref, parent_run_id: nil, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
|
|
14
|
+
raise AccessDenied unless run
|
|
15
|
+
raise AccessDenied, "APPROVAL_INVALIDATED" unless run.security_version == context.security_version
|
|
16
|
+
run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.definition(run)
|
|
20
|
+
resource = RailsAiAdmin.registry.fetch(run.resource_key)
|
|
21
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
|
|
22
|
+
unless definition.custom? && definition.scope == :collection && run.operation_version == Operations::RunnerFingerprint.call(resource, definition)
|
|
23
|
+
raise AccessDenied, "APPROVAL_INVALIDATED"
|
|
24
|
+
end
|
|
25
|
+
ensure_pool!(resource)
|
|
26
|
+
[ resource, definition ]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.ensure_outside_transaction!
|
|
30
|
+
raise ConfigurationError, "Collection admission, cancellation and worker steps must own their transaction" if OperationRun.connection.transaction_open?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.ensure_pool!(resource)
|
|
34
|
+
model = resource.model_class
|
|
35
|
+
unless [ OperationRun, AuditEvent, WorkItem, BatchItem, Selection, SelectionItem ].all? { |klass| klass.connection_pool.equal?(model.connection_pool) } &&
|
|
36
|
+
RecordVersion.supported?(model) && %w[SQLite PostgreSQL].include?(model.connection.adapter_name)
|
|
37
|
+
raise ConfigurationError, "Collection storage and domain must share one SQLite/PostgreSQL pool with lock_version or updated_at"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.verify_manifest!(run)
|
|
42
|
+
manifest = run.manifest_payload
|
|
43
|
+
unless manifest.is_a?(Array) && manifest.size.between?(1, Selections::Builder::LIMIT) && manifest.all? { |pair| pair.is_a?(Array) && pair.size == 2 && pair[0].is_a?(String) && pair[0].bytesize.between?(1, 150) && pair[1].is_a?(Integer) && pair[1] >= 0 } &&
|
|
44
|
+
manifest.map(&:first).uniq.size == manifest.size && manifest.sort_by(&:first) == manifest &&
|
|
45
|
+
InputSchema.digest(resource_version: run.manifest_resource_version, targets: manifest) == run.selection_digest
|
|
46
|
+
raise AccessDenied, "APPROVAL_INVALIDATED"
|
|
47
|
+
end
|
|
48
|
+
manifest
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.approval_digest(run)
|
|
52
|
+
InputSchema.digest(actor: run.actor_ref, tenant: run.tenant_ref, epoch: run.security_version,
|
|
53
|
+
resource: run.resource_key, operation: run.operation, version: run.operation_version,
|
|
54
|
+
selection: run.selection_id, input_digest: run.input_digest, selection_digest: run.selection_digest,
|
|
55
|
+
deadline: run.execution_deadline)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.verify_approved!(run, definition, context)
|
|
59
|
+
verify_manifest!(run)
|
|
60
|
+
values = InputSchema.new(definition.handler_class.input).coerce(run.input_payload, time_zone: context.time_zone)
|
|
61
|
+
unless run.confirmed_at && run.approved_by_ref == run.actor_ref && run.approved_digest == approval_digest(run) &&
|
|
62
|
+
run.execution_deadline && values.valid? && InputSchema.digest(values.values) == run.input_digest
|
|
63
|
+
raise AccessDenied, "APPROVAL_INVALIDATED"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Caller holds the parent lock. Each successful child updates its own receipt
|
|
68
|
+
# and these aggregate statuses inside that same business transaction.
|
|
69
|
+
def self.refresh!(run)
|
|
70
|
+
counts = BatchItem.where(parent_run_id: run.id).group(:status).count
|
|
71
|
+
raise ConfigurationError, "Collection item count changed" unless counts.values.sum == run.manifest_payload.size
|
|
72
|
+
status = if counts.fetch("running", 0) > 0
|
|
73
|
+
"running"
|
|
74
|
+
elsif counts.fetch("queued", 0) > 0
|
|
75
|
+
"queued"
|
|
76
|
+
elsif counts.fetch("succeeded", 0) == run.manifest_payload.size
|
|
77
|
+
"succeeded"
|
|
78
|
+
elsif counts.fetch("succeeded", 0) > 0
|
|
79
|
+
"partially_succeeded"
|
|
80
|
+
elsif counts.fetch("cancelled", 0) > 0
|
|
81
|
+
"cancelled"
|
|
82
|
+
else
|
|
83
|
+
"failed"
|
|
84
|
+
end
|
|
85
|
+
run.update!(status: status, completed_at: TERMINAL.include?(status) ? Time.current : nil)
|
|
86
|
+
status
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.quarantine!(run, code)
|
|
90
|
+
BatchItem.where(parent_run_id: run.id, status: ACTIVE).update_all(status: "cancelled", last_code: code,
|
|
91
|
+
completed_at: Time.current, lease_owner: nil, lease_expires_at: nil)
|
|
92
|
+
run.update!(status: "reconciliation_required", completed_at: Time.current)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.stop_remaining!(run, code)
|
|
96
|
+
# A domain transaction holds this same parent lock. Once acquired, an
|
|
97
|
+
# uncommitted claimed item can be fenced without racing a business commit.
|
|
98
|
+
BatchItem.where(parent_run_id: run.id, status: ACTIVE).update_all(status: "cancelled", last_code: code,
|
|
99
|
+
completed_at: Time.current, lease_owner: nil, lease_expires_at: nil)
|
|
100
|
+
refresh!(run)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.contention?(error)
|
|
104
|
+
error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout) || error.is_a?(ActiveRecord::RecordNotUnique) ||
|
|
105
|
+
(defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.result(run, replayed: false)
|
|
109
|
+
Result.new(code: ACTIVE.include?(run.status) ? "QUEUED" : "OK", job_ref: run.id, status: run.status, replayed: replayed, warning: run.warning_code)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Collections
|
|
5
|
+
# Executes bounded, independently committed database items. A recurring host
|
|
6
|
+
# scan recovers queue loss and expired leases; no scheduler is installed.
|
|
7
|
+
class Dispatcher
|
|
8
|
+
def run(job_ref, limit: 100)
|
|
9
|
+
raise ArgumentError, "limit must be between 1 and 100" unless limit.is_a?(Integer) && (1..100).cover?(limit)
|
|
10
|
+
raise ConfigurationError, "Collection execution cannot run inside an existing transaction" if OperationRun.connection.transaction_open?
|
|
11
|
+
limit.times do
|
|
12
|
+
claim = claim_next(job_ref)
|
|
13
|
+
break unless claim
|
|
14
|
+
next if claim.is_a?(Result)
|
|
15
|
+
result = begin
|
|
16
|
+
Operations::Runner.new.execute_admitted(claim: claim)
|
|
17
|
+
rescue ConfigurationError, NameError
|
|
18
|
+
Operations::Result.new(code: "APPROVAL_INVALIDATED")
|
|
19
|
+
rescue StandardError
|
|
20
|
+
Operations::Result.new(code: "OPERATION_FAILED")
|
|
21
|
+
end
|
|
22
|
+
break if result.code == "FENCED"
|
|
23
|
+
break if !result.success? && !finish_rejected(claim, result)
|
|
24
|
+
end
|
|
25
|
+
parent = OperationRun.where.not(selection_id: nil).find_by(id: job_ref, parent_run_id: nil)
|
|
26
|
+
parent ? Contract.result(parent) : Result.new(code: "NOT_FOUND")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def scan_pending!(limit: 100, inline: false)
|
|
30
|
+
raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
|
|
31
|
+
now = Time.current
|
|
32
|
+
queued = BatchItem.where(status: "queued").where("next_attempt_at IS NULL OR next_attempt_at <= ?", now)
|
|
33
|
+
stale = BatchItem.where(status: "running").where("lease_expires_at IS NULL OR lease_expires_at <= ?", now)
|
|
34
|
+
ids = queued.or(stale).distinct.limit(limit).pluck(:parent_run_id)
|
|
35
|
+
ids.count do |ref|
|
|
36
|
+
if inline
|
|
37
|
+
run(ref)
|
|
38
|
+
true
|
|
39
|
+
else
|
|
40
|
+
!!ExecutionJob.perform_later(ref)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Exposed to trusted worker infrastructure for deterministic lease tests.
|
|
46
|
+
# Claiming grants no write permission; Runner checks the full binding again.
|
|
47
|
+
def claim_next(job_ref)
|
|
48
|
+
Contract.ensure_outside_transaction!
|
|
49
|
+
OperationRun.transaction(requires_new: true) do
|
|
50
|
+
parent = OperationRun.lock.where.not(selection_id: nil).find_by(id: job_ref, parent_run_id: nil)
|
|
51
|
+
next unless parent && Contract::ACTIVE.include?(parent.status)
|
|
52
|
+
begin
|
|
53
|
+
_resource, definition = Contract.definition(parent)
|
|
54
|
+
Contract.verify_manifest!(parent)
|
|
55
|
+
rescue AccessDenied, ConfigurationError, NameError
|
|
56
|
+
Contract.quarantine!(parent, "APPROVAL_INVALIDATED")
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
if parent.cancellation_requested_at || !parent.execution_deadline || parent.execution_deadline <= Time.current
|
|
60
|
+
Contract.stop_remaining!(parent, parent.cancellation_requested_at ? "CANCELLED" : "EXECUTION_EXPIRED")
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
now = Time.current
|
|
64
|
+
next if BatchItem.where(parent_run_id: parent.id, status: "running").where("lease_expires_at > ?", now).exists?
|
|
65
|
+
ready = BatchItem.where(parent_run_id: parent.id, status: "queued").where("next_attempt_at IS NULL OR next_attempt_at <= ?", now)
|
|
66
|
+
expired = BatchItem.where(parent_run_id: parent.id, status: "running").where("lease_expires_at IS NULL OR lease_expires_at <= ?", now)
|
|
67
|
+
item = ready.or(expired).order(:position).lock.first
|
|
68
|
+
next unless item
|
|
69
|
+
if item.attempts >= definition.handler_class.max_attempts
|
|
70
|
+
item.update!(status: "failed", last_code: "ATTEMPTS_EXHAUSTED", completed_at: now)
|
|
71
|
+
Contract.refresh!(parent)
|
|
72
|
+
next Result.new(code: "ATTEMPTS_EXHAUSTED", job_ref: parent.id, status: parent.status)
|
|
73
|
+
end
|
|
74
|
+
item.update!(status: "running", lease_owner: SecureRandom.uuid, generation: item.generation + 1,
|
|
75
|
+
attempts: item.attempts + 1, lease_expires_at: now + definition.handler_class.lease_duration, next_attempt_at: nil)
|
|
76
|
+
parent.update!(status: "running")
|
|
77
|
+
Claim.new(item)
|
|
78
|
+
end
|
|
79
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
80
|
+
raise unless Contract.contention?(error)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def finish_rejected(claim, result)
|
|
87
|
+
OperationRun.transaction(requires_new: true) do
|
|
88
|
+
parent = OperationRun.lock.find(claim.parent_ref)
|
|
89
|
+
item = BatchItem.lock.find(claim.item_ref)
|
|
90
|
+
next unless item.status == "running" && item.lease_owner == claim.owner && item.generation == claim.generation
|
|
91
|
+
# An expired response cannot finalize the old lease. The durable running
|
|
92
|
+
# item is left for the scanner to reclaim, without changing its receipt.
|
|
93
|
+
next unless item.lease_expires_at && item.lease_expires_at > Time.current
|
|
94
|
+
lease_deadline = item.lease_expires_at
|
|
95
|
+
finalized = if %w[APPROVAL_INVALIDATED AUTHORIZATION_REVOKED CANCELLED EXECUTION_EXPIRED].include?(result.code)
|
|
96
|
+
Contract.stop_remaining!(parent, result.code)
|
|
97
|
+
else
|
|
98
|
+
retryable = result.code == "OPERATION_FAILED" && item.attempts < current_attempt_limit(parent) && parent.execution_deadline > Time.current
|
|
99
|
+
item.update!(status: retryable ? "queued" : (result.code == "NOT_FOUND" ? "cancelled" : "failed"), last_code: result.code,
|
|
100
|
+
next_attempt_at: retryable ? Time.current + [ 2**item.attempts, 60 ].min : nil,
|
|
101
|
+
completed_at: retryable ? nil : Time.current)
|
|
102
|
+
AuditEvent.create!(actor_ref: parent.actor_ref, tenant_ref: parent.tenant_ref, resource_key: parent.resource_key,
|
|
103
|
+
operation: parent.operation, status: "rejected", code: result.code, correlation_ref: item.operation_run_id,
|
|
104
|
+
origin: parent.origin, changed_fields: [], created_at: Time.current)
|
|
105
|
+
Contract.refresh!(parent)
|
|
106
|
+
end
|
|
107
|
+
raise AccessDenied, "FENCED" unless lease_deadline > Time.current
|
|
108
|
+
finalized
|
|
109
|
+
end
|
|
110
|
+
rescue AccessDenied => error
|
|
111
|
+
raise unless error.code == "FENCED"
|
|
112
|
+
nil
|
|
113
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
114
|
+
raise unless Contract.contention?(error)
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def current_attempt_limit(parent)
|
|
119
|
+
_resource, definition = Contract.definition(parent)
|
|
120
|
+
definition.handler_class.max_attempts
|
|
121
|
+
rescue AccessDenied
|
|
122
|
+
0
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Collections
|
|
5
|
+
class Queries
|
|
6
|
+
Entry = Data.define(:record_id, :label, :status, :code, :audit_ref, :warning, :work_statuses)
|
|
7
|
+
# waiting_since: when the oldest still-queued item of an active run was
|
|
8
|
+
# queued, so a page can say that nothing has picked it up (RAA-020).
|
|
9
|
+
Snapshot = Data.define(:job_ref, :resource_key, :operation, :description, :status, :counts, :entries, :hidden_count, :can_cancel, :warning, :waiting_since)
|
|
10
|
+
|
|
11
|
+
def fetch(context:, job_ref:, summary: false)
|
|
12
|
+
ActiveRecord::Base.while_preventing_writes do
|
|
13
|
+
parent = Contract.owned(context, job_ref)
|
|
14
|
+
resource, definition = Contract.definition(parent)
|
|
15
|
+
access = Authorization::Access.new(context, resource)
|
|
16
|
+
access.authorize_receipt!(parent.operation, nil)
|
|
17
|
+
Contract.verify_manifest!(parent)
|
|
18
|
+
Contract.verify_approved!(parent, definition, context) if parent.approved_digest
|
|
19
|
+
items = BatchItem.where(parent_run_id: parent.id).order(:position).limit(Selections::Builder::LIMIT + 1).to_a
|
|
20
|
+
if parent.approved_digest && items.size != parent.manifest_payload.size
|
|
21
|
+
raise ConfigurationError, "Collection result manifest is inconsistent"
|
|
22
|
+
end
|
|
23
|
+
counts = items.group_by(&:status).transform_values(&:size).freeze
|
|
24
|
+
entries = summary ? [] : items.filter_map { |item| entry(access, parent, item) }
|
|
25
|
+
cancellable = begin
|
|
26
|
+
access.authorize_cancel!(parent.operation)
|
|
27
|
+
parent.status == "proposed" || Contract::ACTIVE.include?(parent.status)
|
|
28
|
+
rescue AccessDenied
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
waiting = Contract::ACTIVE.include?(parent.status) && items.none? { |item| item.status == "running" } ?
|
|
32
|
+
items.select { |item| item.status == "queued" }.map(&:created_at).compact.min : nil
|
|
33
|
+
Snapshot.new(parent.id, resource.key.to_s, parent.operation, definition.description, parent.status, counts,
|
|
34
|
+
entries.freeze, summary ? 0 : items.size - entries.size, cancellable, parent.warning_code, waiting)
|
|
35
|
+
end
|
|
36
|
+
rescue StandardError => error
|
|
37
|
+
raise if error.is_a?(AccessDenied) || error.is_a?(ConfigurationError)
|
|
38
|
+
raise ConfigurationError, "Collection result cannot be projected safely", cause: nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def recent(context:)
|
|
42
|
+
raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
|
|
43
|
+
refs = OperationRun.where(actor_ref: context.actor_ref, tenant_ref: context.tenant_ref, security_version: context.security_version,
|
|
44
|
+
parent_run_id: nil).where.not(selection_id: nil).where.not(status: "proposed").order(created_at: :desc, id: :desc).limit(50).pluck(:id)
|
|
45
|
+
refs.filter_map do |ref|
|
|
46
|
+
fetch(context: context, job_ref: ref, summary: true)
|
|
47
|
+
rescue AccessDenied
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def entry(access, parent, item)
|
|
55
|
+
unless parent.manifest_payload[item.position] == [ item.target_id, item.expected_version ]
|
|
56
|
+
raise ConfigurationError, "Collection item identity changed"
|
|
57
|
+
end
|
|
58
|
+
record = access.find!(item.target_id)
|
|
59
|
+
access.authorize_receipt!(parent.operation, record)
|
|
60
|
+
label = access.record_label(record)
|
|
61
|
+
child = OperationRun.find_by!(id: item.operation_run_id, parent_run_id: parent.id)
|
|
62
|
+
unless child.target_id == item.target_id && child.expected_version == item.expected_version &&
|
|
63
|
+
child.resource_key == parent.resource_key && child.operation == parent.operation && child.input_digest == parent.input_digest &&
|
|
64
|
+
child.actor_ref == parent.actor_ref && child.tenant_ref == parent.tenant_ref && child.operation_version == parent.operation_version
|
|
65
|
+
raise ConfigurationError, "Collection child identity changed"
|
|
66
|
+
end
|
|
67
|
+
work = WorkItem.where(operation_run_id: child.id).filter_map do |work_item|
|
|
68
|
+
access.authorize_delivery!(parent.operation, work_item.kind, record)
|
|
69
|
+
work_item.status
|
|
70
|
+
rescue AccessDenied
|
|
71
|
+
nil
|
|
72
|
+
end.tally.freeze
|
|
73
|
+
Entry.new(record.id.to_s, label, item.status, item.last_code, child.audit_event_id, child.warning_code, work)
|
|
74
|
+
rescue AccessDenied
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Collections
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :code, :job_ref, :status, :errors, :replayed, :warning
|
|
7
|
+
def initialize(code:, job_ref: nil, status: "rejected", errors: {}, replayed: false, warning: nil)
|
|
8
|
+
@code, @job_ref, @status, @errors, @replayed, @warning = code, job_ref, status, errors.freeze, replayed, warning
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
def accepted? = job_ref.present? && %w[QUEUED OK].include?(code)
|
|
12
|
+
def success? = status == "succeeded"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Collections
|
|
5
|
+
# One parent owns approval. Per-target child intents are created only at
|
|
6
|
+
# admission, and can execute solely through an admitted worker lease.
|
|
7
|
+
class Runner
|
|
8
|
+
PREFLIGHT_BUDGET = 30
|
|
9
|
+
Target = Data.define(:id, :label, :changes)
|
|
10
|
+
Proposal = Data.define(:intent, :values, :targets)
|
|
11
|
+
class Rejected < StandardError
|
|
12
|
+
attr_reader :result
|
|
13
|
+
def initialize(code, errors = {})
|
|
14
|
+
@result = Result.new(code: code, errors: errors)
|
|
15
|
+
super(code)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def preflight(context:, resource:, operation:, selection_ref:, input:, origin: :ui)
|
|
20
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PREFLIGHT_BUDGET
|
|
21
|
+
raise ConfigurationError, "Unsupported origin" unless Operations::Runner::ORIGINS.include?(origin.to_s)
|
|
22
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
|
|
23
|
+
raise ConfigurationError, "Expected a collection operation" unless definition.custom? && definition.scope == :collection
|
|
24
|
+
Contract.ensure_pool!(resource)
|
|
25
|
+
selection = Selections::Builder.new.revalidate(context: context, selection_ref: selection_ref, resource: resource)
|
|
26
|
+
values = collection_input(context, resource, definition, input)
|
|
27
|
+
targets = previews(context, resource, definition, selection, values, deadline: deadline)
|
|
28
|
+
raise Rejected.new("VALIDATION_FAILED", "base" => [ "preview is too large" ]) if JSON.generate(targets.map(&:changes)).bytesize > 262_144
|
|
29
|
+
source = Selection.find(selection.selection_ref)
|
|
30
|
+
manifest = selection.targets.map { |target| [ target.id, target.expected_version ] }
|
|
31
|
+
payload = wire(values)
|
|
32
|
+
raise Rejected.new("VALIDATION_FAILED", "base" => [ "input is too large" ]) if JSON.generate(payload).bytesize > 4096
|
|
33
|
+
intent = OperationRun.create!(id: SecureRandom.uuid, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
|
|
34
|
+
security_version: context.security_version, resource_key: resource.key.to_s, operation: definition.key,
|
|
35
|
+
operation_version: Operations::RunnerFingerprint.call(resource, definition), selection_id: selection.selection_ref,
|
|
36
|
+
selection_digest: selection.manifest_digest, manifest_resource_version: source.resource_version,
|
|
37
|
+
manifest_payload: manifest, input_payload: payload, input_digest: InputSchema.digest(values), origin: origin.to_s,
|
|
38
|
+
expires_at: Time.current + RailsAiAdmin.configuration.intent_ttl)
|
|
39
|
+
Proposal.new(intent, values, targets.freeze)
|
|
40
|
+
rescue Rejected => error
|
|
41
|
+
error.result
|
|
42
|
+
rescue AccessDenied => error
|
|
43
|
+
Result.new(code: error.code)
|
|
44
|
+
rescue StandardError => error
|
|
45
|
+
raise if error.is_a?(ConfigurationError)
|
|
46
|
+
Result.new(code: Contract.contention?(error) ? "CONFLICT" : "OPERATION_FAILED")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def admit(context:, intent_ref:, input:, human_confirmed: false, binding: nil)
|
|
50
|
+
admitted = false
|
|
51
|
+
Contract.ensure_outside_transaction!
|
|
52
|
+
result = OperationRun.transaction(requires_new: true) do
|
|
53
|
+
run = Contract.owned(context, intent_ref, lock: true)
|
|
54
|
+
resource, definition = Contract.definition(run)
|
|
55
|
+
verify_binding!(run, binding) if binding
|
|
56
|
+
Contract.verify_manifest!(run)
|
|
57
|
+
if run.status != "proposed"
|
|
58
|
+
Authorization::Access.new(context, resource).authorize_receipt!(run.operation, nil)
|
|
59
|
+
raise Rejected.new("CONFLICT") unless Contract::ACTIVE.include?(run.status) || (Contract::TERMINAL.include?(run.status) && run.approved_digest)
|
|
60
|
+
Contract.verify_approved!(run, definition, context)
|
|
61
|
+
verify_input!(run, definition, context, input, replay: true)
|
|
62
|
+
next Contract.result(run, replayed: true)
|
|
63
|
+
end
|
|
64
|
+
raise Rejected.new("APPROVAL_EXPIRED") unless run.expires_at > Time.current
|
|
65
|
+
raise Rejected.new("APPROVAL_REQUIRED") unless human_confirmed == true
|
|
66
|
+
values = collection_input(context, resource, definition, input)
|
|
67
|
+
raise Rejected.new("APPROVAL_INVALIDATED") unless InputSchema.digest(values) == run.input_digest
|
|
68
|
+
access = Authorization::Access.new(context, resource)
|
|
69
|
+
selection = Selections::Builder.new.validate_for_admission(context: context, selection_ref: run.selection_id, resource: resource, manifest_digest: run.selection_digest) do |record|
|
|
70
|
+
access.authorize!(definition.key, record)
|
|
71
|
+
current = InputSchema.new(definition.fields(access, record)).coerce(wire(values), time_zone: context.time_zone)
|
|
72
|
+
raise Rejected.new("APPROVAL_INVALIDATED") unless current.valid? && InputSchema.digest(current.values) == run.input_digest
|
|
73
|
+
end
|
|
74
|
+
raise Rejected.new("APPROVAL_INVALIDATED") unless selection.targets.map { |target| [ target.id, target.expected_version ] } == run.manifest_payload
|
|
75
|
+
deadline = Time.current + definition.handler_class.execution_window
|
|
76
|
+
source_items = SelectionItem.where(selection_id: run.selection_id).order(:position).limit(Selections::Builder::LIMIT + 1).to_a
|
|
77
|
+
unless source_items.map { |item| [ item.target_id, item.expected_version ] } == run.manifest_payload && source_items.map(&:position) == (0...source_items.size).to_a
|
|
78
|
+
raise Rejected.new("APPROVAL_INVALIDATED")
|
|
79
|
+
end
|
|
80
|
+
now = Time.current
|
|
81
|
+
children, items = [], []
|
|
82
|
+
source_items.each do |item|
|
|
83
|
+
id = SecureRandom.uuid
|
|
84
|
+
children << { id: id, parent_run_id: run.id, actor_ref: run.actor_ref, tenant_ref: run.tenant_ref,
|
|
85
|
+
security_version: run.security_version, resource_key: run.resource_key, operation: run.operation,
|
|
86
|
+
operation_version: run.operation_version, target_id: item.target_id, expected_version: item.expected_version,
|
|
87
|
+
input_digest: run.input_digest, origin: run.origin, expires_at: deadline, created_at: now, updated_at: now }
|
|
88
|
+
items << { id: SecureRandom.uuid, parent_run_id: run.id, operation_run_id: id, selection_item_id: item.id,
|
|
89
|
+
target_id: item.target_id, expected_version: item.expected_version, position: item.position, created_at: now, updated_at: now }
|
|
90
|
+
end
|
|
91
|
+
OperationRun.insert_all!(children)
|
|
92
|
+
BatchItem.insert_all!(items)
|
|
93
|
+
audit = AuditEvent.create!(actor_ref: run.actor_ref, tenant_ref: run.tenant_ref, resource_key: run.resource_key,
|
|
94
|
+
operation: run.operation, status: "admitted", code: "QUEUED", correlation_ref: run.id, origin: run.origin, changed_fields: [], created_at: now)
|
|
95
|
+
run.execution_deadline = deadline
|
|
96
|
+
run.update!(status: "queued", audit_event_id: audit.id, approved_by_ref: context.actor_ref,
|
|
97
|
+
approved_digest: Contract.approval_digest(run), confirmed_at: now, execution_deadline: deadline)
|
|
98
|
+
admitted = true
|
|
99
|
+
Contract.result(run)
|
|
100
|
+
end
|
|
101
|
+
result || Result.new(code: "CONFLICT")
|
|
102
|
+
rescue Rejected => error
|
|
103
|
+
error.result
|
|
104
|
+
rescue AccessDenied => error
|
|
105
|
+
Result.new(code: error.code)
|
|
106
|
+
rescue StandardError => error
|
|
107
|
+
if admitted && !OperationRun.connection.transaction_open?
|
|
108
|
+
begin
|
|
109
|
+
committed = OperationRun.find_by(id: intent_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
|
|
110
|
+
rescue ActiveRecord::ActiveRecordError
|
|
111
|
+
return Result.new(code: "OUTCOME_UNKNOWN", job_ref: intent_ref, status: "unknown")
|
|
112
|
+
end
|
|
113
|
+
if committed && committed.approved_digest && committed.status != "proposed"
|
|
114
|
+
return Contract.result(committed)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
raise if error.is_a?(ConfigurationError)
|
|
118
|
+
Result.new(code: Contract.contention?(error) ? "CONFLICT" : "OPERATION_FAILED")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cancel(context:, job_ref:)
|
|
122
|
+
cancelling = false
|
|
123
|
+
Contract.ensure_outside_transaction!
|
|
124
|
+
OperationRun.transaction(requires_new: true) do
|
|
125
|
+
run = Contract.owned(context, job_ref, lock: true)
|
|
126
|
+
resource, = Contract.definition(run)
|
|
127
|
+
Authorization::Access.new(context, resource).authorize_cancel!(run.operation)
|
|
128
|
+
changed = run.status == "proposed" || Contract::ACTIVE.include?(run.status)
|
|
129
|
+
if run.status == "proposed"
|
|
130
|
+
run.update!(status: "cancelled", cancellation_requested_at: Time.current, completed_at: Time.current)
|
|
131
|
+
elsif Contract::ACTIVE.include?(run.status)
|
|
132
|
+
run.update!(cancellation_requested_at: Time.current)
|
|
133
|
+
Contract.stop_remaining!(run, "CANCELLED")
|
|
134
|
+
end
|
|
135
|
+
if changed
|
|
136
|
+
AuditEvent.create!(actor_ref: context.actor_ref, tenant_ref: context.tenant_ref, resource_key: run.resource_key,
|
|
137
|
+
operation: run.operation, status: "cancelled", code: "CANCELLED", correlation_ref: run.id, origin: "ui", changed_fields: [], created_at: Time.current)
|
|
138
|
+
end
|
|
139
|
+
cancelling = changed
|
|
140
|
+
Result.new(code: "CANCELLED", job_ref: run.id, status: run.status)
|
|
141
|
+
end
|
|
142
|
+
rescue AccessDenied => error
|
|
143
|
+
Result.new(code: error.code)
|
|
144
|
+
rescue StandardError => error
|
|
145
|
+
if cancelling && !OperationRun.connection.transaction_open?
|
|
146
|
+
begin
|
|
147
|
+
committed = OperationRun.find_by(id: job_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
|
|
148
|
+
rescue ActiveRecord::ActiveRecordError
|
|
149
|
+
return Result.new(code: "OUTCOME_UNKNOWN", job_ref: job_ref, status: "unknown")
|
|
150
|
+
end
|
|
151
|
+
if committed&.cancellation_requested_at
|
|
152
|
+
return Result.new(code: "CANCELLED", job_ref: committed.id, status: committed.status)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
raise if error.is_a?(ConfigurationError)
|
|
156
|
+
Result.new(code: Contract.contention?(error) ? "CONFLICT" : "OPERATION_FAILED")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def verify_binding!(run, binding)
|
|
162
|
+
expected = { resource_key: run.resource_key, operation: run.operation, selection_id: run.selection_id }
|
|
163
|
+
raise AccessDenied unless binding.transform_values { |value| value&.to_s } == expected
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def verify_input!(run, definition, context, input, replay: false)
|
|
167
|
+
coercion = InputSchema.new(definition.handler_class.input).coerce(input, time_zone: context.time_zone)
|
|
168
|
+
raise Rejected.new(replay ? "IDEMPOTENCY_CONFLICT" : "APPROVAL_INVALIDATED") unless coercion.valid? && InputSchema.digest(coercion.values) == run.input_digest
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def collection_input(context, resource, definition, input)
|
|
172
|
+
ActiveRecord::Base.while_preventing_writes do
|
|
173
|
+
access = Authorization::Access.new(context, resource)
|
|
174
|
+
access.authorize!(definition.key)
|
|
175
|
+
coercion = InputSchema.new(definition.fields(access, nil)).coerce(input, time_zone: context.time_zone)
|
|
176
|
+
raise Rejected.new("VALIDATION_FAILED", coercion.errors) unless coercion.valid?
|
|
177
|
+
coercion.values
|
|
178
|
+
end
|
|
179
|
+
rescue ActiveRecord::ReadOnlyError
|
|
180
|
+
raise ConfigurationError, "Collection input resolution must be read-only", cause: nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def previews(context, resource, definition, selection, values, deadline:)
|
|
184
|
+
runner = Operations::Runner.new
|
|
185
|
+
selection.targets.map do |target|
|
|
186
|
+
raise Rejected.new("TIMEOUT") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
187
|
+
preview = runner.preview(context: context, resource: resource, operation: definition.key,
|
|
188
|
+
target_id: target.id, expected_version: target.expected_version, input: wire(values))
|
|
189
|
+
raise Rejected.new(preview.code, preview.errors) if preview.is_a?(Operations::Result)
|
|
190
|
+
raise Rejected.new("TIMEOUT") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
191
|
+
raise Rejected.new("APPROVAL_INVALIDATED") unless InputSchema.digest(preview.values) == InputSchema.digest(values)
|
|
192
|
+
Target.new(target.id, target.label, preview.changes)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def wire(values)
|
|
197
|
+
values.transform_values do |value|
|
|
198
|
+
case value
|
|
199
|
+
when BigDecimal then value.to_s("F")
|
|
200
|
+
when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
|
|
201
|
+
when Date then value.iso8601
|
|
202
|
+
else value
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|