plutonium 0.62.2 → 0.63.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 (255) 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/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,295 @@
1
+ # Async Interactions
2
+
3
+ ::: warning Experimental
4
+ Async interactions are experimental — the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ For the task-oriented walkthrough — declaring the action, its policy, and where it appears — start with the [Custom actions guide](/guides/custom-actions). This page is the reference.
8
+
9
+ `async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it."
10
+
11
+ Reach for it once the work stops being something a user can reasonably wait on: archiving ten thousand records, generating a report, calling a third party that takes its time. The run outlives the request, gets a progress page for free, and stays queryable after the fact.
12
+
13
+ ## 🚨 Critical
14
+
15
+ - **Opt-in and migrated.** `config.async_interactions.enabled = true` + `rails db:migrate` (off by default).
16
+ - **`async` replaces `execute` entirely.** The interaction still validates, authorizes and renders its form exactly as before; only what happens on submit changes.
17
+ - **Permissions are re-derived at perform time, never replayed from dispatch.** A run created a job, not a snapshot of "what the initiator could do then." See [Authorization](#authorization-is-re-derived-not-replayed).
18
+ - **The run itself is a resource.** Register it once per portal (`rails g pu:async_interactions:install --dest=your_portal`); its show page IS its progress page.
19
+ - **A stalled run does not silently replay.** `pu:async_interactions:install` schedules `Async::ReapJob` when Solid Queue is in the bundle; on any other scheduler you must add it yourself, or a crash mid-batch leaves the row `"running"` forever. See [Stalled runs](#stalled-runs-and-reapjob).
20
+
21
+ ## Enabling
22
+
23
+ ```ruby
24
+ # config/initializers/plutonium.rb
25
+ Plutonium.configure do |config|
26
+ config.async_interactions.enabled = true
27
+ config.async_interactions.queue = :default # ActiveJob queue for run jobs
28
+ config.async_interactions.stall_after = 1.hour # see Stalled runs, below
29
+ end
30
+ ```
31
+
32
+ ```bash
33
+ rails db:migrate # creates plutonium_async_runs
34
+ ```
35
+
36
+ The flag gates the migration, not just the behaviour: while it is off the runs migration path is never registered, so the table does not exist. A `async` interaction that runs anyway raises `Plutonium::Interaction::Concerns::Dispatchable::NotEnabledError` naming the flag, rather than a raw "no such table" from inside ActiveRecord.
37
+
38
+ ## Declaring the work
39
+
40
+ `async` takes a block, and the block is the run's class body. Define `perform_on(record)` for **targeted** work (bulk/record actions, one call per target) or `perform` for **opaque** work (resource actions with no subject):
41
+
42
+ ```ruby
43
+ class Blogging::ArchivePosts < ResourceInteraction
44
+ presents label: "Archive", icon: Phlex::TablerIcons::Archive
45
+ attribute :resources # bulk — perform_on runs once per record
46
+ attribute :reason, :string
47
+
48
+ async do
49
+ on_failure :continue # :halt (default) | :continue | :transactional
50
+
51
+ def perform_on(post)
52
+ post.archive!(reason: options["reason"])
53
+ end
54
+ end
55
+ end
56
+ ```
57
+
58
+ One class. There is no second file, and no name to invent for the run.
59
+
60
+ ### Why the block declares `perform_on` rather than executing
61
+
62
+ The block is **not** the body of `#execute`. The work happens later, in a job, in a process with no controller, no request and no `view_context` — it cannot be a closure over anything in the interaction, which is the same reason the row records the initiator and tenant instead of serialising them. So the block declares a `Plutonium::Interaction::Async::Run` subclass with exactly the API a standalone run has, and the validated attributes arrive through `options`.
63
+
64
+ `def` opens a fresh scope, so those method bodies cannot accidentally capture the interaction's locals.
65
+
66
+ The generated class is named `<Interaction>::Run` rather than left anonymous, because the class name is persisted in the run's `type` column and constantized in another process. An anonymous class would write a row nothing could read back.
67
+
68
+ | `on_failure` | Behavior when a target raises |
69
+ |---|---|
70
+ | `:halt` (default) | Stops at the first failure; the run ends `"failed"`, targets after the failure point are never attempted |
71
+ | `:continue` | Records the failure, keeps going; the run ends `"completed"` (see [Outcome vs state](#outcome-vs-state) for why that's not the same as a clean success) |
72
+ | `:transactional` | Wraps the whole batch in one DB transaction; any failure rolls back everything applied so far |
73
+
74
+ ### Attributes reach the run through `options`
75
+
76
+ Everything the interaction validated, except the targets, is written to the run's `options` and read back in the job:
77
+
78
+ ```ruby
79
+ attribute :reason, :string
80
+ async do
81
+ def perform_on(post) = post.archive!(reason: options["reason"])
82
+ end
83
+ ```
84
+
85
+ `options` is a JSON column, so dispatch writes it through `ActiveJob::Arguments`. Primitives are stored verbatim — a String stays a String in the column — and only values needing one get a serializer envelope, so a `Date` arrives as a `Date` and a `BigDecimal` as a `BigDecimal` rather than as strings. Hosts can register their own serializers.
86
+
87
+ An attribute that can't be carried at all is refused at dispatch, naming the interaction, rather than being written to a row whose work then fails deep in a job.
88
+
89
+ ### Files
90
+
91
+ A file can't ride the options column: JSON has no files, and the request's tempfile is deleted on the way out. So an uploaded file is staged to its backend's cache at dispatch and carried as the token — `options["import_file"]` is that token, a String.
92
+
93
+ Use `attachment` to read it back:
94
+
95
+ ```ruby
96
+ class Catalog::ImportProducts < ResourceInteraction
97
+ attribute :import_file
98
+
99
+ async do
100
+ def perform
101
+ attachment(:import_file).open do |file|
102
+ CSV.foreach(file, headers: true) { |row| Catalog::Product.create!(row.to_h) }
103
+ end
104
+ end
105
+ end
106
+ end
107
+ ```
108
+
109
+ `attachment(:key)` returns one, `attachments(:key)` all of them for a multiple-file attribute, each exposing `filename`, `content_type`, `url`, `open` and `download`. Reviving reaches storage, so it is not folded into `options`: the progress page reads options on every poll and has no need of the file.
110
+
111
+ #### Per-field backend and uploader
112
+
113
+ `backend:` and `uploader:` are read off the attribute's own `input` declaration — the same options, in the same place, a wizard step reads them from:
114
+
115
+ ```ruby
116
+ attribute :import_file
117
+ input :import_file, as: :uppy, uploader: Catalog::ImportUploader
118
+ ```
119
+
120
+ The uploader's `Attacher.validate` rules run when the interaction validates, so a file that breaks them **fails the form** — the submitter sees a field error and nothing is dispatched. Without that the interaction would validate clean, dispatch, and the author's `validate_max_size` would surface as a run failure on a page the submitter has already left. A no-op for ActiveStorage fields and for uploaders declaring no rules.
121
+
122
+ Validating means staging first, since Shrine validates an assigned cached file — so an upload that fails validation has still been written to the cache, and is reaped by the backend's own unattached-cache cleanup. Wizards make the same trade on every step submit.
123
+
124
+ Where no `backend:` is declared: `config.async_interactions.attachment_backend`, then `config.attachment_backend`, then auto-detection (active_shrine loaded → Shrine, else ActiveStorage). Same layering wizards use.
125
+
126
+ ### Sharing one run across interactions
127
+
128
+ Pass a class instead of a block when several interactions do the same kind of work:
129
+
130
+ ```ruby
131
+ class Blogging::ArchivePostsRun < Plutonium::Interaction::Async::Run
132
+ on_failure :continue
133
+ def perform_on(post) = post.archive!
134
+ end
135
+
136
+ class Blogging::ArchivePosts < ResourceInteraction
137
+ async Blogging::ArchivePostsRun
138
+ attribute :resources
139
+ end
140
+ ```
141
+
142
+ Passing both a class and a block raises `ArgumentError` — the block *is* a run class, so there is nothing to combine.
143
+
144
+ `async` must be the only thing that defines `#execute` on the class. Declaring it on a class that already has its own `execute` raises `ArgumentError` at load time (an interaction either executes inline or runs async, never both).
145
+
146
+ ## What gets recorded at dispatch
147
+
148
+ Nothing is passed in explicitly. Everything the run needs is already reachable from the interaction's own state:
149
+
150
+ - **Targets.** `attribute :resource` / `attribute :resources`, already narrowed by the controller's policy scope, stored as ids (`target_ids`) and re-resolved at perform time, never serialized as records.
151
+ - **Initiator + tenant.** `current_user` / `current_scoped_entity`, two of the things every Plutonium policy authorizes on.
152
+ - **Nested-route parent.** `current_parent` and `current_nested_association` — `/orgs/1/posts/5/comments` records `(Post#5, :comments)`. Both halves or neither, since `Policy#default_relation_scope` raises on one alone. This is the third policy input, and it is not optional detail: that method picks **one** branch, parent *or* entity, so a nested run without its parent re-derives targets under the tenant where dispatch used the parent — wider than the scope the initiator was shown. It also leaves a host predicate reading `parent` looking at `nil`, which (being declared `optional: true`) answers false rather than raising, refusing every target for a reason that names the predicate instead of the missing context.
153
+ - **The policy actually resolved.** `policy_class_name`, not an inferred `"#{Model}Policy"` (a namespaced portal or an STI fallback would make that guess wrong), plus `policy_action`, the predicate dispatch checked (e.g. `"archive?"`).
154
+ - **`authorization_namespace`.** The portal's module name, so perform-time policy lookup finds the same narrowed policy dispatch did.
155
+
156
+ An opaque (untargeted) run records none of the target/policy columns: there's no subject to check, so there's nothing to re-verify.
157
+
158
+ ## Authorization is re-derived, not replayed
159
+
160
+ The job has no controller, no request, no `current_user`. `Async::Context` rebuilds the authorization triple from the row and re-checks it from scratch. It does not trust anything the dispatching request already decided:
161
+
162
+ - The **scope** check re-runs `Post.associated_with(tenant)` style filtering — or, for a nested dispatch, the parent's association. A target that left the tenant or the parent, or was deleted, between dispatch and perform is reported `missing`/`unauthorized`, never silently skipped.
163
+ - The **predicate** check re-asks the policy the same question dispatch asked (`policy_action`), per target, immediately before `perform_on`, not once up front. An initiator whose permission was revoked mid-run stops applying to the remaining targets.
164
+ - A **policy mismatch** (the class renamed/re-parented/re-namespaced since dispatch) refuses to run at all, rather than silently authorizing under a different policy than the initiator was ever subject to.
165
+ - A **deleted subject** — initiator, tenant or parent — refuses the run. In each case the association nils out, and nil reads as "there was never one": no tenant, or not a nested dispatch. Both of those drop a filter rather than narrowing, so the `*_type` column is what tells "carries none" apart from "carries one that is gone".
166
+
167
+ This is deliberate and asymmetric: a resolution failure always fails **closed** (refuse / report missing), never open (never "assume permitted").
168
+
169
+ ## Outcome vs state
170
+
171
+ Read `run.outcome`, not `run.state`, when displaying or branching on the result. A `:continue` run that couldn't apply every target still ends with `state == "completed"`, since the author declared partial application acceptable. `outcome` is what distinguishes that from a clean pass:
172
+
173
+ ```ruby
174
+ run.state # "completed"
175
+ run.outcome # "completed_with_errors" (state == "completed" && errors_log.any?)
176
+ ```
177
+
178
+ The progress page, the table's `outcome` column, and the running banner all render `outcome`, never bare `state`.
179
+
180
+ ## Registering the Run resource
181
+
182
+ Register it once per portal so its show page becomes routable:
183
+
184
+ ```bash
185
+ rails g pu:async_interactions:install --dest=admin_portal
186
+ ```
187
+
188
+ ```ruby
189
+ # packages/admin_portal/config/routes.rb
190
+ register_resource ::Plutonium::Interaction::Async::Run
191
+ ```
192
+
193
+ ```ruby
194
+ # packages/admin_portal/app/controllers/admin_portal/async_runs_controller.rb
195
+ class AdminPortal::AsyncRunsController < AdminPortal::ResourceController
196
+ controller_for ::Plutonium::Interaction::Async::Run
197
+
198
+ include AdminPortal::Concerns::Controller
199
+ end
200
+ ```
201
+
202
+ `controller_for` is required: the controller's name doesn't match `Run`'s real, namespaced class, so inference can't find it on its own. No policy/definition files are generated: `Plutonium::Interaction::Async::RunPolicy`/`Async::RunDefinition` already resolve automatically (Rails matches `Plutonium::Interaction::Async::RunDefinition` by the exact class name, and ActionPolicy's own lookup finds `AsyncRunPolicy` the same way).
203
+
204
+ If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` (`--schedule` to override the default `every 15 minutes`) — see [Stalled runs and ReapJob](#stalled-runs-and-reapjob). Idempotent, so running the generator against a second portal doesn't duplicate the entry.
205
+
206
+ A registered run resource gets, for free:
207
+
208
+ - **A progress page.** The show page IS the progress page. It self-refreshes via polling (not ActionCable) while `state` is `pending`/`running`, and stops carrying the poll once the run settles, so a finished run is a static page, not a background request per viewer.
209
+ - **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option — `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
210
+ - **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
211
+ - **Tenant scoping.** A run's `associated_with` scope filters on the tenant it was dispatched in (recorded on the row), not on walking the object graph, since the two polymorphic tenant columns make the generic scope unusable.
212
+ - **A humanized target label.** `run.target_label` reads the target class through `model_name.human` ("Post", not "Blogging::Post"), falling back to the raw string if that class has since been renamed or removed.
213
+
214
+ The progress page, mid-run:
215
+
216
+ ![A run's progress page showing a Running badge, a progress bar at 27 of 50 targets, and the run's type, target type, initiator and timestamps](/images/reference/async-progress-page.png)
217
+
218
+ And the banner it leaves on the target resource's index while it is still going:
219
+
220
+ ![A Task index with a banner above the collection reading "Archive Tasks Async Interaction Run #19 Running", with a View progress link](/images/reference/async-running-banner.png)
221
+
222
+ ## Stalled runs and ReapJob
223
+
224
+ A worker crash mid-batch (or a job the queue silently drops) leaves a run `"running"` (or `"pending"`) forever; nothing else ever revisits it on its own. `Plutonium::Interaction::Async::ReapJob` finds runs with no recorded activity (`last_activity_at`, falling back to `created_at` for a run never even picked up) past `config.async_interactions.stall_after`, and resumes them: resets to `"pending"` and re-enqueues.
225
+
226
+ This is safe, not a replay. The executor tracks `handled_target_ids` (every target already dispositioned, success or failure) and resumes only the remainder, so a target already applied before the interruption is not redone.
227
+
228
+ `rails g pu:async_interactions:install` schedules this for you when Solid Queue is in the bundle:
229
+
230
+ ```yaml
231
+ # config/recurring.yml (Solid Queue)
232
+ production:
233
+ reap_stalled_async_runs:
234
+ class: Plutonium::Interaction::Async::ReapJob
235
+ schedule: every 15 minutes
236
+ ```
237
+
238
+ Without Solid Queue — or for another scheduler like `whenever` — add it yourself:
239
+
240
+ ```ruby
241
+ # whenever gem
242
+ every 15.minutes do
243
+ runner "Plutonium::Interaction::Async::ReapJob.perform_later"
244
+ end
245
+ ```
246
+
247
+ A 15 to 30 minute cadence is reasonable against the default 1-hour `stall_after`: frequent enough that a stalled run doesn't sit for long, with enough margin that clock jitter doesn't matter.
248
+
249
+ ::: warning This is a time heuristic, not a lease
250
+ Resuming is based on elapsed time, not a true distributed lock. A run that is merely slow (not dead) and happens to cross `stall_after` gets resumed too.
251
+
252
+ What bounds that is `lock_version`. Both the reaper's resume and the executor's claim bump it, so the worker that is still alive holds a version the row no longer has: its very next write raises `ActiveRecord::StaleObjectError`, and the executor treats that as "no longer mine" — it abandons the pass without marking the run failed and without overwriting the new worker's progress. Two things it deliberately does not do: it cannot interrupt a `perform_on` already in flight, so one target may be applied twice (once by each side), and it cannot roll back what the superseded worker already committed. Set `stall_after` well above this app's slowest legitimate run — the fence bounds the damage of a bad value, it does not make one free.
253
+
254
+ ### Long work must say it is alive
255
+
256
+ `stall_after` is a **silence** threshold, not a runtime limit. Every write the executor makes refreshes the clock, so a targeted run with quick targets heartbeats once per target for free. Two shapes get nothing, and both are exactly the "long-running task" case:
257
+
258
+ - **Opaque work.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and — having no `handled_target_ids` to resume from — re-runs **from scratch**.
259
+ - **A single `perform_on`** that outlives `stall_after` on its own.
260
+
261
+ Call `heartbeat!` from inside such work:
262
+
263
+ ```ruby
264
+ class Billing::ReissueInvoicesRun < Plutonium::Interaction::Async::Run
265
+ def perform
266
+ invoices.each_slice(500) do |slice|
267
+ reissue(slice)
268
+ heartbeat! # "still working" — resets the stall clock
269
+ end
270
+ end
271
+ end
272
+ ```
273
+
274
+ This is deliberately not automatic. A background thread would have to guess a cadence, and would go on reporting a wedged worker as healthy; only the work itself knows it is making progress.
275
+
276
+ `heartbeat!` also **answers**. The write is conditional on this worker still holding the row's `lock_version`, so one that was superseded inside a long `perform` raises `ActiveRecord::StaleObjectError` at its next beat and abandons the pass — for opaque work that is the only place it can find out before `finish!`. Under `:transactional` the beat is inside the batch transaction like everything else, so it stays invisible to the reaper until the batch commits.
277
+
278
+ ### Queue-level concurrency
279
+
280
+ On a queue that provides ActiveJob concurrency controls — Solid Queue does, whenever it is in the bundle — the run job also declares a per-run semaphore, and the reaper a global one:
281
+
282
+ | Job | Key | Limit | Duration |
283
+ |---|---|---|---|
284
+ | `Async::Job` | the run's id | 1 | `config.async_interactions.stall_after` |
285
+ | `Async::ReapJob` | constant | 1 | Solid Queue's default |
286
+
287
+ This is declared only when the method exists; Plutonium depends on no queue backend, and nothing above requires one.
288
+
289
+ It is not a second copy of the claim. `claim!` can only *refuse* a duplicate delivery, and only once a worker is already running it — by which point a reaper's resume has re-entered `perform_on` for one target. The semaphore removes the race a step earlier: the second delivery waits instead of racing, so on a queue that supports it the double-applied target does not happen at all. Keying the run job on `stall_after` matters here — Solid Queue's 3-minute default would expire the semaphore mid-batch on any run big enough to be worth dispatching.
290
+ :::
291
+
292
+ ## Related
293
+
294
+ - [Interactions](/reference/behavior/interactions) — `async` is declared inside `Plutonium::Resource::Interaction`; everything else about inputs, validation and outcomes is unchanged.
295
+ - [Policies](/reference/behavior/policies) — the policy dispatch checks and the job re-checks are the same predicate.
@@ -59,7 +59,8 @@ Plus interactive-action routes for every action declared in the definition (`/po
59
59
  |---|---|
60
60
  | Field rendering (inputs, displays, columns) | [Definition](/reference/resource/definition) |
61
61
  | Search, filters, scopes, sorting | [Query](/reference/resource/query) |
62
- | Custom operations (publish, archive, import) | [Interaction](./interactions) + action on definition |
62
+ | Custom operations (publish, archive, import) — the *button* | [Interaction](./interactions) + action on definition |
63
+ | The operation itself, once a job/API/task also needs it | The **model** — see [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for) |
63
64
  | Authorization rules | [Policy](./policies) |
64
65
  | Form / show / page chrome | Definition (custom page classes — see [UI › Pages](/reference/ui/pages)) |
65
66
  | **Custom redirect logic** | **[Controller hook](#redirect-hooks)** |
@@ -108,6 +109,14 @@ def filtered_resource_collection
108
109
  end
109
110
  ```
110
111
 
112
+ Also where you eager-load associations the index renders. `super` keeps scoping, search, filters and sorting:
113
+
114
+ ```ruby
115
+ def filtered_resource_collection = super.includes(:author, :category)
116
+ ```
117
+
118
+ See [Guides › Performance](/guides/performance).
119
+
111
120
  ### Presentation hooks
112
121
 
113
122
  Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
@@ -147,13 +156,15 @@ end
147
156
 
148
157
  ## Custom actions
149
158
 
150
- Prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) for anything with business logic. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
159
+ Prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) for anything a user triggers from a page: you get the button, the policy check, the form, and the flash for free. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
160
+
161
+ Either way the *operation* should be a named method on the model — that's what keeps it reachable from a job or an API. The controller and the interaction are two different front doors to the same `post.publish!`.
151
162
 
152
163
  ```ruby
153
164
  class PostsController < ::ResourceController
154
165
  def publish
155
166
  authorize_current!(resource_record!, to: :publish?)
156
- resource_record!.update!(published: true)
167
+ resource_record!.publish!
157
168
  redirect_to resource_url_for(resource_record!), notice: "Published!"
158
169
  end
159
170
  end
@@ -252,11 +263,13 @@ Routes prefixed `nested_` automatically resolve the parent. See [Tenancy › Nes
252
263
 
253
264
  ```ruby
254
265
  current_parent # parent record
266
+ current_parent_class # User
255
267
  current_nested_association # :posts
256
- parent_route_param # :user_id
257
268
  parent_input_param # :user
258
269
  ```
259
270
 
271
+ The nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and `current_parent_class` / `current_nested_association` read it back. (There is no `parent_route_param` — the id parameter is derived from the parent's own route, and a singular parent contributes none at all.)
272
+
260
273
  Parent fields are excluded from forms/displays by default. Toggle with the [presentation hooks](#presentation-hooks).
261
274
 
262
275
  Custom parent resolution:
@@ -4,10 +4,15 @@ The behavior layer is intentionally thin:
4
4
 
5
5
  - **[Controllers](./controllers) route** — handle requests, redirect after submit, transform params.
6
6
  - **[Policies](./policies) authorize** — decide who can do what, which fields they can see, which records they can access.
7
- - **[Interactions](./interactions) act** — encapsulate business logic for custom operations (publish, archive, import, send invitation).
7
+ - **[Interactions](./interactions) present** — declare the inputs for a custom operation (publish, archive, import, send invitation), render as a button and a form, and hand back an outcome.
8
+ - **[Async Interactions](./async-interactions)** — `async` declares a persisted, resumable run instead of executing inline, for bulk operations and anything else too slow to hold a request open.
8
9
 
9
10
  Registering an action and rendering it lives in [Resource › Definition](/reference/resource/definition) and [Resource › Actions](/reference/resource/actions). This section covers **writing** the controller hook, policy method, or interaction class behind it.
10
11
 
12
+ ::: tip And the operation itself lives on the model
13
+ An interaction is a presentation object — it can only be constructed with a `view_context`. Logic may *start* in `execute`, but the second caller (a job, an API controller, a rake task, the console) is the trigger to move it onto the record, Rails-style. See [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for).
14
+ :::
15
+
11
16
  For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
12
17
 
13
18
  ## At a glance
@@ -16,6 +21,7 @@ For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity sc
16
21
  |---|---|
17
22
  | Field rendering (inputs, displays, columns, search/filters) | [Definition](/reference/resource/definition) |
18
23
  | Custom operations (publish, archive, import) | [Interaction](./interactions) + [Action](/reference/resource/actions) on the definition |
24
+ | Work too slow or too large for a request | [Async Interactions](./async-interactions) |
19
25
  | Authorization rules | [Policy](./policies) |
20
26
  | Tenant scoping (`relation_scope`) | [Policy](./policies) + [Tenancy](/reference/tenancy/entity-scoping) |
21
27
  | Custom redirect logic, param munging, custom index query shape | [Controller hook](./controllers) |