plutonium 0.62.2 → 0.64.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 (256) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/.standard.yml +1 -1
  13. data/CHANGELOG.md +73 -0
  14. data/CLAUDE.md +87 -0
  15. data/Rakefile +34 -0
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +685 -102
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +53 -53
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/app/views/rodauth/_login_form.html.erb +13 -0
  23. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  24. data/docs/.vitepress/config.ts +81 -3
  25. data/docs/.vitepress/theme/blog.data.ts +44 -0
  26. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  27. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  28. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  29. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  30. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  31. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  32. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  33. data/docs/.vitepress/theme/index.ts +8 -4
  34. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  35. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  36. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  37. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  38. data/docs/blog/index.md +13 -0
  39. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  40. data/docs/blog/introducing-plutonium.md +253 -0
  41. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  42. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  43. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  44. data/docs/blog/two-forms-one-dom-id.md +69 -0
  45. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  46. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  47. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  48. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  49. data/docs/guides/authentication.md +73 -0
  50. data/docs/guides/authorization.md +2 -0
  51. data/docs/guides/creating-packages.md +5 -3
  52. data/docs/guides/custom-actions.md +74 -12
  53. data/docs/guides/customizing-ui.md +9 -2
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +7 -5
  56. data/docs/guides/nested-resources.md +11 -1
  57. data/docs/guides/performance.md +104 -0
  58. data/docs/guides/user-invites.md +1 -1
  59. data/docs/guides/wizards.md +9 -1
  60. data/docs/index.md +3 -3
  61. data/docs/public/images/home/tour-actions.png +0 -0
  62. data/docs/public/images/home/tour-async.png +0 -0
  63. data/docs/public/images/home/tour-kanban.png +0 -0
  64. data/docs/public/images/home/tour-tenancy.png +0 -0
  65. data/docs/public/images/home/tour-wizard.png +0 -0
  66. data/docs/public/images/reference/async-progress-page.png +0 -0
  67. data/docs/public/images/reference/async-running-banner.png +0 -0
  68. data/docs/public/templates/experimental.rb +34 -0
  69. data/docs/public/templates/pluton8.rb +14 -0
  70. data/docs/reference/app/portals.md +15 -3
  71. data/docs/reference/auth/accounts.md +19 -0
  72. data/docs/reference/behavior/async-interactions.md +295 -0
  73. data/docs/reference/behavior/controllers.md +17 -4
  74. data/docs/reference/behavior/index.md +7 -1
  75. data/docs/reference/behavior/interactions.md +152 -22
  76. data/docs/reference/configuration.md +5 -0
  77. data/docs/reference/index.md +1 -0
  78. data/docs/reference/kanban/dsl.md +7 -4
  79. data/docs/reference/kanban/index.md +1 -1
  80. data/docs/reference/kanban/positioning.md +26 -4
  81. data/docs/reference/positioning.md +568 -0
  82. data/docs/reference/resource/actions.md +97 -4
  83. data/docs/reference/resource/definition.md +181 -9
  84. data/docs/reference/tenancy/invites.md +1 -1
  85. data/docs/reference/tenancy/nested-resources.md +60 -2
  86. data/docs/reference/ui/assets.md +4 -0
  87. data/docs/reference/ui/components.md +57 -4
  88. data/docs/reference/ui/displays.md +20 -10
  89. data/docs/reference/ui/index.md +1 -1
  90. data/docs/reference/wizard/dsl.md +33 -0
  91. data/docs/reference/wizard/storage-config.md +1 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  93. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  95. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  97. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  98. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  99. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  100. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  101. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  102. data/gemfiles/postgres.gemfile.lock +85 -85
  103. data/gemfiles/rails_7.gemfile.lock +323 -141
  104. data/gemfiles/rails_8.0.gemfile.lock +126 -116
  105. data/gemfiles/rails_8.1.gemfile.lock +127 -117
  106. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  107. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  108. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  109. data/lib/generators/pu/invites/install_generator.rb +3 -3
  110. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  112. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  113. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  114. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  115. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  116. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  118. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  119. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  120. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  121. data/lib/plutonium/action/base.rb +71 -9
  122. data/lib/plutonium/action/interactive.rb +9 -0
  123. data/lib/plutonium/attachments.rb +254 -0
  124. data/lib/plutonium/configuration.rb +82 -1
  125. data/lib/plutonium/core/controller.rb +50 -7
  126. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  127. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  128. data/lib/plutonium/definition/base.rb +51 -0
  129. data/lib/plutonium/definition/display_layout.rb +112 -0
  130. data/lib/plutonium/definition/index_views.rb +8 -7
  131. data/lib/plutonium/definition/input_aliases.rb +38 -0
  132. data/lib/plutonium/definition/page_widths.rb +65 -0
  133. data/lib/plutonium/definition/positioning.rb +126 -0
  134. data/lib/plutonium/definition/sorting.rb +17 -2
  135. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  136. data/lib/plutonium/interaction/README.md +61 -24
  137. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  138. data/lib/plutonium/interaction/async/context.rb +419 -0
  139. data/lib/plutonium/interaction/async/executor.rb +422 -0
  140. data/lib/plutonium/interaction/async/job.rb +80 -0
  141. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  142. data/lib/plutonium/interaction/async/run.rb +394 -0
  143. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  144. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  145. data/lib/plutonium/interaction/base.rb +34 -7
  146. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  147. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  148. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  149. data/lib/plutonium/kanban/board.rb +14 -0
  150. data/lib/plutonium/kanban/column.rb +4 -2
  151. data/lib/plutonium/kanban/dsl.rb +4 -1
  152. data/lib/plutonium/kanban/grouping.rb +9 -22
  153. data/lib/plutonium/kanban/positioning.rb +5 -65
  154. data/lib/plutonium/positioning/config.rb +94 -0
  155. data/lib/plutonium/positioning/model.rb +128 -0
  156. data/lib/plutonium/positioning.rb +25 -86
  157. data/lib/plutonium/railtie.rb +1 -0
  158. data/lib/plutonium/resource/controller.rb +118 -38
  159. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  160. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  161. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  162. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  163. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  164. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  165. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  166. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  167. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  168. data/lib/plutonium/resource/policy.rb +33 -0
  169. data/lib/plutonium/resource/query_object.rb +36 -0
  170. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  171. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  172. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  173. data/lib/plutonium/testing/resource_policy.rb +6 -2
  174. data/lib/plutonium/ui/action_button.rb +12 -7
  175. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  176. data/lib/plutonium/ui/block.rb +21 -1
  177. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  178. data/lib/plutonium/ui/component/methods.rb +5 -0
  179. data/lib/plutonium/ui/component/positionable.rb +112 -0
  180. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  181. data/lib/plutonium/ui/component/section.rb +185 -0
  182. data/lib/plutonium/ui/display/base.rb +13 -1
  183. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  184. data/lib/plutonium/ui/display/components/section.rb +18 -0
  185. data/lib/plutonium/ui/display/resource.rb +141 -22
  186. data/lib/plutonium/ui/display/theme.rb +20 -1
  187. data/lib/plutonium/ui/export_button.rb +1 -1
  188. data/lib/plutonium/ui/form/base.rb +8 -7
  189. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  190. data/lib/plutonium/ui/form/components/section.rb +7 -62
  191. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  192. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  194. data/lib/plutonium/ui/form/query.rb +2 -4
  195. data/lib/plutonium/ui/form/resource.rb +92 -15
  196. data/lib/plutonium/ui/form/theme.rb +17 -0
  197. data/lib/plutonium/ui/form/wizard.rb +25 -1
  198. data/lib/plutonium/ui/grid/card.rb +79 -11
  199. data/lib/plutonium/ui/grid/resource.rb +47 -5
  200. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  201. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  202. data/lib/plutonium/ui/kanban/card.rb +2 -1
  203. data/lib/plutonium/ui/kanban/column.rb +12 -6
  204. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  205. data/lib/plutonium/ui/layout/base.rb +10 -3
  206. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  207. data/lib/plutonium/ui/page/base.rb +19 -0
  208. data/lib/plutonium/ui/page/edit.rb +4 -1
  209. data/lib/plutonium/ui/page/index.rb +69 -18
  210. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  211. data/lib/plutonium/ui/page/new.rb +4 -1
  212. data/lib/plutonium/ui/page/show.rb +27 -10
  213. data/lib/plutonium/ui/page/wizard.rb +10 -1
  214. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  215. data/lib/plutonium/ui/page_width.rb +58 -0
  216. data/lib/plutonium/ui/table/base.rb +34 -1
  217. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  218. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  219. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  220. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  221. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  222. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  223. data/lib/plutonium/ui/table/resource.rb +50 -5
  224. data/lib/plutonium/ui/table/theme.rb +59 -2
  225. data/lib/plutonium/ui/wizard/review.rb +4 -2
  226. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  227. data/lib/plutonium/version.rb +1 -1
  228. data/lib/plutonium/wizard/attachments.rb +32 -197
  229. data/lib/plutonium/wizard/base.rb +6 -1
  230. data/lib/plutonium/wizard/configuration.rb +12 -0
  231. data/lib/plutonium/wizard/controller.rb +14 -0
  232. data/lib/plutonium/wizard/driving.rb +99 -15
  233. data/lib/plutonium/wizard/dsl.rb +23 -0
  234. data/lib/plutonium/wizard/resume.rb +127 -49
  235. data/lib/plutonium/wizard/runner.rb +46 -2
  236. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  237. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  238. data/lib/plutonium.rb +21 -0
  239. data/lib/rodauth/features/session_isolation.rb +92 -0
  240. data/lib/rodauth/plugins.rb +1 -0
  241. data/package.json +2 -1
  242. data/plutonium.gemspec +20 -20
  243. data/src/css/components.css +89 -1
  244. data/src/css/slim_select.css +20 -0
  245. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  246. data/src/js/controllers/bulk_actions_controller.js +10 -2
  247. data/src/js/controllers/kanban_controller.js +30 -21
  248. data/src/js/controllers/positioned_controller.js +452 -0
  249. data/src/js/controllers/register_controllers.js +6 -0
  250. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  251. data/src/js/controllers/run_progress_controller.js +73 -0
  252. data/src/js/drag/sortable.js +186 -0
  253. data/yarn.lock +108 -63
  254. metadata +101 -22
  255. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  256. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -3,22 +3,40 @@ require "active_model"
