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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Outbox
5
+ class DeliveryContext
6
+ attr_reader :record, :generation
7
+
8
+ def initialize(item, context, resource, record, authorization:)
9
+ @item, @context, @resource, @record, @authorization = item, context, resource, record, authorization
10
+ @generation, @used, @completed = item.generation, false, false
11
+ @effect_mutex = Mutex.new
12
+ end
13
+
14
+ %i[actor tenant actor_ref tenant_ref security_version time_zone].each do |method|
15
+ define_method(method) { @context.public_send(method) }
16
+ end
17
+
18
+ def delivery_key = "raa:work:#{@item.id}"
19
+ def effect_completed? = @completed
20
+
21
+ def assert_current!
22
+ current = WorkItem.where(id: @item.id, status: "running", lease_owner: @item.lease_owner, generation: generation)
23
+ .where("lease_expires_at > ?", Time.current).exists?
24
+ raise Dispatcher::LeaseLost unless current
25
+ true
26
+ end
27
+
28
+ def effect(request:)
29
+ @effect_mutex.synchronize do
30
+ raise ConfigurationError, "Each Work item permits exactly one effect" if @used
31
+ @used = true
32
+ end
33
+ raise ConfigurationError, "External effects cannot run in a database transaction" if WorkItem.connection.transaction_open?
34
+ digest = RequestDigest.call(request)
35
+ @context, @resource, @record = @authorization.call
36
+ owned = WorkItem.where(id: @item.id, status: "running", lease_owner: @item.lease_owner, generation: generation)
37
+ .where("lease_expires_at > ?", Time.current)
38
+ previous = owned.pick(:effect_digest)
39
+ raise Dispatcher::Blocked, "REQUEST_CHANGED" if previous && previous != digest
40
+ changed = ActiveRecord::Base.while_preventing_writes(false) do
41
+ owned.where("(effect_generation IS NULL OR effect_generation < ?) AND (effect_digest IS NULL OR effect_digest = ?)", generation, digest)
42
+ .update_all(effect_started_at: Time.current, effect_digest: digest, effect_generation: generation, updated_at: Time.current)
43
+ end
44
+ raise Dispatcher::LeaseLost if changed.zero?
45
+ result = yield self
46
+ @completed = true
47
+ result
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Outbox
5
+ # Fencing protects database receipts. Replaying external requests still needs
6
+ # the downstream system to honor the stable delivery key.
7
+ class Dispatcher
8
+ Result = Data.define(:work_ref, :status, :code) do
9
+ def success? = status == "succeeded"
10
+ end
11
+ class Blocked < StandardError
12
+ attr_reader :code
13
+ def initialize(code)
14
+ @code = code
15
+ super(code)
16
+ end
17
+ end
18
+ class LeaseLost < StandardError; end
19
+
20
+ def run(work_ref)
21
+ raise ConfigurationError, "Outbox delivery cannot run inside a database transaction" if WorkItem.connection.transaction_open?
22
+ item = claim(work_ref)
23
+ return item if item.is_a?(Result)
24
+ return Result.new(work_ref, "missing", "NOT_FOUND") unless item
25
+ definition = RailsAiAdmin.work_registry.fetch(item.kind)
26
+ authorization = -> { authorize(item) }
27
+ context, resource, record = ActiveRecord::Base.while_preventing_writes { authorization.call }
28
+ values = InputSchema.new(definition.handler_class.input).coerce(item.payload, time_zone: context.time_zone)
29
+ raise Blocked, "PAYLOAD_INVALID" unless values.valid? && InputSchema.digest(values.values) == item.payload_digest
30
+ delivery = DeliveryContext.new(item, context, resource, record, authorization: authorization)
31
+ ActiveRecord::Base.while_preventing_writes { definition.handler_class.new.perform(delivery, values.values) }
32
+ raise ConfigurationError, "Work must complete exactly one ctx.effect" unless delivery.effect_completed?
33
+ finish(item, "succeeded", "OK")
34
+ rescue AccessDenied
35
+ delivery&.effect_completed? ? finish(item, "succeeded", "POST_EFFECT_CALLBACK_FAILED") : fail_delivery(item, "AUTHORIZATION_REVOKED", denied: true)
36
+ rescue Blocked => error
37
+ delivery&.effect_completed? ? finish(item, "succeeded", "POST_EFFECT_CALLBACK_FAILED") : fail_delivery(item, error.code, reconcile: true)
38
+ rescue LeaseLost
39
+ fence_lost(item)
40
+ rescue StandardError
41
+ raise unless item.is_a?(WorkItem)
42
+ delivery&.effect_completed? ? finish(item, "succeeded", "POST_EFFECT_CALLBACK_FAILED") : fail_delivery(item, "DELIVERY_FAILED")
43
+ end
44
+
45
+ def scan_pending!(limit: 100, inline: false)
46
+ raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
47
+ now = Time.current
48
+ due = WorkItem.where(status: "pending").where("next_attempt_at IS NULL OR next_attempt_at <= ? OR deadline_at <= ?", now, now)
49
+ stale = WorkItem.where(status: "running").where("lease_expires_at IS NULL OR lease_expires_at <= ?", now)
50
+ ids = due.or(stale).order(:created_at, :id).limit(limit).pluck(:id)
51
+ ids.count do |id|
52
+ if inline
53
+ run(id)
54
+ true
55
+ else
56
+ !!DeliveryJob.perform_later(id)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def claim(work_ref)
64
+ WorkItem.transaction do
65
+ item = WorkItem.lock.find_by(id: work_ref)
66
+ next unless item
67
+ now = Time.current
68
+ unless %w[pending running].include?(item.status)
69
+ next Result.new(item.id, item.status, item.last_code || "TERMINAL")
70
+ end
71
+ if item.status == "running" && item.lease_expires_at && item.lease_expires_at > now
72
+ next Result.new(item.id, item.status, "IN_PROGRESS")
73
+ end
74
+ if item.deadline_at <= now
75
+ terminal = item.effect_started_at ? "reconciliation_required" : "failed"
76
+ item.update!(status: terminal, last_code: "WORK_EXPIRED", completed_at: now)
77
+ next Result.new(item.id, terminal, "WORK_EXPIRED")
78
+ end
79
+ if item.status == "pending" && item.next_attempt_at && item.next_attempt_at > now
80
+ next Result.new(item.id, item.status, "BACKOFF")
81
+ end
82
+ begin
83
+ definition = RailsAiAdmin.work_registry.fetch(item.kind)
84
+ valid = item.handler_version == definition.fingerprint
85
+ rescue ConfigurationError, NameError
86
+ valid = false
87
+ end
88
+ unless valid
89
+ item.update!(status: "reconciliation_required", last_code: "HANDLER_CHANGED", completed_at: now)
90
+ next Result.new(item.id, item.status, item.last_code)
91
+ end
92
+ if item.effect_started_at && !definition.handler_class.idempotent
93
+ item.update!(status: "reconciliation_required", last_code: "OUTCOME_UNKNOWN", completed_at: now)
94
+ next Result.new(item.id, item.status, item.last_code)
95
+ end
96
+ if item.attempts >= definition.handler_class.max_attempts
97
+ item.update!(status: item.effect_started_at ? "reconciliation_required" : "failed", last_code: "ATTEMPTS_EXHAUSTED", completed_at: now)
98
+ next Result.new(item.id, item.status, item.last_code)
99
+ end
100
+ item.update!(status: "running", lease_owner: SecureRandom.uuid, generation: item.generation + 1,
101
+ attempts: item.attempts + 1, lease_expires_at: now + definition.handler_class.lease_duration,
102
+ next_attempt_at: nil, last_code: nil)
103
+ item
104
+ end
105
+ rescue ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout
106
+ Result.new(work_ref, "pending", "CONFLICT")
107
+ rescue ActiveRecord::StatementInvalid => error
108
+ raise unless defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException)
109
+ Result.new(work_ref, "pending", "CONFLICT")
110
+ end
111
+
112
+ def authorize(item)
113
+ begin
114
+ work = RailsAiAdmin.work_registry.fetch(item.kind)
115
+ valid = work.fingerprint == item.handler_version
116
+ rescue ConfigurationError, NameError
117
+ valid = false
118
+ end
119
+ raise Blocked, "HANDLER_CHANGED" unless valid
120
+ run = OperationRun.find(item.operation_run_id)
121
+ raise Blocked, "OPERATION_NOT_COMMITTED" unless run.status == "succeeded"
122
+ resolver = RailsAiAdmin.configuration.context_adapter&.constantize&.new
123
+ raise Blocked, "JOB_CONTEXT_NOT_CONFIGURED" unless resolver&.respond_to?(:resolve_job)
124
+ context = resolver.resolve_job(actor_ref: run.actor_ref, tenant_ref: run.tenant_ref, security_version: run.security_version)
125
+ unless context.is_a?(Context) && context.actor_ref == run.actor_ref && context.tenant_ref == run.tenant_ref && context.security_version == run.security_version
126
+ raise AccessDenied
127
+ end
128
+ resource = RailsAiAdmin.registry.fetch(run.resource_key)
129
+ unless resource.model_class.connection_pool.equal?(WorkItem.connection_pool) && OperationRun.connection_pool.equal?(WorkItem.connection_pool)
130
+ raise Blocked, "CONNECTION_CHANGED"
131
+ end
132
+ definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
133
+ raise Blocked, "OPERATION_CHANGED" unless run.operation_version == Operations::RunnerFingerprint.call(resource, definition)
134
+ access = Authorization::Access.new(context, resource)
135
+ record = access.relation.find_by(resource.model_class.primary_key => run.record_id)
136
+ raise AccessDenied unless record
137
+ access.authorize_delivery!(run.operation, item.kind, record)
138
+ policy = RailsAiAdmin.work_registry.fetch(item.kind).handler_class.record_version_policy
139
+ raise Blocked, "RECORD_CHANGED" if policy == :exact && item.expected_version != RecordVersion.of(record)
140
+ # The admission TTL has been consumed. Delivery has its own deadline.
141
+ raise Blocked, "WORK_EXPIRED" unless item.deadline_at > Time.current
142
+ [ context, resource, record ]
143
+ end
144
+
145
+ def owned(item)
146
+ WorkItem.where(id: item.id, status: "running", lease_owner: item.lease_owner, generation: item.generation)
147
+ end
148
+
149
+ def finish(item, status, code)
150
+ changed = owned(item).where("lease_expires_at > ?", Time.current).update_all(status: status, last_code: code,
151
+ lease_owner: nil, lease_expires_at: nil, completed_at: Time.current, updated_at: Time.current)
152
+ changed.zero? ? fence_lost(item) : Result.new(item.id, status, code)
153
+ end
154
+
155
+ def fail_delivery(item, code, denied: false, reconcile: false)
156
+ return Result.new(nil, "missing", "NOT_FOUND") unless item.is_a?(WorkItem)
157
+ current = WorkItem.find(item.id)
158
+ unknown = current.effect_started_at.present?
159
+ definition = RailsAiAdmin.work_registry.fetch(item.kind)
160
+ terminal = if reconcile || (unknown && (denied || !definition.handler_class.idempotent))
161
+ "reconciliation_required"
162
+ elsif denied
163
+ "cancelled"
164
+ elsif current.attempts >= definition.handler_class.max_attempts
165
+ unknown ? "reconciliation_required" : "failed"
166
+ else
167
+ "pending"
168
+ end
169
+ if terminal == "pending"
170
+ changed = owned(item).where("lease_expires_at > ?", Time.current).update_all(status: terminal, last_code: code,
171
+ next_attempt_at: Time.current + [ 2**current.attempts, 300 ].min, lease_owner: nil, lease_expires_at: nil, updated_at: Time.current)
172
+ changed.zero? ? fence_lost(item) : Result.new(item.id, terminal, code)
173
+ else
174
+ finish(item, terminal, code)
175
+ end
176
+ end
177
+
178
+ def fence_lost(item)
179
+ return Result.new(nil, "missing", "NOT_FOUND") unless item.is_a?(WorkItem)
180
+ # Only this expired generation may become uncertain. Never overwrite a
181
+ # newer worker's pending/success state with a stale response.
182
+ owned(item).where("lease_expires_at <= ?", Time.current).where.not(effect_started_at: nil).update_all(
183
+ status: "reconciliation_required", last_code: "OUTCOME_UNKNOWN", completed_at: Time.current, updated_at: Time.current)
184
+ current = WorkItem.find_by(id: item.id)
185
+ Result.new(item.id, current&.status || "missing", "FENCED")
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Outbox
5
+ class Registry
6
+ Definition = Data.define(:kind, :handler_name) do
7
+ def handler_class
8
+ klass = handler_name.constantize
9
+ raise ConfigurationError, "Work handler must inherit RailsAiAdmin::Work" unless klass.is_a?(Class) && klass < Work
10
+ klass
11
+ end
12
+
13
+ def fingerprint
14
+ InputSchema.digest(handler: handler_name, version: handler_class.version, fields: handler_class.input.map(&:contract),
15
+ idempotent: handler_class.idempotent, lease: handler_class.lease_duration, attempts: handler_class.max_attempts,
16
+ max_age: handler_class.max_age, record_version: handler_class.record_version_policy)
17
+ end
18
+ end
19
+
20
+ def initialize(handlers)
21
+ @entries = handlers.to_h do |kind, name|
22
+ definition = Definition.new(kind.to_s, name)
23
+ definition.handler_class.validate!
24
+ [ kind.to_s, definition ]
25
+ end.freeze
26
+ freeze
27
+ end
28
+
29
+ def fetch(kind)
30
+ @entries.fetch(kind.to_s) { raise ConfigurationError, "Unregistered outbox work kind" }
31
+ end
32
+
33
+ def definitions = @entries.values
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Outbox
5
+ # Unlike typed input digests, request fingerprints have no type-marker domain.
6
+ # Accept only plain JSON data and reject keys that collide after normalization.
7
+ module RequestDigest
8
+ def self.call(request)
9
+ raise ConfigurationError, "Effect request must be an object" unless request.is_a?(Hash)
10
+ body = JSON.generate(normalize(request))
11
+ raise ConfigurationError, "Effect request exceeds 16384 bytes" if body.bytesize > 16_384
12
+ Digest::SHA256.hexdigest(body)
13
+ rescue JSON::GeneratorError
14
+ raise ConfigurationError, "Effect request must contain valid JSON data", cause: nil
15
+ end
16
+
17
+ def self.normalize(value, depth = 0)
18
+ raise ConfigurationError, "Effect request nesting is too deep" if depth > 8
19
+ case value
20
+ when Hash
21
+ normalized = {}
22
+ value.each do |key, entry|
23
+ unless key.is_a?(String) || key.is_a?(Symbol)
24
+ raise ConfigurationError, "Effect request keys must be strings or symbols"
25
+ end
26
+ name = key.to_s
27
+ raise ConfigurationError, "Effect request has duplicate normalized keys" if normalized.key?(name)
28
+ normalized[name] = normalize(entry, depth + 1)
29
+ end
30
+ normalized.sort.to_h
31
+ when Array then value.map { |entry| normalize(entry, depth + 1) }
32
+ when String, Integer, TrueClass, FalseClass, NilClass then value
33
+ when Float
34
+ raise ConfigurationError, "Effect request numbers must be finite" unless value.finite?
35
+ value
36
+ else
37
+ raise ConfigurationError, "Serialize typed values to plain JSON scalars before effect admission"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Outbox
5
+ # Persists intent in Runner's transaction; never contacts a queue/provider.
6
+ class Writer
7
+ def initialize(context, run, resource)
8
+ @context, @run, @resource = context, run, resource
9
+ end
10
+
11
+ def enqueue(kind, key: kind, input: nil, **attributes)
12
+ unless OperationRun.connection.transaction_open? && @run.status == "running" && @run.actor_ref == @context.actor_ref && @run.tenant_ref == @context.tenant_ref
13
+ raise ConfigurationError, "Outbox enqueue requires the active operation transaction"
14
+ end
15
+ raise ConfigurationError, "Work key must be a bounded string" unless key.is_a?(String) && key.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,99}\z/)
16
+ raise ConfigurationError, "Use input or keyword attributes, not both" if input && attributes.any?
17
+ definition = RailsAiAdmin.work_registry.fetch(kind)
18
+ result = InputSchema.new(definition.handler_class.input).coerce(input || attributes, time_zone: @context.time_zone)
19
+ raise ConfigurationError, "Invalid outbox payload" unless result.valid?
20
+ payload = result.values.transform_values do |value|
21
+ case value
22
+ when BigDecimal then value.to_s("F")
23
+ when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
24
+ when Date then value.iso8601
25
+ else value
26
+ end
27
+ end
28
+ raise ConfigurationError, "Outbox payload exceeds 4096 bytes" if JSON.generate(payload).bytesize > 4096
29
+ digest = InputSchema.digest(result.values)
30
+ existing = WorkItem.find_by(operation_run_id: @run.id, work_key: key)
31
+ if existing
32
+ unless existing.kind == kind.to_s && existing.payload_digest == digest && existing.handler_version == definition.fingerprint
33
+ raise AccessDenied, "IDEMPOTENCY_CONFLICT"
34
+ end
35
+ return existing.id
36
+ end
37
+ WorkItem.create!(id: SecureRandom.uuid, operation_run_id: @run.id, work_key: key,
38
+ kind: kind.to_s, payload: payload, payload_digest: digest, handler_version: definition.fingerprint,
39
+ deadline_at: Time.current + definition.handler_class.max_age).id
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # Builds readonly has_many panels for one parent record. Children come only
6
+ # from the child resource's current policy relation, narrowed by the child's
7
+ # own belongs_to column; nothing here writes or widens a grant.
8
+ module AssociationPanel
9
+ MAX_PANELS = 10
10
+ MAX_LIMIT = 50
11
+ NAME = /\A[a-z][a-z0-9_]*\z/
12
+ Row = Data.define(:id, :label, :linked)
13
+ # list_filter is the canonical list filter for "view all", present only
14
+ # while the child's reference field carries a current filter grant.
15
+ # list_params is the complete list state for that link: like the panel,
16
+ # it shows every child, not the child's default list tab (RAA-006).
17
+ # unscoped_list is true when the child has a granted default list tab.
18
+ # new_prefill is the prefill for the child's new form when the actor may
19
+ # create a child with a writable reference back to this record.
20
+ Panel = Data.define(:name, :label, :resource_key, :rows, :more, :list_filter, :unscoped_list, :single, :new_prefill) do
21
+ def list_params
22
+ return unless list_filter
23
+ unscoped_list ? { filter: list_filter, scope: Queries::NamedScope::NONE } : { filter: list_filter }
24
+ end
25
+ end
26
+
27
+ Definition = Data.define(:name, :resource, :explicit_via, :label, :limit, :single) do
28
+ def self.build(name, resource:, via:, label:, limit:, single: false)
29
+ names = [ name, resource ] + (via.nil? ? [] : [ via ])
30
+ unless names.all? { |value| (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.match?(NAME) && value.to_s.length <= 100 } &&
31
+ (label.nil? || label.is_a?(String) && label.valid_encoding? && label.length.between?(1, 80)) &&
32
+ limit.is_a?(Integer) && limit.between?(1, MAX_LIMIT)
33
+ raise ConfigurationError, "has_many needs stable names, an optional short plain label and a limit of 1..#{MAX_LIMIT}"
34
+ end
35
+ new(name.to_sym, resource.to_sym, via&.to_sym, label&.dup&.freeze, single ? 1 : limit, single)
36
+ end
37
+
38
+ # Defaults to the parent model's underscored name, as Rails does.
39
+ def via(parent) = explicit_via || parent.model.to_s.demodulize.underscore.to_sym
40
+ def caption = label || name.to_s.humanize
41
+ end
42
+
43
+ class << self
44
+ def build(context, parent, record)
45
+ parent.has_many_declarations.filter_map { |definition| panel(context, parent, record, definition) }
46
+ end
47
+
48
+ private
49
+
50
+ def panel(context, parent, record, definition)
51
+ child = RailsAiAdmin.registry.fetch(definition.resource)
52
+ access = Authorization::Access.new(context, child)
53
+ access.authorize!(:index)
54
+ field = child.fields.find { |candidate| candidate.name == definition.via(parent) }
55
+ target = Fields::Association.validate!(child, field)
56
+ unless target.model_class == parent.model_class
57
+ raise ConfigurationError, "has_many #{definition.name} must point back to the #{parent.key} model"
58
+ end
59
+ # Listing children by parent reveals each child's reference value.
60
+ return unless access.fields(:read, view: :index).include?(field)
61
+ model = child.model_class
62
+ records = access.relation.where(field.column => record.id)
63
+ .reorder(model.arel_table[model.primary_key].asc).limit(definition.limit + 1).to_a
64
+ rows = records.first(definition.limit).map do |child_record|
65
+ linked = begin
66
+ access.authorize!(:show, child_record)
67
+ rescue AccessDenied
68
+ false
69
+ end
70
+ Row.new(child_record.id.to_s.freeze, access.record_label(child_record, view: :index).freeze, linked)
71
+ end
72
+ filter = access.fields(:filter).include?(field) ? { field.name.to_s => { "eq" => record.id.to_s } }.freeze : nil
73
+ Panel.new(definition.name, definition.caption, child.key, rows.freeze, records.size > definition.limit, filter,
74
+ access.named_scopes.any?(&:default?), definition.single, creatable(access, field, record))
75
+ rescue AccessDenied
76
+ nil
77
+ end
78
+
79
+ def creatable(access, field, record)
80
+ child = access.relation.new
81
+ access.authorize!(:create, child)
82
+ return unless access.fields(:write, child, operation: :create).include?(field)
83
+ { field.name.to_s => record.id.to_s }.freeze
84
+ rescue AccessDenied
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # Recent audit events across the resources whose view_audit_log? grant
6
+ # this actor holds, for the current tenant only. Field names follow each
7
+ # resource's current read and write grants; the rest are counted.
8
+ class AuditLog
9
+ PAGE_SIZE = 50
10
+ STATUSES = %w[succeeded rejected].freeze
11
+ Entry = Data.define(:id, :at, :resource, :record_id, :operation, :status, :code, :origin, :actor_ref, :fields, :hidden_fields)
12
+ Page = Data.define(:entries, :older)
13
+ CURSOR = /\A[1-9][0-9]{0,18}\z/
14
+
15
+ attr_reader :resources
16
+
17
+ def initialize(context)
18
+ @context = context
19
+ @resources = RailsAiAdmin.registry.resources.select do |resource|
20
+ Authorization::Access.new(context, resource).authorize_audit_log!
21
+ rescue AccessDenied
22
+ false
23
+ end
24
+ raise AccessDenied if @resources.empty?
25
+ end
26
+
27
+ # Filters are exact values; unknown ones are an invalid query.
28
+ def page(resource: nil, operation: nil, status: nil, actor: nil, before: nil)
29
+ keys = @resources.map { |entry| entry.key.to_s }
30
+ valid = (resource.nil? || keys.include?(resource)) && (status.nil? || STATUSES.include?(status)) &&
31
+ (operation.nil? || operation.is_a?(String) && operation.match?(/\A[a-z][a-z0-9_]{0,63}\z/)) &&
32
+ (actor.nil? || actor.is_a?(String) && actor.bytesize <= 150 && !actor.match?(/[[:cntrl:]]/)) &&
33
+ (before.nil? || before.is_a?(String) && before.match?(CURSOR))
34
+ raise AccessDenied, "INVALID_QUERY" unless valid
35
+ scope = AuditEvent.where(tenant_ref: @context.tenant_ref, resource_key: resource || keys)
36
+ scope = scope.where(operation: operation) if operation
37
+ scope = scope.where(status: status) if status
38
+ scope = scope.where(actor_ref: actor) if actor
39
+ scope = scope.where(AuditEvent.arel_table[:id].lt(Integer(before, 10))) if before
40
+ events = scope.order(id: :desc).limit(PAGE_SIZE + 1).to_a
41
+ by_key = @resources.index_by { |entry| entry.key.to_s }
42
+ labels = Hash.new { |hash, key| hash[key] = visible_labels(by_key.fetch(key)) }
43
+ entries = events.first(PAGE_SIZE).map do |event|
44
+ names = Array(event.changed_fields).grep(String)
45
+ shown = names.filter_map { |name| labels[event.resource_key][name] }
46
+ Entry.new(event.id, event.created_at, by_key.fetch(event.resource_key), event.record_id, event.operation, event.status,
47
+ event.code, event.origin, event.actor_ref, shown.freeze, names.size - shown.size)
48
+ end
49
+ Page.new(entries.freeze, events.size > PAGE_SIZE ? entries.last.id : nil)
50
+ end
51
+
52
+ private
53
+
54
+ def visible_labels(resource)
55
+ access = Authorization::Access.new(@context, resource)
56
+ fields = access.fields(:read) | access.fields(:write, operation: :update)
57
+ fields.to_h { |field| [ field.name.to_s, field.label ] }
58
+ rescue AccessDenied
59
+ {}
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Presentation
5
+ # The current list (search, list tab, filters and sort) as CSV, through
6
+ # the same grants as the pages: rows from the index relation and each
7
+ # record's show grant, columns and values from its read grant.
8
+ class CsvExport
9
+ BATCH = 500
10
+ # A spreadsheet runs a cell that starts with these as a formula
11
+ # (CSV injection); such cells get a leading apostrophe.
12
+ FORMULA = /\A[=+\-@\t\r]/
13
+ # Number and time text is produced by coercion and cannot be a formula.
14
+ NUMERIC = %i[integer decimal date datetime time].freeze
15
+ Result = Data.define(:body, :rows, :fields, :too_many)
16
+
17
+ def initialize(context, resource, limit: RailsAiAdmin.configuration.export_limit)
18
+ @context, @resource, @limit = context, resource, limit
19
+ @access = Authorization::Access.new(context, resource)
20
+ end
21
+
22
+ def call(search: nil, sort: nil, direction: "asc", scope: nil, filters: nil)
23
+ @access.authorize_export!
24
+ relation = Queries::List.new(@context, @resource).relation(search: search, sort: sort, direction: direction, scope: scope, filters: filters)
25
+ return Result.new(nil, 0, [], true) if relation.except(:order).limit(@limit + 1).count > @limit
26
+ fields = @access.fields(:read, nil, view: :show)
27
+ references = Fields::Association.new(@context, @resource)
28
+ lines = [ line(fields.map(&:label)) ]
29
+ # One query per list field and batch instead of one per row.
30
+ lists = fields.select { |field| field.scalar_type == :references }.map(&:name)
31
+ relation = relation.preload(*lists) if lists.any?
32
+ rows = 0
33
+ offset = 0
34
+ loop do
35
+ records = relation.offset(offset).limit(BATCH).to_a
36
+ break if records.empty?
37
+ offset += records.size
38
+ fields.select { |field| field.scalar_type == :belongs_to }.each do |field|
39
+ references.prime(field, records.filter_map { |record| record.has_attribute?(field.column) ? record.read_attribute(field.column)&.to_s : nil })
40
+ end
41
+ records.each do |record|
42
+ values = @access.project(record, view: :show)
43
+ lines << line(fields.map { |field| values.key?(field.name.to_s) ? cell(field, values[field.name.to_s], references) : "" })
44
+ rows += 1
45
+ rescue AccessDenied
46
+ # Left out like an unavailable row on the list page (RAA-001).
47
+ next
48
+ end
49
+ break if records.size < BATCH || offset >= @limit
50
+ end
51
+ # The byte order mark lets spreadsheet programs read UTF-8.
52
+ Result.new("#{lines.join}", rows, fields.map { |field| field.name.to_s }, false)
53
+ end
54
+
55
+ private
56
+
57
+ def cell(field, value, references)
58
+ return "" if value.nil?
59
+ text = if field.plugin?
60
+ field.format(value, time_zone: @context.time_zone)
61
+ else
62
+ case field.scalar_type
63
+ when :date then value.iso8601
64
+ when :datetime then value.in_time_zone(@context.time_zone).iso8601
65
+ when :decimal then field.format_decimal(value)
66
+ when :json then JSON.generate(value)
67
+ when :file then value.filename
68
+ when :files then Array(value).map(&:filename).join("; ")
69
+ when :references
70
+ references.prime(field, Array(value))
71
+ Array(value).map { |id| references.reference(field, id).label.to_s }.join("; ")
72
+ when :belongs_to then references.reference(field, value.to_s).label.to_s
73
+ else field.format(value, time_zone: @context.time_zone)
74
+ end
75
+ end
76
+ text = text.to_s
77
+ !NUMERIC.include?(field.scalar_type) && text.match?(FORMULA) ? "'#{text}" : text
78
+ end
79
+
80
+ def line(cells)
81
+ "#{cells.map { |cell| %("#{cell.to_s.gsub('"', '""')}") }.join(',')}\r\n"
82
+ end
83
+ end
84
+ end
85
+ end