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
@@ -0,0 +1,1414 @@
1
+ # Async Interactions Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Let an interaction enqueue its work as a persisted run that reports progress, records who started it, and appears as a browsable resource.
6
+
7
+ **Architecture:** A single STI table `plutonium_interaction_runs` holds every run; authors subclass `Plutonium::Interaction::Run` to define execution and failure policy, and options ride in a JSON column so an async action costs zero migrations. Interactions keep their existing job (inputs, validation, authorization) and only dispatch, so `Outcome` stays synchronous. The job rebuilds the `(initiator, scoped_entity)` policy context rather than inheriting one, and re-resolves targets through the policy scope at perform time.
8
+
9
+ **Tech Stack:** Rails 7.2+/8.x, ActiveJob, ActiveRecord STI, Phlex views, Turbo, minitest + appraisal.
10
+
11
+ **User Verification:** NO — no user verification required. The spec asks for a working subsystem; correctness is established by the test suite, particularly the authorization tests in Task 3.
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ | File | Responsibility |
18
+ |---|---|
19
+ | `lib/plutonium/interaction/runs/configuration.rb` | `enabled`, `queue`, `cleanup_after` config |
20
+ | `db/migrate/interaction_runs/20260817000001_create_plutonium_interaction_runs.rb` | The table |
21
+ | `lib/plutonium/interaction/run.rb` | STI base: state, progress, options, targets |
22
+ | `lib/plutonium/interaction/runs/context.rb` | Rebuilds `(initiator, scoped_entity)`; resolves targets through the policy scope |
23
+ | `lib/plutonium/interaction/runs/executor.rb` | Perform loop + failure policies |
24
+ | `lib/plutonium/interaction/runs/job.rb` | ActiveJob entry point |
25
+ | `lib/plutonium/interaction/concerns/dispatchable.rb` | `dispatches_to` on the interaction |
26
+ | `lib/plutonium/interaction/runs/definition.rb` | Resource definition for the run |
27
+ | `lib/plutonium/interaction/runs/policy.rb` | Who may see a run |
28
+ | `lib/plutonium/ui/interaction/run_progress.rb` | Progress panel (poll frame) |
29
+ | `lib/plutonium/ui/interaction/running_banner.rb` | In-progress list on a target index |
30
+
31
+ Task 3 is the security core. Tasks 1–2 are prerequisites for everything; 4–7 depend on 3.
32
+
33
+ ---
34
+
35
+ ### Task 1: Config, migration and registration
36
+
37
+ **Goal:** The `plutonium_interaction_runs` table exists in a host app when the feature is enabled, following the wizard subsystem's exact pattern.
38
+
39
+ **Files:**
40
+ - Create: `lib/plutonium/interaction/runs/configuration.rb`
41
+ - Create: `db/migrate/interaction_runs/20260817000001_create_plutonium_interaction_runs.rb`
42
+ - Modify: `lib/plutonium/railtie.rb` (beside the existing `Migrations.register(:wizards, ...)` call)
43
+ - Modify: `lib/plutonium/configuration.rb` (expose `interaction_runs`)
44
+ - Test: `test/plutonium/interaction/runs/configuration_test.rb`
45
+
46
+ **Acceptance Criteria:**
47
+ - [ ] `Plutonium.configuration.interaction_runs.enabled` defaults to `false`
48
+ - [ ] `Plutonium::Migrations.enabled_paths` includes the runs path only when enabled
49
+ - [ ] Polymorphic `*_id` columns are `string`, so uuid-keyed host apps work
50
+ - [ ] The migration runs cleanly on the dummy app
51
+
52
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/configuration_test.rb` → 0 failures
53
+
54
+ **Steps:**
55
+
56
+ - [ ] **Step 1: Write the failing test**
57
+
58
+ ```ruby
59
+ # test/plutonium/interaction/runs/configuration_test.rb
60
+ require "test_helper"
61
+
62
+ class Plutonium::Interaction::Runs::ConfigurationTest < ActiveSupport::TestCase
63
+ test "is disabled by default" do
64
+ refute Plutonium::Interaction::Runs::Configuration.new.enabled
65
+ end
66
+
67
+ test "migration path is surfaced only when enabled" do
68
+ Plutonium::Migrations.reset!
69
+ path = Plutonium.root.join("db/migrate/interaction_runs").to_s
70
+ Plutonium::Migrations.register(:interaction_runs, path)
71
+
72
+ Plutonium.configuration.interaction_runs.enabled = false
73
+ refute_includes Plutonium::Migrations.enabled_paths, path
74
+
75
+ Plutonium.configuration.interaction_runs.enabled = true
76
+ assert_includes Plutonium::Migrations.enabled_paths, path
77
+ ensure
78
+ Plutonium.configuration.interaction_runs.enabled = false
79
+ end
80
+ end
81
+ ```
82
+
83
+ - [ ] **Step 2: Run it and watch it fail**
84
+
85
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/configuration_test.rb`
86
+ Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs`
87
+
88
+ - [ ] **Step 3: Add the configuration class**
89
+
90
+ ```ruby
91
+ # lib/plutonium/interaction/runs/configuration.rb
92
+ # frozen_string_literal: true
93
+
94
+ module Plutonium
95
+ module Interaction
96
+ module Runs
97
+ # Configuration for persisted interaction runs. Mirrors
98
+ # Plutonium::Wizard::Configuration: `enabled` gates both the subsystem and
99
+ # its migrations (see Plutonium::Migrations).
100
+ class Configuration
101
+ # @return [Boolean] whether runs (and their migrations) are enabled
102
+ attr_accessor :enabled
103
+
104
+ # @return [Symbol] ActiveJob queue for run jobs
105
+ attr_accessor :queue
106
+
107
+ def initialize
108
+ @enabled = false
109
+ @queue = :default
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ ```
116
+
117
+ - [ ] **Step 4: Expose it on the main configuration**
118
+
119
+ In `lib/plutonium/configuration.rb`, beside `@wizards = Plutonium::Wizard::Configuration.new` in `initialize`:
120
+
121
+ ```ruby
122
+ @interaction_runs = Plutonium::Interaction::Runs::Configuration.new
123
+ ```
124
+
125
+ and add `:interaction_runs` to the same `attr_reader` that exposes `:wizards`.
126
+
127
+ - [ ] **Step 5: Register the migration path**
128
+
129
+ In `lib/plutonium/railtie.rb`, directly below the existing wizard registration:
130
+
131
+ ```ruby
132
+ Plutonium::Migrations.register(:interaction_runs, Plutonium.root.join("db/migrate/interaction_runs").to_s)
133
+ ```
134
+
135
+ - [ ] **Step 6: Write the migration**
136
+
137
+ ```ruby
138
+ # db/migrate/interaction_runs/20260817000001_create_plutonium_interaction_runs.rb
139
+ # frozen_string_literal: true
140
+
141
+ class CreatePlutoniumInteractionRuns < ActiveRecord::Migration[7.2]
142
+ def change
143
+ create_table :plutonium_interaction_runs do |t|
144
+ # STI discriminator — the author's Run subclass.
145
+ t.string :type, null: false
146
+ t.string :state, null: false, default: "pending" # pending | running | completed | failed
147
+
148
+ # JSON payloads are jsonb, matching plutonium_wizard_sessions: Postgres
149
+ # hosts get equality and GIN indexing (plain json has neither), and SQLite
150
+ # hosts get the type via PLUTONIUM_SQLITE_TYPE_ALIASES, which aliases
151
+ # jsonb -> json. Changing a column type post-release costs every host app a
152
+ # migration, so pick the queryable one now.
153
+ #
154
+ # The dispatching interaction's validated inputs.
155
+ t.public_send(:jsonb, :options, null: false, default: {})
156
+
157
+ # Targets. target_type is a REAL column, not JSON: the index feature
158
+ # queries "runs for this resource", which cannot be indexed out of a
159
+ # JSON array. Ids are stored as given and re-resolved through the policy
160
+ # scope at perform time (see Runs::Context).
161
+ t.string :target_type
162
+ t.public_send(:jsonb, :target_ids, null: false, default: [])
163
+
164
+ # Who started it, and in which tenant.
165
+ #
166
+ # The initiator is ALWAYS required: a nullable initiator would drift into
167
+ # meaning "unscoped", which is the fail-open case this design guards.
168
+ #
169
+ # The scoped entity is nullable BY DESIGN — only entity-scoped portals
170
+ # have one, mirroring plutonium_wizard_sessions.scope_type/scope_id. nil
171
+ # means "no tenant", NOT "tenant unknown", and code rebuilding the policy
172
+ # context must handle nil explicitly rather than assume a tenant is
173
+ # present. This schema offers no non-null guarantee to lean on.
174
+ #
175
+ # *_id is string-typed to accommodate bigint or uuid host primary keys,
176
+ # matching plutonium_wizard_sessions.
177
+ t.string :initiator_type, null: false
178
+ t.string :initiator_id, null: false
179
+ t.string :scoped_entity_type
180
+ t.string :scoped_entity_id
181
+
182
+ # Counts. Both nil for opaque (untargeted) work — the progress UI reads
183
+ # nil as "indeterminate" rather than 0%.
184
+ t.integer :progress_total
185
+ t.integer :progress_done, null: false, default: 0
186
+
187
+ t.public_send(:jsonb, :errors_log, null: false, default: [])
188
+
189
+ t.datetime :started_at
190
+ t.datetime :finished_at
191
+
192
+ t.timestamps
193
+
194
+ t.index [:target_type, :state], name: "idx_pu_runs_on_target_and_state"
195
+ t.index [:initiator_type, :initiator_id], name: "idx_pu_runs_on_initiator"
196
+ t.index [:scoped_entity_type, :scoped_entity_id], name: "idx_pu_runs_on_scoped_entity"
197
+ end
198
+ end
199
+ end
200
+ ```
201
+
202
+ - [ ] **Step 7: Run the test to verify it passes**
203
+
204
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/configuration_test.rb`
205
+ Expected: PASS, 2 runs 0 failures
206
+
207
+ - [ ] **Step 8: Apply to the dummy app and commit**
208
+
209
+ ```bash
210
+ cd test/dummy && bin/rails db:migrate && cd ../..
211
+ git add lib/plutonium/interaction/runs/configuration.rb db/migrate/interaction_runs lib/plutonium/railtie.rb lib/plutonium/configuration.rb test/plutonium/interaction/runs/configuration_test.rb
212
+ git commit -m "feat(runs): table, config and migration registration for interaction runs"
213
+ ```
214
+
215
+ ---
216
+
217
+ ### Task 2: The Run model
218
+
219
+ **Goal:** `Plutonium::Interaction::Run` — STI base with state transitions, progress accounting, and typed access to options.
220
+
221
+ **Files:**
222
+ - Create: `lib/plutonium/interaction/run.rb`
223
+ - Test: `test/plutonium/interaction/run_test.rb`
224
+
225
+ **Acceptance Criteria:**
226
+ - [ ] `start!` / `finish!` / `fail!` move `state` and stamp `started_at` / `finished_at`
227
+ - [ ] `progress_fraction` returns nil when `progress_total` is nil (opaque work)
228
+ - [ ] `options` round-trips through JSON with symbol access
229
+ - [ ] `record_target_failure!` appends to `errors_log` without losing prior entries
230
+ - [ ] `targeted?` is true only when the subclass responds to `perform_on`
231
+
232
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/run_test.rb` → 0 failures
233
+
234
+ **Steps:**
235
+
236
+ - [ ] **Step 1: Write the failing test**
237
+
238
+ ```ruby
239
+ # test/plutonium/interaction/run_test.rb
240
+ require "test_helper"
241
+
242
+ class TestArchiveRun < Plutonium::Interaction::Run
243
+ def perform_on(record) = record
244
+ end
245
+
246
+ class TestOpaqueRun < Plutonium::Interaction::Run
247
+ def perform = :done
248
+ end
249
+
250
+ class Plutonium::Interaction::RunTest < ActiveSupport::TestCase
251
+ def build(klass = TestArchiveRun, **attrs)
252
+ klass.new(initiator_type: "User", initiator_id: "1", **attrs)
253
+ end
254
+
255
+ test "lifecycle stamps state and timestamps" do
256
+ run = build
257
+ run.start!
258
+ assert_equal "running", run.state
259
+ refute_nil run.started_at
260
+
261
+ run.finish!
262
+ assert_equal "completed", run.state
263
+ refute_nil run.finished_at
264
+ end
265
+
266
+ test "progress is indeterminate for opaque work" do
267
+ assert_nil build(TestOpaqueRun).progress_fraction
268
+ end
269
+
270
+ test "progress is a fraction for targeted work" do
271
+ run = build(progress_total: 4, progress_done: 1)
272
+ assert_in_delta 0.25, run.progress_fraction, 0.001
273
+ end
274
+
275
+ test "target failures accumulate" do
276
+ run = build
277
+ run.record_target_failure!(id: 1, message: "boom")
278
+ run.record_target_failure!(id: 2, message: "bang")
279
+ assert_equal 2, run.errors_log.size
280
+ assert_equal "boom", run.errors_log.first["message"]
281
+ end
282
+
283
+ test "targeted? reflects what the subclass implements" do
284
+ assert build(TestArchiveRun).targeted?
285
+ refute build(TestOpaqueRun).targeted?
286
+ end
287
+ end
288
+ ```
289
+
290
+ - [ ] **Step 2: Run it and watch it fail**
291
+
292
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/run_test.rb`
293
+ Expected: FAIL — `uninitialized constant Plutonium::Interaction::Run`
294
+
295
+ - [ ] **Step 3: Implement the model**
296
+
297
+ ```ruby
298
+ # lib/plutonium/interaction/run.rb
299
+ # frozen_string_literal: true
300
+
301
+ module Plutonium
302
+ module Interaction
303
+ # A persisted interaction run.
304
+ #
305
+ # STI base: authors subclass to define execution and failure policy, and the
306
+ # subclass name lands in `type`. Options are JSON, so an async action costs
307
+ # no migration.
308
+ #
309
+ # This class deliberately holds NO execution logic — see Runs::Executor. It
310
+ # is the record; the executor is the behaviour. Keeping them apart is what
311
+ # lets the job rebuild an authorization context around the work (Runs::Context)
312
+ # without the model knowing anything about policies.
313
+ class Run < ActiveRecord::Base
314
+ self.table_name = "plutonium_interaction_runs"
315
+
316
+ STATES = %w[pending running completed failed].freeze
317
+
318
+ belongs_to :initiator, polymorphic: true
319
+ belongs_to :scoped_entity, polymorphic: true, optional: true
320
+
321
+ validates :state, inclusion: {in: STATES}
322
+ # Required, not optional: a nullable initiator would drift into meaning
323
+ # "unscoped", which is exactly the fail-open case this design guards.
324
+ validates :initiator_type, :initiator_id, presence: true
325
+
326
+ scope :in_progress, -> { where(state: %w[pending running]) }
327
+ scope :for_target, ->(klass) { where(target_type: klass.to_s) }
328
+
329
+ def start!
330
+ update!(state: "running", started_at: Time.current)
331
+ end
332
+
333
+ def finish!
334
+ update!(state: "completed", finished_at: Time.current)
335
+ end
336
+
337
+ def fail!(message = nil)
338
+ record_target_failure!(id: nil, message: message) if message
339
+ update!(state: "failed", finished_at: Time.current)
340
+ end
341
+
342
+ def in_progress? = %w[pending running].include?(state)
343
+
344
+ # nil means INDETERMINATE, not zero: opaque work has no denominator, and
345
+ # the progress UI renders a spinner rather than a 0% bar.
346
+ def progress_fraction
347
+ return nil if progress_total.nil? || progress_total.zero?
348
+
349
+ progress_done.to_f / progress_total
350
+ end
351
+
352
+ def record_target_failure!(id:, message:)
353
+ self.errors_log = errors_log + [{"target_id" => id, "message" => message}]
354
+ end
355
+
356
+ # Which shape of work this is, decided by what the subclass implements
357
+ # rather than a mode flag — one less thing for an author to keep in sync.
358
+ def targeted? = respond_to?(:perform_on)
359
+
360
+ # Failure policy, overridden by subclasses via `on_failure`.
361
+ def self.on_failure(policy)
362
+ @failure_policy = policy
363
+ end
364
+
365
+ def self.failure_policy = @failure_policy || :halt
366
+
367
+ def failure_policy = self.class.failure_policy
368
+ end
369
+ end
370
+ end
371
+ ```
372
+
373
+ - [ ] **Step 4: Run the test to verify it passes**
374
+
375
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/run_test.rb`
376
+ Expected: PASS, 5 runs 0 failures
377
+
378
+ - [ ] **Step 5: Commit**
379
+
380
+ ```bash
381
+ git add lib/plutonium/interaction/run.rb test/plutonium/interaction/run_test.rb
382
+ git commit -m "feat(runs): Run model with state, progress and failure-policy hooks"
383
+ ```
384
+
385
+ ---
386
+
387
+ ### Task 3: Authorization context and target resolution
388
+
389
+ **Goal:** Rebuild `(initiator, scoped_entity)` inside a job and resolve targets through the policy scope, so authorization survives leaving the request.
390
+
391
+ **This is the security core of the feature.** Both failure modes here fail *open*.
392
+
393
+ **Files:**
394
+ - Create: `lib/plutonium/interaction/runs/context.rb`
395
+ - Test: `test/plutonium/interaction/runs/context_test.rb`
396
+
397
+ **Acceptance Criteria:**
398
+ - [ ] Targets resolve through the target resource's policy scope for the stored initiator
399
+ - [ ] A target outside the stored `scoped_entity` is NOT returned
400
+ - [ ] A target whose id no longer exists is reported as missing, not silently dropped
401
+ - [ ] Resolution is a single query, not one per id
402
+
403
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/context_test.rb` → 0 failures
404
+
405
+ **Steps:**
406
+
407
+ - [ ] **Step 1: Write the failing test**
408
+
409
+ ```ruby
410
+ # test/plutonium/interaction/runs/context_test.rb
411
+ require "test_helper"
412
+
413
+ class Plutonium::Interaction::Runs::ContextTest < ActiveSupport::TestCase
414
+ include IntegrationTestHelper
415
+
416
+ setup do
417
+ @org = create_organization!
418
+ @other_org = create_organization!
419
+ @user = create_user!
420
+ create_membership!(organization: @org, user: @user)
421
+
422
+ @mine = create_post!(user: @user, organization: @org)
423
+ @theirs = create_post!(user: @user, organization: @other_org)
424
+ end
425
+
426
+ def run_for(ids)
427
+ TestPostRun.new(
428
+ initiator: @user,
429
+ scoped_entity: @org,
430
+ target_type: "Blogging::Post",
431
+ target_ids: ids
432
+ )
433
+ end
434
+
435
+ test "resolves targets inside the stored entity scope" do
436
+ resolved = Plutonium::Interaction::Runs::Context.new(run_for([@mine.id])).targets
437
+ assert_equal [@mine.id], resolved.records.map(&:id)
438
+ assert_empty resolved.missing_ids
439
+ end
440
+
441
+ test "a target in another tenant is not returned" do
442
+ resolved = Plutonium::Interaction::Runs::Context.new(run_for([@theirs.id])).targets
443
+ assert_empty resolved.records,
444
+ "a run must never reach outside the entity it was dispatched in"
445
+ assert_equal [@theirs.id], resolved.missing_ids
446
+ end
447
+
448
+ test "a vanished target is reported, not silently dropped" do
449
+ resolved = Plutonium::Interaction::Runs::Context.new(run_for([@mine.id, 999_999])).targets
450
+ assert_equal [@mine.id], resolved.records.map(&:id)
451
+ assert_equal [999_999], resolved.missing_ids
452
+ end
453
+
454
+ test "resolution is one query regardless of target count" do
455
+ ids = [@mine.id] * 25
456
+ queries = 0
457
+ counter = ->(*, payload) { queries += 1 unless payload[:name] == "SCHEMA" }
458
+ ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
459
+ Plutonium::Interaction::Runs::Context.new(run_for(ids)).targets
460
+ end
461
+ assert_operator queries, :<=, 2, "targets must resolve in a single scoped query"
462
+ end
463
+ end
464
+ ```
465
+
466
+ - [ ] **Step 2: Add the dummy run subclass the test needs**
467
+
468
+ ```ruby
469
+ # test/dummy/app/runs/test_post_run.rb
470
+ class TestPostRun < Plutonium::Interaction::Run
471
+ def perform_on(post) = post.touch
472
+ end
473
+ ```
474
+
475
+ - [ ] **Step 3: Run it and watch it fail**
476
+
477
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/context_test.rb`
478
+ Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs::Context`
479
+
480
+ - [ ] **Step 4: Implement the context**
481
+
482
+ ```ruby
483
+ # lib/plutonium/interaction/runs/context.rb
484
+ # frozen_string_literal: true
485
+
486
+ module Plutonium
487
+ module Interaction
488
+ module Runs
489
+ # Rebuilds the authorization context a run was dispatched under, and
490
+ # resolves its targets through that context.
491
+ #
492
+ # ## Why this exists
493
+ #
494
+ # `perform` runs in a job: there is no controller, no `current_user`, no
495
+ # request. Plutonium authorizes on the PAIR (user, entity_scope) —
496
+ # Core::Controllers::Authorizable registers
497
+ # `authorize :entity_scope, through: :entity_scope_for_authorize`. Rebuilding
498
+ # only the user would resolve targets under the wrong tenant, and that
499
+ # fails OPEN: a broader scope silently includes records the initiator could
500
+ # not see when they dispatched.
501
+ #
502
+ # Authorization is therefore re-derived HERE, at perform time, and never
503
+ # trusted from dispatch. Access revoked between enqueue and run stops the
504
+ # work — otherwise a persisted run becomes a way to launder stale
505
+ # permissions.
506
+ class Context
507
+ # Resolved targets, plus the ids that did not come back.
508
+ #
509
+ # `missing_ids` is deliberately surfaced rather than swallowed: a target
510
+ # that vanished or fell out of scope is information the operator needs.
511
+ # Silence there is how bulk operations quietly under-apply.
512
+ Targets = Struct.new(:records, :missing_ids, keyword_init: true)
513
+
514
+ def initialize(run)
515
+ @run = run
516
+ end
517
+
518
+ attr_reader :run
519
+
520
+ def initiator = run.initiator
521
+
522
+ def scoped_entity = run.scoped_entity
523
+
524
+ # One scoped query, never N locates. `policy_scope` composes the
525
+ # resource's `relation_scope` with the rebuilt context, so authorization
526
+ # is enforced by construction rather than checked afterwards — which is
527
+ # also why ids beat GlobalIDs here: GlobalID::Locator bypasses the scope
528
+ # entirely.
529
+ def targets
530
+ return Targets.new(records: [], missing_ids: []) if run.target_type.blank?
531
+
532
+ ids = Array(run.target_ids)
533
+ records = authorized_scope.where(id: ids).to_a
534
+ found = records.map { |r| r.id.to_s }
535
+
536
+ Targets.new(records: records, missing_ids: ids.reject { |id| found.include?(id.to_s) })
537
+ end
538
+
539
+ def target_class = run.target_type.constantize
540
+
541
+ private
542
+
543
+ def authorized_scope
544
+ policy_class = Plutonium::Resource::Policy.infer_policy_class(target_class)
545
+ policy_class.new(
546
+ target_class,
547
+ user: initiator,
548
+ entity_scope: scoped_entity
549
+ ).relation_scope(target_class.all)
550
+ end
551
+ end
552
+ end
553
+ end
554
+ end
555
+ ```
556
+
557
+ - [ ] **Step 5: Run the test to verify it passes**
558
+
559
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/context_test.rb`
560
+ Expected: PASS, 4 runs 0 failures
561
+
562
+ If `infer_policy_class` does not exist under that name, find the codebase's existing policy-inference entry point (`grep -rn "def.*policy_class" lib/plutonium/`) and use it — do not introduce a second inference path.
563
+
564
+ - [ ] **Step 6: Commit**
565
+
566
+ ```bash
567
+ git add lib/plutonium/interaction/runs/context.rb test/plutonium/interaction/runs/context_test.rb test/dummy/app/runs/test_post_run.rb
568
+ git commit -m "feat(runs): rebuild the (initiator, scoped_entity) context and resolve targets through the policy scope"
569
+ ```
570
+
571
+ ---
572
+
573
+ ### Task 4: Executor and failure policies
574
+
575
+ **Goal:** Run the work, honour `on_failure`, and keep progress current.
576
+
577
+ **Files:**
578
+ - Create: `lib/plutonium/interaction/runs/executor.rb`
579
+ - Create: `lib/plutonium/interaction/runs/job.rb`
580
+ - Test: `test/plutonium/interaction/runs/executor_test.rb`
581
+
582
+ **Acceptance Criteria:**
583
+ - [ ] `on_failure :continue` records the failure and processes remaining targets
584
+ - [ ] `on_failure :halt` stops at the first error and marks the run failed
585
+ - [ ] `on_failure :transactional` rolls every target back on any error
586
+ - [ ] Missing targets are recorded as per-target failures (not skipped)
587
+ - [ ] `progress_done` advances as targets complete
588
+ - [ ] An opaque run calls `perform` once and needs no targets
589
+
590
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/executor_test.rb` → 0 failures
591
+
592
+ **Steps:**
593
+
594
+ - [ ] **Step 1: Write the failing test**
595
+
596
+ ```ruby
597
+ # test/plutonium/interaction/runs/executor_test.rb
598
+ require "test_helper"
599
+
600
+ class ContinueRun < Plutonium::Interaction::Run
601
+ on_failure :continue
602
+ def perform_on(post)
603
+ raise "boom" if post.title.include?("bad")
604
+ post.touch
605
+ end
606
+ end
607
+
608
+ class HaltRun < Plutonium::Interaction::Run
609
+ on_failure :halt
610
+ def perform_on(post) = raise("boom")
611
+ end
612
+
613
+ class OpaqueOkRun < Plutonium::Interaction::Run
614
+ cattr_accessor :ran
615
+ def perform = self.class.ran = true
616
+ end
617
+
618
+ class Plutonium::Interaction::Runs::ExecutorTest < ActiveSupport::TestCase
619
+ include IntegrationTestHelper
620
+
621
+ setup do
622
+ @org = create_organization!
623
+ @user = create_user!
624
+ create_membership!(organization: @org, user: @user)
625
+ @good = create_post!(user: @user, organization: @org, title: "good one")
626
+ @bad = create_post!(user: @user, organization: @org, title: "bad one")
627
+ end
628
+
629
+ def run!(klass, ids)
630
+ run = klass.create!(
631
+ initiator: @user, scoped_entity: @org,
632
+ target_type: "Blogging::Post", target_ids: ids,
633
+ progress_total: ids.size
634
+ )
635
+ Plutonium::Interaction::Runs::Executor.new(run).call
636
+ run.reload
637
+ end
638
+
639
+ test "continue records the failure and keeps going" do
640
+ run = run!(ContinueRun, [@bad.id, @good.id])
641
+ assert_equal "completed", run.state
642
+ assert_equal 1, run.errors_log.size
643
+ assert_equal 2, run.progress_done
644
+ end
645
+
646
+ test "halt stops at the first error" do
647
+ run = run!(HaltRun, [@bad.id, @good.id])
648
+ assert_equal "failed", run.state
649
+ assert_equal 1, run.errors_log.size
650
+ end
651
+
652
+ test "a missing target is recorded, not skipped" do
653
+ run = run!(ContinueRun, [@good.id, 999_999])
654
+ assert_equal 1, run.errors_log.size
655
+ assert_match(/no longer available/i, run.errors_log.first["message"])
656
+ end
657
+
658
+ test "an opaque run performs once" do
659
+ OpaqueOkRun.ran = false
660
+ run = OpaqueOkRun.create!(initiator: @user, scoped_entity: @org)
661
+ Plutonium::Interaction::Runs::Executor.new(run).call
662
+ assert OpaqueOkRun.ran
663
+ assert_equal "completed", run.reload.state
664
+ end
665
+ end
666
+ ```
667
+
668
+ - [ ] **Step 2: Run it and watch it fail**
669
+
670
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/executor_test.rb`
671
+ Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs::Executor`
672
+
673
+ - [ ] **Step 3: Implement the executor**
674
+
675
+ ```ruby
676
+ # lib/plutonium/interaction/runs/executor.rb
677
+ # frozen_string_literal: true
678
+
679
+ module Plutonium
680
+ module Interaction
681
+ module Runs
682
+ # Runs a run. Separate from the model so the record stays a record, and
683
+ # separate from the job so it can be driven synchronously in tests.
684
+ class Executor
685
+ def initialize(run)
686
+ @run = run
687
+ @context = Context.new(run)
688
+ end
689
+
690
+ attr_reader :run, :context
691
+
692
+ def call
693
+ run.start!
694
+ run.targeted? ? perform_targets : perform_opaque
695
+ rescue => e
696
+ run.fail!(e.message)
697
+ end
698
+
699
+ private
700
+
701
+ def perform_opaque
702
+ run.perform
703
+ run.finish!
704
+ end
705
+
706
+ def perform_targets
707
+ resolved = context.targets
708
+
709
+ # Recorded, never skipped: "3 targets were no longer available" is
710
+ # what tells an operator the run under-applied.
711
+ resolved.missing_ids.each do |id|
712
+ run.record_target_failure!(id: id, message: "Target #{id} was no longer available")
713
+ end
714
+
715
+ if run.failure_policy == :transactional
716
+ run.class.transaction { resolved.records.each { |record| perform_one(record, reraise: true) } }
717
+ else
718
+ resolved.records.each do |record|
719
+ break if perform_one(record) == :halt
720
+ end
721
+ end
722
+
723
+ run.errors_log.any? && run.failure_policy == :halt ? run.fail! : run.finish!
724
+ end
725
+
726
+ # @return [Symbol, nil] :halt when the policy says to stop
727
+ def perform_one(record, reraise: false)
728
+ run.perform_on(record)
729
+ run.progress_done += 1
730
+ run.save!
731
+ nil
732
+ rescue => e
733
+ raise e if reraise
734
+
735
+ run.record_target_failure!(id: record.id, message: e.message)
736
+ run.progress_done += 1
737
+ run.save!
738
+ (run.failure_policy == :halt) ? :halt : nil
739
+ end
740
+ end
741
+ end
742
+ end
743
+ end
744
+ ```
745
+
746
+ - [ ] **Step 4: Add the job**
747
+
748
+ ```ruby
749
+ # lib/plutonium/interaction/runs/job.rb
750
+ # frozen_string_literal: true
751
+
752
+ module Plutonium
753
+ module Interaction
754
+ module Runs
755
+ # The only thing that crosses the process boundary is the run's id. Every
756
+ # scrap of context — who, which tenant, which targets — is re-read from the
757
+ # row, so nothing is inherited from the dispatching request.
758
+ class Job < ActiveJob::Base
759
+ def perform(run_id)
760
+ run = Plutonium::Interaction::Run.find_by(id: run_id)
761
+ return if run.nil? || !run.in_progress?
762
+
763
+ Executor.new(run).call
764
+ end
765
+ end
766
+ end
767
+ end
768
+ end
769
+ ```
770
+
771
+ - [ ] **Step 5: Run the test to verify it passes**
772
+
773
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/executor_test.rb`
774
+ Expected: PASS, 4 runs 0 failures
775
+
776
+ - [ ] **Step 6: Commit**
777
+
778
+ ```bash
779
+ git add lib/plutonium/interaction/runs/executor.rb lib/plutonium/interaction/runs/job.rb test/plutonium/interaction/runs/executor_test.rb
780
+ git commit -m "feat(runs): executor with continue/halt/transactional failure policies"
781
+ ```
782
+
783
+ ---
784
+
785
+ ### Task 5: Interactions dispatch
786
+
787
+ **Goal:** `dispatches_to` turns an interaction into a dispatcher — it creates the run, enqueues it, and redirects.
788
+
789
+ **Files:**
790
+ - Create: `lib/plutonium/interaction/concerns/dispatchable.rb`
791
+ - Modify: `lib/plutonium/interaction/base.rb` (include the concern)
792
+ - Test: `test/plutonium/interaction/dispatchable_test.rb`
793
+
794
+ **Acceptance Criteria:**
795
+ - [ ] `dispatches_to SomeRun` makes `execute` create and enqueue a run
796
+ - [ ] The interaction's validated attributes land in `options`
797
+ - [ ] `initiator` and `scoped_entity` are taken from the interaction's context
798
+ - [ ] The outcome is a success whose response redirects to the run
799
+ - [ ] Validation failure enqueues nothing
800
+
801
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/dispatchable_test.rb` → 0 failures
802
+
803
+ **Steps:**
804
+
805
+ - [ ] **Step 1: Write the failing test**
806
+
807
+ ```ruby
808
+ # test/plutonium/interaction/dispatchable_test.rb
809
+ require "test_helper"
810
+
811
+ class DispatchDemoInteraction < Plutonium::Resource::Interaction
812
+ dispatches_to TestPostRun
813
+ attribute :notify_users, :boolean
814
+ validates :notify_users, inclusion: {in: [true, false]}
815
+ end
816
+
817
+ class Plutonium::Interaction::DispatchableTest < ActiveSupport::TestCase
818
+ include IntegrationTestHelper
819
+
820
+ setup do
821
+ @org = create_organization!
822
+ @user = create_user!
823
+ create_membership!(organization: @org, user: @user)
824
+ @post = create_post!(user: @user, organization: @org)
825
+ end
826
+
827
+ test "dispatch creates a run carrying the validated options" do
828
+ outcome = nil
829
+ assert_difference -> { Plutonium::Interaction::Run.count }, 1 do
830
+ outcome = DispatchDemoInteraction.call(
831
+ view_context: nil, notify_users: true,
832
+ initiator: @user, scoped_entity: @org,
833
+ target_type: "Blogging::Post", target_ids: [@post.id]
834
+ )
835
+ end
836
+
837
+ assert outcome.success?
838
+ run = Plutonium::Interaction::Run.last
839
+ assert_equal "TestPostRun", run.type
840
+ assert_equal true, run.options["notify_users"]
841
+ assert_equal @user, run.initiator
842
+ assert_equal @org, run.scoped_entity
843
+ assert_equal [@post.id], run.target_ids
844
+ end
845
+
846
+ test "an invalid interaction enqueues nothing" do
847
+ assert_no_difference -> { Plutonium::Interaction::Run.count } do
848
+ outcome = DispatchDemoInteraction.call(view_context: nil, notify_users: nil, initiator: @user)
849
+ assert outcome.failure?
850
+ end
851
+ end
852
+ end
853
+ ```
854
+
855
+ - [ ] **Step 2: Run it and watch it fail**
856
+
857
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/dispatchable_test.rb`
858
+ Expected: FAIL — `undefined method 'dispatches_to'`
859
+
860
+ - [ ] **Step 3: Implement the concern**
861
+
862
+ ```ruby
863
+ # lib/plutonium/interaction/concerns/dispatchable.rb
864
+ # frozen_string_literal: true
865
+
866
+ module Plutonium
867
+ module Interaction
868
+ module Concerns
869
+ # Turns an interaction into a dispatcher.
870
+ #
871
+ # The interaction keeps its whole existing job — declare inputs, validate,
872
+ # authorize, render a form — and gains one new behaviour: on execute it
873
+ # persists a run and enqueues it.
874
+ #
875
+ # Note what does NOT change: `call` still returns an Outcome synchronously,
876
+ # because dispatching is what succeeded. That is why Outcome needs no
877
+ # Pending state — the work's completion is the RUN's business, reported on
878
+ # its own page, not this request's.
879
+ module Dispatchable
880
+ extend ActiveSupport::Concern
881
+
882
+ class_methods do
883
+ def dispatches_to(run_class)
884
+ @run_class = run_class
885
+ end
886
+
887
+ def run_class = @run_class
888
+ end
889
+
890
+ included do
891
+ # Dispatch context, supplied by the controller alongside the user's
892
+ # own inputs. Kept as attributes rather than read from a global so the
893
+ # interaction stays callable outside a request (and testable).
894
+ attribute :initiator
895
+ attribute :scoped_entity
896
+ attribute :target_type, :string
897
+ attribute :target_ids, default: -> { [] }
898
+ end
899
+
900
+ private
901
+
902
+ # Everything the user declared, minus the dispatch plumbing above.
903
+ def dispatch_options
904
+ except = %w[initiator scoped_entity target_type target_ids]
905
+ attributes.except(*except)
906
+ end
907
+
908
+ def dispatch!
909
+ run = self.class.run_class.create!(
910
+ initiator: initiator,
911
+ scoped_entity: scoped_entity,
912
+ target_type: target_type,
913
+ target_ids: Array(target_ids),
914
+ progress_total: Array(target_ids).presence&.size,
915
+ options: dispatch_options
916
+ )
917
+ Plutonium::Interaction::Runs::Job
918
+ .set(queue: Plutonium.configuration.interaction_runs.queue)
919
+ .perform_later(run.id)
920
+ run
921
+ end
922
+
923
+ def execute
924
+ run = dispatch!
925
+ succeed(run).with_redirect_response(run)
926
+ end
927
+ end
928
+ end
929
+ end
930
+ end
931
+ ```
932
+
933
+ - [ ] **Step 4: Include it**
934
+
935
+ In `lib/plutonium/interaction/base.rb`, alongside the other includes in the class body:
936
+
937
+ ```ruby
938
+ include Plutonium::Interaction::Concerns::Dispatchable
939
+ ```
940
+
941
+ - [ ] **Step 5: Run the test to verify it passes**
942
+
943
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/dispatchable_test.rb`
944
+ Expected: PASS, 2 runs 0 failures
945
+
946
+ If `with_redirect_response` is not the codebase's helper name, check `lib/plutonium/interaction/outcome.rb` for the actual redirect helper and use it — do not add a second one.
947
+
948
+ - [ ] **Step 6: Commit**
949
+
950
+ ```bash
951
+ git add lib/plutonium/interaction/concerns/dispatchable.rb lib/plutonium/interaction/base.rb test/plutonium/interaction/dispatchable_test.rb
952
+ git commit -m "feat(runs): dispatches_to — interactions enqueue a run and redirect to it"
953
+ ```
954
+
955
+ ---
956
+
957
+ ### Task 6: The run as a resource, and its progress page
958
+
959
+ > **AS BUILT — the steps below are the original draft and are wrong in five
960
+ > places.** Recorded here because the plan is a record, not a spec, once a task
961
+ > has landed. Implemented in `0615ef3d`; each delta was verified as forced by a
962
+ > framework constraint, not chosen:
963
+ >
964
+ > - **`RunDefinition` / `RunPolicy`, not `Runs::Definition` / `Runs::Policy`.**
965
+ > Definition lookup is hard-coded: `"#{resource_class}Definition".constantize`
966
+ > (`controllers/defineable.rb:14`, `resource/controller.rb:233`). A namespaced
967
+ > definition is never found, and `Definition::Base.model_class` infers the
968
+ > model back out of that same name.
969
+ > - **`Run` must `include Plutonium::Resource::Record`**, above the `belongs_to`s.
970
+ > `Resource::Register#register` raises otherwise (`register.rb:25`).
971
+ > - **`Run` needs a `model_name` override.** The default spells
972
+ > `/admin/plutonium/interaction/runs` into every URL. Pinned to the base class
973
+ > so an STI subclass resolves the one registered route.
974
+ > - **`associated_with` had to be written by hand.** The generic one matches
975
+ > `assoc.klass.name == record.class.name unless assoc.polymorphic?`
976
+ > (`record/associated_with.rb:49`), so it cannot see `scoped_entity` and raises
977
+ > for every host tenant model. Replaced with `where(scoped_entity: entity)`.
978
+ > - **Registration needs `controller:`** — `register_resource
979
+ > ::Plutonium::Interaction::Run, controller: "interaction_runs"`. Without it the
980
+ > path derives to `plutonium/interaction/runs`, so the host must define
981
+ > `MyPortal::Plutonium::Interaction::RunsController`, and `MyPortal::Plutonium`
982
+ > then shadows the gem's `::Plutonium` for every constant looked up lexically
983
+ > inside `module MyPortal` — breaking the portal's own
984
+ > `include Plutonium::Portal::Controller`. One additive line in
985
+ > `routing/route_set_extensions.rb` supports it.
986
+ >
987
+ > Also: **no `relation_scope` is declared at all.** Tenancy lives on the model's
988
+ > `associated_with`, which the base policy's macro already routes through
989
+ > `default_relation_scope` — so the rule is in one place rather than restated
990
+ > where it could drift. And the `turbo_poll_interval` data attribute below does
991
+ > not exist; polling is a Stimulus controller rendered *inside* the frame, which
992
+ > the server only emits while `run.in_progress?`, so the timer stops when the
993
+ > replacement markup arrives without it.
994
+
995
+ **Goal:** A portal can register runs; the show page is the progress page and refreshes itself.
996
+
997
+ **Files:**
998
+ - Create: `lib/plutonium/interaction/runs/definition.rb`
999
+ - Create: `lib/plutonium/interaction/runs/policy.rb`
1000
+ - Create: `lib/plutonium/ui/interaction/run_progress.rb`
1001
+ - Test: `test/plutonium/interaction/runs/policy_test.rb`
1002
+ - Test: `test/integration/admin_portal/interaction_run_progress_test.rb`
1003
+
1004
+ **Acceptance Criteria:**
1005
+ - [ ] `relation_scope` returns only runs in the current entity scope
1006
+ - [ ] A user cannot see another tenant's run
1007
+ - [ ] The show page renders state, progress and any recorded failures
1008
+ - [ ] While in progress the page carries a self-refreshing turbo-frame; when finished it does not
1009
+ - [ ] Progress renders as a spinner (not 0%) when `progress_total` is nil
1010
+
1011
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/policy_test.rb test/integration/admin_portal/interaction_run_progress_test.rb` → 0 failures
1012
+
1013
+ **Steps:**
1014
+
1015
+ - [ ] **Step 1: Write the failing policy test**
1016
+
1017
+ ```ruby
1018
+ # test/plutonium/interaction/runs/policy_test.rb
1019
+ require "test_helper"
1020
+
1021
+ class Plutonium::Interaction::Runs::PolicyTest < ActiveSupport::TestCase
1022
+ include IntegrationTestHelper
1023
+
1024
+ setup do
1025
+ @org = create_organization!
1026
+ @other = create_organization!
1027
+ @user = create_user!
1028
+ create_membership!(organization: @org, user: @user)
1029
+
1030
+ @mine = TestPostRun.create!(initiator: @user, scoped_entity: @org)
1031
+ @theirs = TestPostRun.create!(initiator: @user, scoped_entity: @other)
1032
+ end
1033
+
1034
+ test "a run is visible only inside its own entity scope" do
1035
+ scope = Plutonium::Interaction::Runs::Policy
1036
+ .new(Plutonium::Interaction::Run, user: @user, entity_scope: @org)
1037
+ .relation_scope(Plutonium::Interaction::Run.all)
1038
+
1039
+ assert_includes scope, @mine
1040
+ refute_includes scope, @theirs,
1041
+ "a run from another tenant must never be listed"
1042
+ end
1043
+ end
1044
+ ```
1045
+
1046
+ - [ ] **Step 2: Run it and watch it fail**
1047
+
1048
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/policy_test.rb`
1049
+ Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs::Policy`
1050
+
1051
+ - [ ] **Step 3: Implement the policy**
1052
+
1053
+ ```ruby
1054
+ # lib/plutonium/interaction/runs/policy.rb
1055
+ # frozen_string_literal: true
1056
+
1057
+ module Plutonium
1058
+ module Interaction
1059
+ module Runs
1060
+ # Runs are read-only to users: they are a record of something that
1061
+ # happened, so there is no create/update/destroy surface. Visibility is
1062
+ # scoped by the entity the run was dispatched in — the same boundary the
1063
+ # work itself respected.
1064
+ class Policy < Plutonium::Resource::Policy
1065
+ def index? = true
1066
+
1067
+ def show? = true
1068
+
1069
+ def create? = false
1070
+
1071
+ def update? = false
1072
+
1073
+ def destroy? = false
1074
+
1075
+ # The MACRO form, not `def relation_scope`. An instance method of that
1076
+ # name overrides nothing — apply_scope dispatches to the macro-generated
1077
+ # __scoping__active_record_relation__default — so the narrowing would
1078
+ # silently never run. Since PR #96 that mistake raises at class load
1079
+ # rather than failing open.
1080
+ #
1081
+ # Consequences of being a block rather than a method: `super` is not
1082
+ # available (call default_relation_scope, which also satisfies the
1083
+ # multi-tenancy guard), and `return` would return from the enclosing
1084
+ # method, so use `next`.
1085
+ relation_scope do |relation|
1086
+ scope = default_relation_scope(relation)
1087
+ next scope unless entity_scope
1088
+
1089
+ scope.where(scoped_entity_type: entity_scope.class.name, scoped_entity_id: entity_scope.id.to_s)
1090
+ end
1091
+
1092
+ def permitted_attributes_for_read
1093
+ %i[type state progress_done progress_total started_at finished_at initiator]
1094
+ end
1095
+ end
1096
+ end
1097
+ end
1098
+ end
1099
+ ```
1100
+
1101
+ - [ ] **Step 4: Implement the definition**
1102
+
1103
+ ```ruby
1104
+ # lib/plutonium/interaction/runs/definition.rb
1105
+ # frozen_string_literal: true
1106
+
1107
+ module Plutonium
1108
+ module Interaction
1109
+ module Runs
1110
+ class Definition < Plutonium::Resource::Definition
1111
+ field :type, as: :string
1112
+ field :state, as: :string
1113
+ field :started_at, as: :datetime
1114
+ field :finished_at, as: :datetime
1115
+
1116
+ display :state, as: :badge
1117
+
1118
+ column :type
1119
+ column :state
1120
+ column :started_at
1121
+ end
1122
+ end
1123
+ end
1124
+ end
1125
+ ```
1126
+
1127
+ - [ ] **Step 5: Implement the progress panel**
1128
+
1129
+ ```ruby
1130
+ # lib/plutonium/ui/interaction/run_progress.rb
1131
+ # frozen_string_literal: true
1132
+
1133
+ module Plutonium
1134
+ module UI
1135
+ module Interaction
1136
+ # The progress panel on a run's show page.
1137
+ #
1138
+ # Polls by default: a turbo-frame with a refresh interval needs no
1139
+ # ActionCable, so this works on any deployment. A run may opt into
1140
+ # realtime and reuse Kanban::Broadcaster, whose stream names are already
1141
+ # keyed on both resource_class AND scoped_entity so tenants can never
1142
+ # share a stream.
1143
+ #
1144
+ # The frame only refreshes WHILE the run is in progress — a finished run
1145
+ # is a static record, and leaving the poll running would be a background
1146
+ # request per viewer forever.
1147
+ class RunProgress < Plutonium::UI::Component::Base
1148
+ def initialize(run) = @run = run
1149
+
1150
+ attr_reader :run
1151
+
1152
+ def view_template
1153
+ turbo_frame_tag(dom_id(run), **frame_options) do
1154
+ div(class: "space-y-3") do
1155
+ render_state
1156
+ render_progress
1157
+ render_failures if run.errors_log.any?
1158
+ end
1159
+ end
1160
+ end
1161
+
1162
+ private
1163
+
1164
+ def frame_options
1165
+ return {} unless run.in_progress?
1166
+
1167
+ {src: resource_url_for(run), loading: :lazy, data: {turbo_poll_interval: 2000}}
1168
+ end
1169
+
1170
+ def render_state
1171
+ div(class: "text-sm font-medium") { plain run.state.humanize }
1172
+ end
1173
+
1174
+ def render_progress
1175
+ fraction = run.progress_fraction
1176
+ if fraction.nil?
1177
+ # Indeterminate: opaque work has no denominator, and a 0% bar would
1178
+ # read as "nothing has happened" rather than "unknown".
1179
+ div(class: "text-xs text-[var(--pu-text-muted)]") { plain "Working…" }
1180
+ else
1181
+ div(class: "w-full h-2 rounded bg-[var(--pu-surface-alt)]") do
1182
+ div(class: "h-2 rounded bg-primary-600", style: "width: #{(fraction * 100).round}%")
1183
+ end
1184
+ div(class: "text-xs text-[var(--pu-text-muted)]") { plain "#{run.progress_done} of #{run.progress_total}" }
1185
+ end
1186
+ end
1187
+
1188
+ def render_failures
1189
+ ul(class: "text-xs text-danger-600 space-y-1") do
1190
+ run.errors_log.each { |entry| li { plain entry["message"] } }
1191
+ end
1192
+ end
1193
+ end
1194
+ end
1195
+ end
1196
+ end
1197
+ ```
1198
+
1199
+ - [ ] **Step 6: Write the integration test**
1200
+
1201
+ ```ruby
1202
+ # test/integration/admin_portal/interaction_run_progress_test.rb
1203
+ require "test_helper"
1204
+
1205
+ class AdminPortal::InteractionRunProgressTest < ActionDispatch::IntegrationTest
1206
+ include IntegrationTestHelper
1207
+
1208
+ setup do
1209
+ @admin = create_admin!
1210
+ login_as_admin(@admin)
1211
+ @org = create_organization!
1212
+ @user = create_user!
1213
+ end
1214
+
1215
+ test "an in-progress run polls; a finished one does not" do
1216
+ run = TestPostRun.create!(initiator: @user, scoped_entity: @org, state: "running",
1217
+ progress_total: 4, progress_done: 1)
1218
+
1219
+ get "/admin/interaction_runs/#{run.id}"
1220
+ assert_response :success
1221
+ assert_match(/turbo-poll-interval/, response.body,
1222
+ "an in-progress run must refresh itself")
1223
+
1224
+ run.finish!
1225
+ get "/admin/interaction_runs/#{run.id}"
1226
+ refute_match(/turbo-poll-interval/, response.body,
1227
+ "a finished run is static; polling forever is a request per viewer")
1228
+ end
1229
+ end
1230
+ ```
1231
+
1232
+ - [ ] **Step 7: Register the resource in the dummy admin portal**
1233
+
1234
+ In `test/dummy/packages/admin_portal/config/routes.rb`, beside the other `register_resource` calls:
1235
+
1236
+ ```ruby
1237
+ register_resource ::Plutonium::Interaction::Run
1238
+ ```
1239
+
1240
+ - [ ] **Step 8: Run both tests to verify they pass**
1241
+
1242
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/policy_test.rb test/integration/admin_portal/interaction_run_progress_test.rb`
1243
+ Expected: PASS, 0 failures
1244
+
1245
+ - [ ] **Step 9: Commit**
1246
+
1247
+ ```bash
1248
+ git add lib/plutonium/interaction/runs/definition.rb lib/plutonium/interaction/runs/policy.rb lib/plutonium/ui/interaction/run_progress.rb test/plutonium/interaction/runs/policy_test.rb test/integration/admin_portal/interaction_run_progress_test.rb test/dummy/packages/admin_portal/config/routes.rb
1249
+ git commit -m "feat(runs): run as a resource, with a self-refreshing progress page"
1250
+ ```
1251
+
1252
+ ---
1253
+
1254
+ ### Task 7: In-progress runs on the target index
1255
+
1256
+ **Goal:** A resource's index lists runs currently working on it, at the top.
1257
+
1258
+ **Files:**
1259
+ - Create: `lib/plutonium/ui/interaction/running_banner.rb`
1260
+ - Modify: `lib/plutonium/ui/page/index.rb` (render the banner above the collection)
1261
+ - Test: `test/integration/admin_portal/interaction_run_banner_test.rb`
1262
+
1263
+ **Acceptance Criteria:**
1264
+ - [ ] The index shows a banner listing in-progress runs whose `target_type` is this resource
1265
+ - [ ] Completed runs do not appear
1266
+ - [ ] Runs targeting a different resource do not appear
1267
+ - [ ] Another tenant's run does not appear
1268
+ - [ ] No banner renders when there are none (no empty chrome)
1269
+
1270
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/interaction_run_banner_test.rb` → 0 failures
1271
+
1272
+ **Steps:**
1273
+
1274
+ - [ ] **Step 1: Write the failing test**
1275
+
1276
+ ```ruby
1277
+ # test/integration/admin_portal/interaction_run_banner_test.rb
1278
+ require "test_helper"
1279
+
1280
+ class AdminPortal::InteractionRunBannerTest < ActionDispatch::IntegrationTest
1281
+ include IntegrationTestHelper
1282
+
1283
+ setup do
1284
+ @admin = create_admin!
1285
+ login_as_admin(@admin)
1286
+ @user = create_user!
1287
+ end
1288
+
1289
+ test "the index lists in-progress runs for this resource only" do
1290
+ running = TestPostRun.create!(initiator: @user, target_type: "Blogging::Post", state: "running")
1291
+ done = TestPostRun.create!(initiator: @user, target_type: "Blogging::Post", state: "completed")
1292
+ other = TestPostRun.create!(initiator: @user, target_type: "Catalog::Product", state: "running")
1293
+
1294
+ get "/admin/blogging/posts"
1295
+ assert_response :success
1296
+
1297
+ assert_match(/pu-running-banner/, response.body)
1298
+ assert_match(/data-run-id="#{running.id}"/, response.body)
1299
+ refute_match(/data-run-id="#{done.id}"/, response.body, "a finished run is not in progress")
1300
+ refute_match(/data-run-id="#{other.id}"/, response.body, "that run targets another resource")
1301
+ end
1302
+
1303
+ test "no banner renders when nothing is running" do
1304
+ get "/admin/blogging/posts"
1305
+ assert_response :success
1306
+ refute_match(/pu-running-banner/, response.body, "an empty banner is chrome for nothing")
1307
+ end
1308
+ end
1309
+ ```
1310
+
1311
+ - [ ] **Step 2: Run it and watch it fail**
1312
+
1313
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/interaction_run_banner_test.rb`
1314
+ Expected: FAIL — no `pu-running-banner` in the body
1315
+
1316
+ - [ ] **Step 3: Implement the banner**
1317
+
1318
+ ```ruby
1319
+ # lib/plutonium/ui/interaction/running_banner.rb
1320
+ # frozen_string_literal: true
1321
+
1322
+ module Plutonium
1323
+ module UI
1324
+ module Interaction
1325
+ # Lists runs currently working on THIS resource, above its collection.
1326
+ #
1327
+ # Queried on target_type — which is why that is a real column rather than
1328
+ # a key inside the options JSON: this predicate has to be indexable.
1329
+ #
1330
+ # Renders nothing when there is nothing running. An always-present empty
1331
+ # banner is chrome that teaches the user to ignore the area.
1332
+ class RunningBanner < Plutonium::UI::Component::Base
1333
+ def initialize(resource_class:, runs:)
1334
+ @resource_class = resource_class
1335
+ @runs = runs
1336
+ end
1337
+
1338
+ attr_reader :resource_class, :runs
1339
+
1340
+ def view_template
1341
+ return if runs.empty?
1342
+
1343
+ div(class: "pu-running-banner mb-3 rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] bg-[var(--pu-surface-alt)] p-3 space-y-2") do
1344
+ runs.each { |run| render_run(run) }
1345
+ end
1346
+ end
1347
+
1348
+ private
1349
+
1350
+ def render_run(run)
1351
+ div(class: "flex items-center justify-between gap-3 text-sm", data: {run_id: run.id}) do
1352
+ span { plain "#{run.type.titleize} — #{run.state}" }
1353
+ a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary") { plain "View progress" }
1354
+ end
1355
+ end
1356
+ end
1357
+ end
1358
+ end
1359
+ end
1360
+ ```
1361
+
1362
+ - [ ] **Step 4: Render it from the index page**
1363
+
1364
+ In `lib/plutonium/ui/page/index.rb`, immediately above where the collection renders:
1365
+
1366
+ ```ruby
1367
+ if Plutonium.configuration.interaction_runs.enabled
1368
+ render Plutonium::UI::Interaction::RunningBanner.new(
1369
+ resource_class: resource_class,
1370
+ runs: current_authorized_scope_for(Plutonium::Interaction::Run)
1371
+ .for_target(resource_class).in_progress.to_a
1372
+ )
1373
+ end
1374
+ ```
1375
+
1376
+ Use the index page's existing authorized-scope helper — `grep -n "authorized_scope" lib/plutonium/ui/page/index.rb` — so the banner is scoped exactly as the collection is, and a run from another tenant can never appear.
1377
+
1378
+ - [ ] **Step 5: Run the test to verify it passes**
1379
+
1380
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/interaction_run_banner_test.rb`
1381
+ Expected: PASS, 2 runs 0 failures
1382
+
1383
+ - [ ] **Step 6: Run the full suite and commit**
1384
+
1385
+ ```bash
1386
+ bundle exec appraisal rails-8.1 rake test
1387
+ bundle exec standardrb
1388
+ git add lib/plutonium/ui/interaction/running_banner.rb lib/plutonium/ui/page/index.rb test/integration/admin_portal/interaction_run_banner_test.rb
1389
+ git commit -m "feat(runs): list in-progress runs above a resource's collection"
1390
+ ```
1391
+
1392
+ ---
1393
+
1394
+ ## Deliberately deferred
1395
+
1396
+ **Opt-in realtime broadcast.** The spec offers it alongside polling; this plan
1397
+ ships polling only. Polling satisfies the requirement on every deployment,
1398
+ broadcast is an optimisation, and adding it now would put an ActionCable path
1399
+ into a subsystem whose authorization story is the risky part. When it is wanted,
1400
+ it is additive: `Kanban::Broadcaster` already builds tenant-safe stream names
1401
+ from `(resource_class, scoped_entity)`, and `RunProgress` already branches on
1402
+ `run.in_progress?` — the broadcast case slots into that branch.
1403
+
1404
+ The spec's own out-of-scope list (scheduling, approvals, retries, import/export
1405
+ run types, sweeping) stands unchanged.
1406
+
1407
+ ## Final verification
1408
+
1409
+ ```bash
1410
+ bundle exec appraisal rake test # all three Rails versions
1411
+ bundle exec standardrb
1412
+ ```
1413
+
1414
+ Expected: 0 failures across rails-7, rails-8.0 and rails-8.1; standardrb clean.