3
3
  module Plutonium
4
4
  module Interaction
5
5
  # Base class for all interactions.
6
- # Provides core functionality for validations, execution, and result handling.
7
6
  #
8
- # @example
9
- # class MyInteraction < Plutonium::Interaction::Base
10
- # attribute :user_id, :integer
11
- # validates :user_id, presence: true
7
+ # An interaction is a *presentation object*: it declares the inputs for an
8
+ # operation, renders as a button and a form, is gated by a policy, and
9
+ # returns an {Plutonium::Interaction::Outcome} the controller turns into a
10
+ # flash message and a redirect. It provides validations (of input *shape*),
11
+ # execution, and result handling.
12
+ #
13
+ # It cannot be constructed without a +view_context:+ — which is precisely
14
+ # the boundary it marks. Logic may *start* in +#execute+; a one-off with a
15
+ # single caller is fine there, and pre-extracting is YAGNI. But the moment a
16
+ # second caller appears — a background job, an API controller, a rake task,
17
+ # the console — move the behaviour onto the model, Rails-style. Otherwise
18
+ # that caller must either duplicate it or manufacture a +view_context+ it
19
+ # has no business owning.
20
+ #
21
+ # @example The model owns what "confirming" means; the interaction presents it
22
+ # class ConfirmUserInteraction < Plutonium::Interaction::Base
23
+ # attribute :resource
24
+ # attribute :confirmed_at, :datetime, default: -> { Time.current }
25
+ #
26
+ # validates :confirmed_at, presence: true
12
27
  #
