rails_ai_admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,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