13
28
  # private
14
29
  #
15
30
  # def execute
16
- # user = User.find(user_id)
17
- # success(user)
31
+ # resource.confirm!(at: confirmed_at) # User#confirm! — reachable from a job too
32
+ # succeed(resource).with_message("User confirmed.")
33
+ # rescue ActiveRecord::RecordInvalid => e
34
+ # failed(e.record.errors)
18
35
  # end
19
36
  # end
20
37
  #
21
38
  # @note Subclasses must implement the #execute method.
39
+ # @see https://radioactive-labs.github.io/plutonium-core/reference/behavior/interactions
22
40
  class Base
23
41
  include ActiveModel::Model
24
42
  include ActiveModel::Attributes
@@ -27,6 +45,15 @@ module Plutonium
27
45
  include Plutonium::Definition::Presentable
28
46
  include Plutonium::Definition::StructuredInputs
29
47
  include Plutonium::Definition::FormLayout
48
+ include Plutonium::Definition::InheritableConfigAttr
49
+ # An interaction renders through the same Form::Resource a resource does,
50
+ # so it needs the same width knobs — `page_width` / `form_width`, falling
51
+ # back to `Plutonium.configuration.default_page_width`. Without this an
52
+ # interactive action rendered as a standalone page has no width at all.
53
+ include Plutonium::Definition::PageWidths
54
+ # `async SomeRun` — the interaction persists a run and enqueues it
55
+ # instead of doing the work inline. Inert until an interaction declares it.
56
+ include Plutonium::Interaction::Concerns::Dispatchable
30
57
 
31
58
  # On interactions, declaring a structured input also declares the backing
32
59
  # ActiveModel attribute so the value survives `attributes=` and appears in
@@ -0,0 +1,518 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Concerns
6
+ # Turns an interaction into a dispatcher: instead of doing the work
7
+ # inline, it persists a {Plutonium::Interaction::Async::Run}, enqueues it, and
8
+ # sends the user to it.
9
+ #
10
+ # The interaction keeps everything else it already was — it declares
11
+ # inputs, validates them, renders a form, and is gated by the same policy
12
+ # as before. Only +#execute+ changes.
13
+ #
14
+ # @example
15
+ # class Blogging::ArchivePosts < Plutonium::Resource::Interaction
16
+ # attribute :resources
17
+ # attribute :reason, :string
18
+ #
19
+ # async do
20
+ # on_failure :continue
21
+ # def perform_on(post) = post.archive!(reason: options["reason"])
22
+ # end
23
+ # end
24
+ #
25
+ # == The outcome stays synchronous
26
+ #
27
+ # +call+ still returns an Outcome immediately, because dispatching is what
28
+ # succeeded. Whether the WORK succeeds is the run's business, reported on
29
+ # the run's own page — which is why Outcome needs no third "pending" state.
30
+ #
31
+ # == Nothing is passed in at the call site
32
+ #
33
+ # Every value the run needs is already reachable: the targets are the
34
+ # +resource+/+resources+ the controller resolved through the policy scope,
35
+ # and the authorization triple comes from the controller the interaction
36
+ # is rendering in. So no controller and no host app has to hand a
37
+ # dispatching interaction anything an inline one does not already get.
38
+ #
39
+ # == What dispatch records, and why it is exactly this
40
+ #
41
+ # The job has no controller, so {Plutonium::Interaction::Async::Context}
42
+ # rebuilds the authorization context from the row. Three of the columns
43
+ # exist purely so that rebuild cannot silently widen:
44
+ #
45
+ # * +authorization_namespace+ — the portal's module NAME. Without it,
46
+ # perform-time lookup falls back to the base policy and every narrowing
47
+ # the portal applied is lost.
48
+ # * +policy_class_name+ — the policy dispatch ACTUALLY RESOLVED, never an
49
+ # inferred "#{Model}Policy". A namespaced portal and an STI fallback both
50
+ # make the inferred name wrong, and Context refuses to run when the
51
+ # recorded name and today's lookup disagree.
52
+ # * +policy_action+ — the predicate, so perform re-checks PERMISSION and
53
+ # not merely visibility.
54
+ module Dispatchable
55
+ extend ActiveSupport::Concern
56
+
57
+ # Raised when a dispatching interaction runs while async interactions are
58
+ # switched off. Named rather than a bare RuntimeError so a host can tell
59
+ # it apart from anything the interaction itself raised — and so it reads
60
+ # the same way as the interaction layer's other typed failures
61
+ # (Async::Context::UnresolvableError, Async::Executor::TargetRefusedError).
62
+ class NotEnabledError < StandardError; end
63
+
64
+ # The tenant is half of what the policy scope filters on, and this is
65
+ # where it comes from.
66
+ include Plutonium::Interaction::Concerns::Scoping
67
+
68
+ included do
69
+ # A class_attribute rather than a class ivar so a subclass of a
70
+ # dispatching interaction inherits the declaration.
71
+ class_attribute :run_class, instance_accessor: false
72
+
73
+ # Guarded on run_class: an interaction that executes inline hands its
74
+ # uploads straight to a model, whose own attachment validations run as
75
+ # normal. Only a dispatched one stages, and only a staged token can be
76
+ # validated ahead of the work.
77
+ validate :validate_dispatch_attachments, if: -> { self.class.run_class }
78
+ end
79
+
80
+ # Prepended, rather than defined via +define_method+, so a `def
81
+ # execute` written BELOW `async` can't silently shadow it —
82
+ # a prepended module sits ahead of the class in the ancestor chain
83
+ # regardless of source order. (A subclass's own #execute still wins,
84
+ # same as any override — this only closes same-class ordering.)
85
+ module ExecuteOverride
86
+ private
87
+
88
+ def execute = dispatch
89
+ end
90
+
91
+ class_methods do
92
+ # Declares that this interaction executes OUT OF BAND, in a job,
93
+ # instead of inline in the request.
94
+ #
95
+ # An interaction has exactly two ways to do its work, and this is the
96
+ # fork between them:
97
+ #
98
+ # def execute # inline, in the request
99
+ # async do ... # out of band, in a job
100
+ #
101
+ # == The block is the run's class body
102
+ #
103
+ # Not the body of #execute — the work happens later, in a process with
104
+ # no controller and no view_context, so it cannot be a closure over
105
+ # anything here. What the block declares is a
106
+ # {Plutonium::Interaction::Async::Run} subclass, with exactly the API a
107
+ # standalone run has: +on_failure+, and +perform_on(record)+ or
108
+ # +perform+.
109
+ #
110
+ # async do
111
+ # on_failure :continue
112
+ # def perform_on(post) = post.archive!(reason: options["reason"])
113
+ # end
114
+ #
115
+ # +def+ opens a fresh scope, so those bodies cannot accidentally close
116
+ # over the interaction's locals — which is what makes the block safe to
117
+ # write here rather than misleading.
118
+ #
119
+ # The generated class is NAMED, as +self::Run+, not left anonymous.
120
+ # That is load-bearing: the class name is persisted in the run's +type+
121
+ # column and constantized in another process, so an anonymous class
122
+ # would produce a row nothing can read back.
123
+ #
124
+ # == Passing a class instead
125
+ #
126
+ # A run shared by several interactions that do the same kind of work is
127
+ # declared once and named here:
128
+ #
129
+ # async Blogging::ArchivePostsRun
130
+ #
131
+ # @param run_class [Class<Plutonium::Interaction::Async::Run>, nil]
132
+ # @yield the run's class body, when no class is given
133
+ # @raise [ArgumentError] if given both a class and a block, or neither,
134
+ # or if this class already defines +#execute+ or its own +Run+
135
+ # @return [void]
136
+ def async(run_class = nil, &block)
137
+ if run_class && block
138
+ raise ArgumentError,
139
+ "#{self} declares async with both a run class and a block. The block IS " \
140
+ "a run class — pass one or the other."
141
+ end
142
+ unless run_class || block
143
+ raise ArgumentError,
144
+ "#{self} declares async with nothing to run. Pass a run class, or a block " \
145
+ "declaring one."
146
+ end
147
+
148
+ # Catches the OTHER ordering: #execute already defined before
149
+ # async runs is two conflicting declarations, not an
150
+ # ordering accident, and the prepend below does not resolve it.
151
+ if !@execute_defined_by_dispatch &&
152
+ (method_defined?(:execute, false) || private_method_defined?(:execute, false))
153
+ raise ArgumentError,
154
+ "#{self} declares async but also defines its own #execute, which async " \
155
+ "would overwrite. An interaction either executes inline or runs async — " \
156
+ "remove one of the two."
157
+ end
158
+
159
+ run_class ||= build_run_class(&block)
160
+ self.run_class = run_class
161
+
162
+ prepend(ExecuteOverride) unless @execute_defined_by_dispatch
163
+ @execute_defined_by_dispatch = true
164
+ end
165
+
166
+ private
167
+
168
+ # Defines +self::Run+ from the block.
169
+ #
170
+ # +const_set+ rather than an anonymous class: see #async. The
171
+ # collision check refuses to clobber a Run the author declared
172
+ # themselves, because silently replacing it would lose their work with
173
+ # no diagnostic — and because the two ways of declaring a run are meant
174
+ # to be alternatives, not layers.
175
+ def build_run_class(&block)
176
+ if const_defined?(:Run, false)
177
+ raise ArgumentError,
178
+ "#{self} already defines #{self}::Run, which async's block would replace. " \
179
+ "Either drop the block and pass the class, or remove the class."
180
+ end
181
+
182
+ const_set(:Run, Class.new(Plutonium::Interaction::Async::Run)).tap do |klass|
183
+ klass.class_eval(&block)
184
+ end
185
+ end
186
+ end
187
+
188
+ private
189
+
190
+ # Persists the run and returns the outcome that sends the user to it.
191
+ #
192
+ # The run enqueues ITS OWN job, via an +after_commit+ on
193
+ # {Plutonium::Interaction::Async::Run} — not from here, which would race a
194
+ # fast/inline job adapter against the very commit the row needs to
195
+ # be visible.
196
+ #
197
+ # @return [Plutonium::Interaction::Outcome::Success]
198
+ def dispatch
199
+ # The flag gates the MIGRATION, not just the behaviour: while it is off
200
+ # the runs migration path is never registered (see Plutonium::Railtie),
201
+ # so plutonium_async_runs does not exist. Without this the create!
202
+ # below surfaces as a raw "no such table" from inside ActiveRecord,
203
+ # which says nothing about the switch that has to be flipped.
204
+ unless Plutonium.configuration.async_interactions.enabled
205
+ raise NotEnabledError,
206
+ "#{self.class} dispatches to #{self.class.run_class}, but async interactions are not " \
207
+ "enabled. Set `config.async_interactions.enabled = true` in your Plutonium " \
208
+ "initializer, then run the migration."
209
+ end
210
+
211
+ run = self.class.run_class.create!(
212
+ initiator: current_user,
213
+ scoped_entity: current_scoped_entity,
214
+ # The third policy input. Recorded here rather than re-derived at
215
+ # perform time, where there is no controller to ask — same reason
216
+ # the tenant and the namespace are on the row.
217
+ parent: current_parent,
218
+ parent_association: current_nested_association&.to_s,
219
+ options: dispatch_options,
220
+ **dispatch_target_attributes
221
+ )
222
+
223
+ succeed(run).with_redirect_response(dispatch_redirect_target(run))
224
+ end
225
+
226
+ # Everything the interaction validated, minus the records themselves,
227
+ # in a shape that survives the trip to a job.
228
+ #
229
+ # The records are the TARGETS: they are stored as ids and re-resolved
230
+ # through the policy scope when the run performs. A serialized copy in
231
+ # options would be both stale and unauthorized.
232
+ #
233
+ # Two things have to happen to the rest, because options is a JSON column
234
+ # and JSON has neither files nor types:
235
+ #
236
+ # * An UPLOADED FILE is staged to its backend's cache and carried as the
237
+ # token — see {#stage_dispatch_attachments}. Left alone it serializes to
238
+ # a hash naming a tempfile the request deletes on its way out, so the
239
+ # run would receive the right filename and no bytes, with nothing
240
+ # raised anywhere.
241
+ # * TYPED VALUES go through ActiveJob::Arguments, which is Rails' own
242
+ # answer to this exact problem. Without it a Date arrives as "2026-08-19"
243
+ # and a BigDecimal as "12.34", so `options["amount"] * 2` quietly
244
+ # produces "12.3412.34". Primitives pass through verbatim, so the common
245
+ # case stays readable in the column and only what needs an envelope gets
246
+ # one — and a host can register its own serializers.
247
+ #
248
+ # @return [Hash]
249
+ def dispatch_options
250
+ # Staged during validation; this is a no-op for a caller that skipped it.
251
+ stage_dispatch_attachments!
252
+ ::ActiveJob::Arguments.serialize([attributes.except("resource", "resources")]).first
253
+ rescue ::ActiveJob::SerializationError => e
254
+ # Raised HERE, where the author can see which attribute they declared,
255
+ # rather than surviving into a row whose work fails deep in a job.
256
+ raise ArgumentError,
257
+ "#{self.class} cannot carry one of its attributes to a run: #{e.message}. " \
258
+ "A run's options are JSON, so every attribute has to be JSON-safe, an " \
259
+ "uploaded file, or a type ActiveJob knows how to serialize."
260
+ end
261
+
262
+ # Replaces uploaded files with the token their backend mints for them.
263
+ #
264
+ # The token IS what gets stored, verbatim — not wrapped in an envelope —
265
+ # so the column stays readable and the run's own +attachment+ reader is
266
+ # the one place that knows how to turn it back into a file. That split is
267
+ # deliberate: staging happens here, in the request, where the file is;
268
+ # reviving happens wherever the value is read, which may be a job.
269
+ #
270
+ # @return [Hash]
271
+ # Stages every uploaded file and WRITES THE TOKEN BACK onto the attribute.
272
+ #
273
+ # Writing back is what makes the form survive a re-render. An attribute
274
+ # still holding an ActionDispatch::Http::UploadedFile blows up the moment
275
+ # the form redraws — the file input renders previously-attached files and
276
+ # calls +url+ on the value, which an uploaded file does not answer. So any
277
+ # validation failure on ANY attribute took the whole page down with a
278
+ # NoMethodError. A staged token is a plain String, which is exactly what
279
+ # the input already knows how to redraw, and what a wizard holds for the
280
+ # same reason.
281
+ #
282
+ # Idempotent: a token is already a String, so a re-submit stages nothing
283
+ # a second time.
284
+ def stage_dispatch_attachments!
285
+ return if @dispatch_attachments_staged
286
+
287
+ attributes.except("resource", "resources").each do |name, value|
288
+ next unless dispatch_attachment?(value)
289
+
290
+ token = Plutonium::Attachments.stage_upload(value, **dispatch_attachment_options(name))
291
+ public_send(:"#{name}=", token)
292
+ end
293
+
294
+ @dispatch_attachments_staged = true
295
+ end
296
+
297
+ # A file field's +backend:+ and +uploader:+, read off the interaction's own
298
+ # +input+ declaration — the same options, in the same place, that a wizard
299
+ # step reads them from:
300
+ #
301
+ # attribute :import_file
302
+ # input :import_file, as: :uppy, uploader: Catalog::ImportUploader
303
+ #
304
+ # An interaction that declares no input for the attribute gets the
305
+ # configured backend and base Shrine, which is what it would have got
306
+ # before either option existed.
307
+ def dispatch_attachment_options(name)
308
+ options = self.class.defined_inputs.dig(name.to_sym, :options) || {}
309
+
310
+ {backend: options[:backend] || dispatch_attachment_backend, uploader: options[:uploader]}
311
+ end
312
+
313
+ # An uploaded file, or a collection of them. Detected by behaviour rather
314
+ # than by class so it covers ActionDispatch::Http::UploadedFile, Rack's
315
+ # multipart shape, and a bare IO an author assigned themselves.
316
+ def dispatch_attachment?(value)
317
+ return value.any? { |v| dispatch_attachment?(v) } if value.is_a?(Array)
318
+
319
+ value.respond_to?(:read) && value.respond_to?(:original_filename)
320
+ end
321
+
322
+ # Runs override the shared default, so an app can point runs at one
323
+ # backend without moving its wizards.
324
+ def dispatch_attachment_backend
325
+ Plutonium.configuration.async_interactions.attachment_backend ||
326
+ Plutonium::Attachments.default_backend
327
+ end
328
+
329
+ # Runs each file attribute's uploader validations, so a file that breaks
330
+ # them fails the FORM.
331
+ #
332
+ # Without this the interaction validates clean, dispatches, and the run
333
+ # fails in a job — the author's `validate_max_size` reported as a run
334
+ # failure the submitter never sees, on a page they have already left.
335
+ # Wizards reject at the step for the same reason; this is the interaction's
336
+ # equivalent of that.
337
+ #
338
+ # No-op for ActiveStorage fields, and for uploaders declaring no rules.
339
+ def validate_dispatch_attachments
340
+ staged = attributes.except("resource", "resources").select { |_, v| dispatch_attachment?(v) }.keys
341
+ stage_dispatch_attachments!
342
+
343
+ staged.each do |name|
344
+ messages = Plutonium::Attachments.validation_errors(
345
+ public_send(name), **dispatch_attachment_options(name)
346
+ )
347
+ messages.each { |message| errors.add(name, message) }
348
+ end
349
+ end
350
+
351
+ # Whether this interaction has a SUBJECT at all, decided by what it
352
+ # declared rather than by what the declaration currently holds.
353
+ #
354
+ # Emptiness cannot stand in for this. A bulk action whose ids all
355
+ # resolved to nothing produces an empty +resources+, and reading that as
356
+ # "no targets" would dispatch a run indistinguishable from genuinely
357
+ # opaque work — no target_type, no policy assertion, nothing for
358
+ # Async::Context to check. Asking what the class DECLARED separates the
359
+ # two cases exactly.
360
+ #
361
+ # @return [Boolean]
362
+ def dispatch_targeted?
363
+ attributes.key?("resources") || attributes.key?("resource")
364
+ end
365
+
366
+ # The records the run will act on, taken from whichever subject the
367
+ # controller bound — +resources+ for a bulk action, +resource+ for a
368
+ # record action. Both arrive already narrowed by the policy scope (see
369
+ # Plutonium::Resource::Controllers::InteractiveActions#interactive_bulk).
370
+ #
371
+ # @return [Array<ActiveRecord::Base>]
372
+ def dispatch_targets
373
+ @dispatch_targets ||= begin
374
+ records = Array(attributes["resources"] || attributes["resource"])
375
+ # Refused rather than downgraded to an opaque run. The likeliest
376
+ # cause is a bulk submission whose ids have all since been deleted or
377
+ # left the tenant, and "act on nothing" is not a sensible reading of
378
+ # it — the run would carry no policy assertion and then fail deep in
379
+ # a job with a message about a missing target_type.
380
+ if records.empty?
381
+ raise "#{self.class} declares a subject but resolved no records to act on. " \
382
+ "A targeted run over an empty set cannot record the policy it was " \
383
+ "dispatched under, and must not be silently downgraded to opaque work."
384
+ end
385
+
386
+ records
387
+ end
388
+ end
389
+
390
+ # The target half of the row — empty for opaque work.
391
+ #
392
+ # A run with no targets carries no target_type, and therefore neither a
393
+ # policy to pin nor a predicate to ask. Recording either anyway would be
394
+ # writing an assertion nothing ever checks.
395
+ #
396
+ # @return [Hash]
397
+ def dispatch_target_attributes
398
+ return dispatch_untargeted_attributes unless dispatch_targeted?
399
+
400
+ records = dispatch_targets
401
+ namespace = dispatch_authorization_namespace
402
+ target_class = dispatch_target_class
403
+
404
+ {
405
+ target_type: target_class.name,
406
+ target_ids: records.map(&:id),
407
+ progress_total: records.size,
408
+ authorization_namespace: namespace&.name,
409
+ policy_class_name: ::ActionPolicy.lookup(target_class, namespace: namespace).name,
410
+ policy_action: dispatch_policy_action.to_s
411
+ }
412
+ end
413
+
414
+ # What an OPAQUE run still records: which resource it concerns.
415
+ #
416
+ # It has no targets, so it carries no ids, no total and no policy to pin
417
+ # — there is nothing to re-resolve at perform time, which is the only
418
+ # thing those exist for.
419
+ #
420
+ # But target_type is not only about targets. Its stated job is the index
421
+ # feature, "runs for this resource" — Run.for_target is
422
+ # +where(target_type:)+, and it is what puts a run in the running banner.
423
+ # Left nil, an opaque run dispatched from a resource's index vanished the
424
+ # moment the user was sent back to that index: nothing on the page
425
+ # mentioned it, and the only way to find it was the Async Runs list.
426
+ #
427
+ # nil stays possible, and still means "no resource": an interaction
428
+ # dispatched outside a resource controller has none to name.
429
+ #
430
+ # @return [Hash]
431
+ def dispatch_untargeted_attributes
432
+ resource_class = dispatch_target_class
433
+ return {} unless resource_class
434
+
435
+ {target_type: resource_class.name}
436
+ end
437
+
438
+ # The RESOURCE class, not the class of the records.
439
+ #
440
+ # A bulk selection of STI rows resolves its policy per record when it
441
+ # performs (Async::Context#policy_for), but the SCOPE the run resolves
442
+ # targets through has to be the one dispatch authorized against. Reading
443
+ # it off the first record would narrow a mixed Post/Article selection to
444
+ # whichever type happened to come first, and silently report the rest as
445
+ # missing.
446
+ #
447
+ # @return [Class]
448
+ def dispatch_target_class
449
+ view_context.controller.helpers.resource_class
450
+ end
451
+
452
+ # ActionPolicy's own public accessor for the controller's module nesting
453
+ # (ActionPolicy::Behaviours::Namespaced). nil is legitimate and means
454
+ # "top level".
455
+ #
456
+ # @return [Module, nil]
457
+ def dispatch_authorization_namespace
458
+ view_context.controller.authorization_namespace
459
+ end
460
+
461
+ # The predicate to re-check per record, derived from the action's name
462
+ # exactly as
463
+ # Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!
464
+ # derives it — the two must agree, or perform would re-check a different
465
+ # question than dispatch asked.
466
+ #
467
+ # @return [Symbol]
468
+ def dispatch_policy_action
469
+ action = view_context.controller.helpers.current_interactive_action
470
+ # Refused here rather than at perform time: Async::Context will reject a
471
+ # targeted run with no predicate, but by then the diagnosis is a row in
472
+ # a failed job, hours from the code that wrote it.
473
+ unless action
474
+ raise "#{self.class} dispatches #{self.class.run_class} over #{dispatch_targets.size} target(s), " \
475
+ "but no interactive action is in flight, so there is no policy predicate to record. " \
476
+ "A targeted run without one cannot re-check permission when it performs."
477
+ end
478
+
479
+ :"#{action.name}?"
480
+ end
481
+
482
+ # Where dispatch sends the user: back where they came from, else the
483
+ # run's own page.
484
+ #
485
+ # +return_to+ wins because dispatching is not a destination. The user
486
+ # asked to archive the rows they had selected; landing them on a progress
487
+ # page means the list they were working is now two clicks away, and every
488
+ # index already surfaces its in-progress runs in a banner — so going back
489
+ # loses them nothing. A dispatch with no +return_to+ (a direct link, an
490
+ # API-ish caller) still needs somewhere to go, and the run's page is the
491
+ # only page that is about what just happened.
492
+ #
493
+ # Through +url_from+, which is what makes the parameter safe to honour:
494
+ # it returns nil for anything not same-origin, so a forged +return_to+
495
+ # cannot turn a bulk action into an open redirect.
496
+ #
497
+ # The fallback is resolved with +resource_url_for+, which is how every
498
+ # resource URL in this framework is built, and NOT by handing the record
499
+ # to Response::Redirect for a plain +url_for+. Under +:path+ entity
500
+ # scoping the route helper is entity-prefixed and takes the tenant as its
501
+ # first argument — +org_runs_path(org, run)+, not +runs_path(run)+ — and
502
+ # +url_for(record)+ derives the helper from the record's own route_key, so
503
+ # it cannot know about the prefix. Passing the bare record would therefore
504
+ # raise for the common entity-scoped case.
505
+ #
506
+ # Override to send them somewhere else.
507
+ #
508
+ # @return [String]
509
+ def dispatch_redirect_target(run)
510
+ controller = view_context.controller
511
+
512
+ controller.url_from(controller.params[:return_to]) ||
513
+ controller.helpers.resource_url_for(run)
514
+ end
515
+ end
516
+ end
517
+ end
518
+ end