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,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # A persisted interaction run.
7
+ #
8
+ # STI base: authors subclass to define execution and failure policy, and the
9
+ # subclass name lands in +type+. Options are JSON, so an async action costs
10
+ # no migration.
11
+ #
12
+ # This class deliberately holds NO execution logic — that lives in the
13
+ # runs executor. It is the record; the executor is the behaviour. Keeping
14
+ # them apart is what lets the job rebuild an authorization context around
15
+ # the work without the model knowing anything about policies.
16
+ class Run < ActiveRecord::Base
17
+ # Before the belongs_to declarations below: Record::Associations decorates
18
+ # belongs_to/has_many to generate the sgid accessors, so an association
19
+ # declared above the include gets none. It is also what makes a run
20
+ # registerable as a resource — Resource::Register refuses anything that
21
+ # does not include this.
22
+ include Plutonium::Resource::Record
23
+
24
+ self.table_name = "plutonium_async_runs"
25
+
26
+ STATES = %w[pending running completed failed].freeze
27
+ IN_PROGRESS_STATES = %w[pending running].freeze
28
+ FAILURE_POLICIES = %i[halt continue transactional].freeze
29
+
30
+ # Routes, paths and helpers are all derived from model_name, and the
31
+ # default would spell the gem's own namespace out in every URL
32
+ # (/admin/plutonium/interaction/runs) and every helper
33
+ # (plutonium_async_run_path). Pinned to the BASE class rather than
34
+ # +self+ so every STI subclass routes to the one registered resource:
35
+ # resource_url_for(a TestPostRun) has to find the Run's route config.
36
+ #
37
+ # "Run" rather than the shorter "Run" on purpose: a bare "Run"
38
+ # would squat the most generic name available and collide silently with a
39
+ # host app's own Run model (CI runs, ML training runs, delivery runs) on
40
+ # every axis that matters -- route path, controller name, param_key and
41
+ # i18n key are all identical, and nothing in Resource::Register detects it.
42
+ MODEL_NAME = ActiveModel::Name.new(self, nil, "AsyncRun")
43
+
44
+ def self.model_name = MODEL_NAME
45
+
46
+ # Failure policy, declared by subclasses via +on_failure+ and consumed by
47
+ # the executor. A class_attribute rather than a plain class ivar so a
48
+ # deep STI hierarchy (B < A < Run) inherits what A declared.
49
+ class_attribute :failure_policy, instance_writer: false, default: :halt
50
+
51
+ belongs_to :initiator, polymorphic: true
52
+ # The nested-route parent, present only for a dispatch from a nested
53
+ # route. Paired with +parent_association+; see the migration.
54
+ belongs_to :parent, polymorphic: true, optional: true
55
+ # Nullable by design: only entity-scoped portals have a tenant. nil means
56
+ # "no tenant", NOT "tenant unknown".
57
+ belongs_to :scoped_entity, polymorphic: true, optional: true
58
+
59
+ validates :state, inclusion: {in: STATES}
60
+
61
+ # Guarantees its own job gets enqueued once this row is durably visible
62
+ # (same shape as InviteToken#send_invitation_email) — not done by the
63
+ # caller, which would race a fast/inline job adapter against the commit.
64
+ after_commit :enqueue_job, on: :create
65
+
66
+ scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }
67
+ scope :for_target, ->(klass) { where(target_type: klass.to_s) }
68
+
69
+ # In progress with no recorded activity since +before+ — see
70
+ # Async::ReapJob. +last_activity_at+ is nil until a job actually claims
71
+ # the run (see Async::Executor#claim!), so a run never picked up at all
72
+ # falls back to +created_at+ — otherwise it would never match and a
73
+ # dropped enqueue would sit "pending" forever, unreapable.
74
+ scope :stalled, ->(before:) {
75
+ in_progress.where("COALESCE(last_activity_at, created_at) < ?", before)
76
+ }
77
+
78
+ # Overrides the generic scope Record::AssociatedWith installs, which
79
+ # resolves a tenant by walking reflections. Both of this table's links to
80
+ # a tenant are POLYMORPHIC, and that walk skips polymorphic associations
81
+ # (it cannot know the class) — so the generic version raises
82
+ # "could not resolve the association" for every host tenant model.
83
+ #
84
+ # The tenant a run belongs to is not a fact about the object graph anyway:
85
+ # it is the scope the run was DISPATCHED in, recorded on the row. Reading
86
+ # it from the row is what makes the tenant filter mean the same thing here
87
+ # as it did at dispatch, for any host tenant model, with no host-side
88
+ # scope to define.
89
+ #
90
+ # A nil scoped_entity is therefore excluded from every tenant, which is
91
+ # the correct direction: nil means "dispatched outside any tenant", so it
92
+ # belongs to none of them.
93
+ scope :associated_with, ->(entity) { where(scoped_entity: entity) }
94
+
95
+ # Declares how the executor reacts to a target failure. Rejects an unknown
96
+ # policy at class-definition time rather than letting a typo surface as
97
+ # surprise behaviour deep inside a background job.
98
+ #
99
+ # @param policy [Symbol] one of {FAILURE_POLICIES}
100
+ # @return [void]
101
+ def self.on_failure(policy)
102
+ unless FAILURE_POLICIES.include?(policy)
103
+ raise ArgumentError,
104
+ "unknown failure policy: #{policy.inspect} (expected one of #{FAILURE_POLICIES.map(&:inspect).join(", ")})"
105
+ end
106
+
107
+ self.failure_policy = policy
108
+ end
109
+
110
+ def start! = update!(state: "running", started_at: Time.current, last_activity_at: Time.current)
111
+
112
+ def finish! = update!(state: "completed", finished_at: Time.current, last_activity_at: Time.current)
113
+
114
+ def fail!(message = nil)
115
+ record_target_failure!(id: nil, message: message) if message
116
+ update!(state: "failed", finished_at: Time.current, last_activity_at: Time.current)
117
+ end
118
+
119
+ def in_progress? = IN_PROGRESS_STATES.include?(state)
120
+
121
+ # What actually happened, as opposed to how the executor exited.
122
+ #
123
+ # A :continue run that could not apply some of its targets ends as
124
+ # +completed+ (see Async::Executor#perform_targets) — the author declared
125
+ # partial application acceptable and the loop ran to the end. But
126
+ # "completed" on its own is what a clean success looks like, so rendering
127
+ # it alone would make a run that under-applied indistinguishable from one
128
+ # that did everything asked of it. Every reader — badge, table column,
129
+ # progress panel — goes through this instead of through +state+.
130
+ #
131
+ # @return [String]
132
+ def outcome
133
+ (state == "completed" && errors_log.any?) ? "completed_with_errors" : state
134
+ end
135
+
136
+ # @return [Integer] recorded target failures, run-level entries included
137
+ def error_count = errors_log.size
138
+
139
+ # Human, I18n-aware name of the target resource class — "Post", not
140
+ # "Blogging::Post" — for display (see Async::RunDefinition). Falls back to the
141
+ # raw string for a target_type renamed/removed since this run was
142
+ # dispatched, rather than raising on an old row. nil for opaque
143
+ # (untargeted) work.
144
+ #
145
+ # @return [String, nil]
146
+ def target_label
147
+ return nil if target_type.nil?
148
+
149
+ target_type.constantize.model_name.human
150
+ rescue NameError
151
+ target_type
152
+ end
153
+
154
+ # Runs have no name or title, so Labeling would fall back to
155
+ # "Async run #12" — true but silent about the only thing that
156
+ # distinguishes one row from the next.
157
+ #
158
+ # Demodulized: a host's run class is as likely to be
159
+ # Billing::ReissueInvoicesRun as a top-level one, and the namespace adds
160
+ # nothing to a label that already sits under the run's own breadcrumb.
161
+ #
162
+ # Except when demodulizing is what throws the name away. A run declared
163
+ # by Dispatchable#async is Blogging::ArchivePosts::Run, and every
164
+ # one of them would render "Run #12" — the failure mode this method
165
+ # exists to avoid, reintroduced for the shape most authors will write.
166
+ # For those, the enclosing segment IS the name, so it is folded back in.
167
+ #
168
+ # @return [String]
169
+ def to_label
170
+ parts = self.class.name.split("::")
171
+ name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
172
+ "#{name.titleize} ##{to_param}"
173
+ end
174
+
175
+ # nil means INDETERMINATE, not zero: opaque work has no denominator, and
176
+ # the progress UI renders a spinner rather than a 0% bar.
177
+ #
178
+ # @return [Float, nil]
179
+ def progress_fraction
180
+ return nil if progress_total.nil? || progress_total.zero?
181
+
182
+ progress_done.to_f / progress_total
183
+ end
184
+
185
+ # Appends a failure to +errors_log+ and persists immediately, preserving
186
+ # prior entries. It writes rather than staging because the information it
187
+ # carries — which targets the run could not act on — must survive an early
188
+ # return, a rescue that never reaches {#fail!}, or a caller that simply
189
+ # forgets to save. A silently-dropped entry is indistinguishable from a
190
+ # clean run, which is the one thing an operator must never be told.
191
+ #
192
+ # A nil +id+ is the RUN-LEVEL sentinel: the failure is the whole run's
193
+ # (see {#fail!}), not one target's — it advances neither progress_done
194
+ # nor handled_target_ids. Readers grouping the log by target must treat
195
+ # nil as its own bucket rather than a target id.
196
+ def record_target_failure!(id:, message:)
197
+ record_target_failures!([{id: id, message: message}])
198
+ end
199
+
200
+ # Appends several failures in ONE write — folding in the progress bump
201
+ # and handled_target_ids for every entry that names a real target, so a
202
+ # crash between separate writes can't leave them out of sync with each
203
+ # other.
204
+ #
205
+ # The singular form persists on every call, so a loop over M ids is M
206
+ # writes, each rewriting the whole errors_log JSON — O(M²) bytes. The
207
+ # executor resolves every unavailable target in one pass before it
208
+ # performs anything (see Async::Executor#record_unresolved), and that
209
+ # batch is what this exists for.
210
+ #
211
+ # @param entries [Array<Hash>] +{id:, message:}+ pairs
212
+ def record_target_failures!(entries)
213
+ return if entries.empty?
214
+
215
+ target_entries = entries.reject { |entry| entry[:id].nil? }
216
+ appended = entries.map { |entry| {"target_id" => entry[:id], "message" => entry[:message]} }
217
+ update!(
218
+ errors_log: errors_log + appended,
219
+ progress_done: progress_done + target_entries.size,
220
+ handled_target_ids: handled_target_ids + target_entries.map { |entry| entry[:id].to_s },
221
+ last_activity_at: Time.current
222
+ )
223
+ end
224
+
225
+ # Target ids not yet dispositioned — what a resumed run still has to
226
+ # do. See {#record_target_failures!} and Async::Executor#advance!, which
227
+ # are what populate handled_target_ids.
228
+ #
229
+ # @return [Array]
230
+ def unhandled_target_ids
231
+ handled = handled_target_ids.map(&:to_s).to_set
232
+ target_ids.reject { |id| handled.include?(id.to_s) }
233
+ end
234
+
235
+ # The interaction's validated inputs, with the types dispatch put in.
236
+ #
237
+ # The column is JSON, so Dispatchable#dispatch_options writes it through
238
+ # ActiveJob::Arguments; this is the other half. Without it a Date comes
239
+ # back a String and a BigDecimal comes back a String, which is the kind of
240
+ # bug that survives every test written against a run whose options happen
241
+ # to be strings anyway.
242
+ #
243
+ # Falls back to the raw hash for a row written before this existed, and
244
+ # for one an operator edited by hand — neither carries the envelope keys
245
+ # deserialize expects, and refusing to read them would take out the
246
+ # progress page as well as the work.
247
+ #
248
+ # @return [Hash]
249
+ def options
250
+ raw = super
251
+ return raw unless raw.is_a?(Hash)
252
+
253
+ ::ActiveJob::Arguments.deserialize([raw]).first
254
+ rescue ::ActiveJob::DeserializationError
255
+ raw
256
+ end
257
+
258
+ # An attachment attribute, revived from the token dispatch staged.
259
+ #
260
+ # A file cannot ride the options column, so dispatch uploads it to the
261
+ # backend's cache and stores the token (see
262
+ # Dispatchable#stage_dispatch_attachments). +options["file"]+ is therefore
263
+ # that token — a String — and this is what turns it back into something
264
+ # with a filename and bytes.
265
+ #
266
+ # async do
267
+ # def perform
268
+ # CSV.foreach(attachment(:import_file).url) { |row| ... }
269
+ # end
270
+ # end
271
+ #
272
+ # Deliberately not folded into +options+. The token is what is actually
273
+ # stored, and reviving it reaches storage — which the progress page reads
274
+ # options without wanting to do.
275
+ #
276
+ # @param key [Symbol, String] the attribute name
277
+ # @return [Plutonium::Attachments::Resolved, nil] nil if nothing was
278
+ # staged, or the token no longer resolves
279
+ def attachment(key) = attachments(key).first
280
+
281
+ # Every attachment staged under +key+, for a multiple-file attribute.
282
+ #
283
+ # @param key [Symbol, String]
284
+ # @return [Array<Plutonium::Attachments::Resolved>]
285
+ def attachments(key) = Plutonium::Attachments.resolve(options[key.to_s])
286
+
287
+ # Says "still working" — for work the executor cannot see inside.
288
+ #
289
+ # Async::ReapJob treats a run silent past +stall_after+ as dead and resumes
290
+ # it. Every write the executor makes refreshes that clock, so a targeted
291
+ # run whose targets are quick heartbeats once per target for free. Two
292
+ # shapes of work get nothing:
293
+ #
294
+ # * OPAQUE work. Between the claim and {#finish!} the executor writes
295
+ # nothing at all, because there is nothing to count. An opaque +perform+
296
+ # that outlives stall_after is reaped mid-flight, and — having no
297
+ # handled_target_ids to resume from — is re-run FROM SCRATCH.
298
+ # * A single +perform_on+ that outlives stall_after on its own.
299
+ #
300
+ # So a run that does either must say so itself. This is deliberately not
301
+ # automatic: a background thread would have to guess how often to write,
302
+ # and would keep reporting a wedged worker as healthy. Only the work knows
303
+ # it is making progress.
304
+ #
305
+ # def perform
306
+ # invoices.each_slice(500) do |slice|
307
+ # reissue(slice)
308
+ # heartbeat!
309
+ # end
310
+ # end
311
+ #
312
+ # It also ANSWERS: the write is conditional on this instance still holding
313
+ # the row's lock_version, so a worker superseded while inside a long
314
+ # +perform+ learns at its next beat rather than at the end — see
315
+ # Async::Executor#superseded?, which turns that into "no longer mine"
316
+ # instead of a failure. That is the only place a long opaque run can find
317
+ # out at all.
318
+ #
319
+ # The beat must NOT bump lock_version — that is the executor's own record
320
+ # it would be invalidating, making its next save! raise against a row
321
+ # nobody else touched. Which rules out more than it looks like:
322
+ #
323
+ # * +touch+ and +update!+ both bump it (Locking::Optimistic hooks
324
+ # _touch_row as well as _update_record).
325
+ # * so does +update_all+ GIVEN A HASH. Rails silently adds the increment
326
+ # when the model locks and the hash does not mention the column — see
327
+ # ActiveRecord::Relation#update_all. Only the string/array form escapes
328
+ # it, going through sanitize_sql_for_assignment untouched, which is why
329
+ # this and Async::Executor#claim! both spell their SQL out.
330
+ #
331
+ # The in-memory attribute is left alone for a related reason: nothing
332
+ # reads it, and assigning it would put a change on the record that the
333
+ # caller never asked to save.
334
+ #
335
+ # Call it on SELF, which is what +perform+/+perform_on+ already hand the
336
+ # author. The conditional reads this instance's lock_version, and the
337
+ # executor's own writes keep that current; a separately loaded copy
338
+ # (Run.find(id)) goes stale at the first advance! and would raise here
339
+ # having been superseded by nobody.
340
+ #
341
+ # Under the +:transactional+ policy the beat is inside the batch's
342
+ # transaction like everything else, so it is invisible to the reaper until
343
+ # the batch commits. An all-or-nothing batch longer than stall_after is
344
+ # reapable no matter what this does.
345
+ #
346
+ # @raise [ActiveRecord::StaleObjectError] if another executor owns the run
347
+ # @return [Time] the recorded activity time
348
+ def heartbeat!
349
+ now = Time.current
350
+ written = self.class.where(id: id, lock_version: lock_version)
351
+ .update_all(["last_activity_at = ?", now])
352
+ raise ActiveRecord::StaleObjectError.new(self, "heartbeat") if written.zero?
353
+
354
+ now
355
+ end
356
+
357
+ # Which shape of work this is, decided by what the subclass implements
358
+ # rather than a mode flag — one less thing for an author to keep in sync.
359
+ #
360
+ # Non-public methods count. `private def perform_on(record)` is a natural
361
+ # idiom for work only the executor is meant to invoke, and Ruby's
362
+ # public-only respond_to? default would read that as opaque work — routing
363
+ # it to #perform, which the base class raises NotImplementedError for. The
364
+ # author would see every dispatch fail on a run whose perform_on is right
365
+ # there. Async::Executor invokes both through send to match.
366
+ def targeted? = respond_to?(:perform_on, true)
367
+
368
+ # Opaque (untargeted) work. Subclasses override this, or +perform_on+ for
369
+ # per-target work.
370
+ #
371
+ # Defined here so a subclass that implements NEITHER is diagnosed by name
372
+ # rather than surfacing as a NoMethodError from inside the executor's loop,
373
+ # where nothing in the message says which class is at fault.
374
+ def perform
375
+ raise NotImplementedError,
376
+ "#{self.class} implements neither #perform nor #perform_on: define " \
377
+ "#perform_on(record) for work over targets, or #perform for opaque work"
378
+ end
379
+
380
+ private
381
+
382
+ # Not swallowed: after_commit runs synchronously, so re-raising here
383
+ # still surfaces to the dispatching request rather than leaving a
384
+ # permanently invisible, un-enqueued ghost.
385
+ def enqueue_job
386
+ Plutonium::Interaction::Async::Job.perform_later(id)
387
+ rescue
388
+ fail!("could not be enqueued for execution")
389
+ raise
390
+ end
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # The UI configuration for run records.
7
+ #
8
+ # Named +RunDefinition+ rather than +Async::Definition+ because definition
9
+ # lookup is by convention and nothing else: both
10
+ # Plutonium::Resource::Controller#resource_definition and
11
+ # Plutonium::Resource::Controllers::Defineable#resource_definition
12
+ # constantize "#{resource_class}Definition", and
13
+ # Plutonium::Definition::Base.model_class infers the model back out of that
14
+ # same name. A differently-named class would simply never be found.
15
+ #
16
+ # Host apps override per portal the usual way, with
17
+ # +MyPortal::Plutonium::Interaction::Async::RunDefinition+.
18
+ class RunDefinition < Plutonium::Resource::Definition
19
+ # `outcome` rather than `state`: a :continue run that could not apply some
20
+ # of its targets ends as "completed", and a green Completed badge on a run
21
+ # that under-applied is exactly the report an operator must never be given.
22
+ # See Plutonium::Interaction::Async::Run#outcome.
23
+ # Only `outcome` is declared. Everything else the policy makes readable is
24
+ # a real column and infers its own type; `outcome` is a method, so it has
25
+ # neither a type to infer nor a badge to infer one from (`state` is a
26
+ # plain string column with an inclusion validation, not a Rails enum).
27
+ #
28
+ # `running` and `completed_with_errors` are coloured explicitly: neither
29
+ # is in the badge component's semantic table, and its fallback is a
30
+ # stable-but-meaningless colour.
31
+ field :outcome, as: :string
32
+ display :outcome, as: :badge,
33
+ colors: {running: :info, completed_with_errors: :warning}
34
+ # Declared for the table too: the index is where an operator scans for the
35
+ # run that did not do what it was asked, and a bare
36
+ # "completed_with_errors" string is the one place that reads worse than
37
+ # the badge.
38
+ column :outcome, as: :badge,
39
+ colors: {running: :info, completed_with_errors: :warning}
40
+
41
+ field :target_label, label: "Target type"
42
+
43
+ # The details tab, with the live panel above the fields.
44
+ #
45
+ # On the Display rather than the ShowPage: page-level
46
+ # render_before_content sits outside the tablist and outside the modal
47
+ # chrome, so a run opened in a modal would render its progress above the
48
+ # dialog instead of inside it.
49
+ class Display < Display
50
+ private
51
+
52
+ def render_before_fields
53
+ render Plutonium::UI::Interaction::Async::RunProgress.new(resource_record!)
54
+ end
55
+ end
56
+
57
+ class ShowPage < ShowPage
58
+ # Answers the progress frame's poll with the panel ALONE.
59
+ #
60
+ # Turbo sends +Turbo-Frame: <id>+ when the frame re-fetches its src, and
61
+ # DynaFrameContent wraps every response in a frame of that name. Falling
62
+ # through to the normal page would therefore return the whole show page
63
+ # wrapped in a frame carrying the progress frame's id — with the panel's
64
+ # own frame nested inside it, under the same id. Turbo would swap the
65
+ # outer one in, and the next poll would nest again.
66
+ def view_template(&)
67
+ return super unless progress_poll?
68
+
69
+ DynaFrameContent do
70
+ render Plutonium::UI::Interaction::Async::RunProgress.new(resource_record!)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def progress_poll?
77
+ current_turbo_frame == Plutonium::UI::Interaction::Async::RunProgress.frame_id(resource_record!)
78
+ end
79
+ end
80
+
81
+ # The index refreshes itself while any run is still working.
82
+ #
83
+ # ONE frame around the collection, not one per row. A +turbo-frame+ is
84
+ # not in the content model of +tr+, so a frame wrapping a row is hoisted
85
+ # out of the table by the HTML parser before Turbo ever sees it. A frame
86
+ # per CELL parses, but buys one poller per row for a single page.
87
+ #
88
+ # The Stimulus controller sits INSIDE the frame, exactly as
89
+ # {Plutonium::UI::Interaction::Async::RunProgress} places it: a frame
90
+ # navigation replaces the frame's CONTENTS, not the frame element, so a
91
+ # controller on the tag itself could never be removed and the timer
92
+ # would never stop.
93
+ class IndexPage < IndexPage
94
+ include Phlex::Rails::Helpers::TurboFrameTag
95
+
96
+ FRAME_ID = "pu_async_runs_index"
97
+
98
+ # Answers the frame's poll with the collection ALONE, for the reason
99
+ # spelled out on {ShowPage}: DynaFrameContent already wraps every
100
+ # response in a frame named by the inbound header, so emitting the
101
+ # page would nest the whole thing inside its own frame, and again on
102
+ # the next poll.
103
+ def view_template(&)
104
+ return super unless answering_own_frame?
105
+
106
+ DynaFrameContent() do
107
+ render_default_content
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def answering_own_frame? = current_turbo_frame == FRAME_ID
114
+
115
+ def render_default_content
116
+ body = proc { div(**poll_attributes) { super() } }
117
+ return body.call if answering_own_frame?
118
+
119
+ # `turbo-frame` defaults to `display: inline`.
120
+ turbo_frame_tag(FRAME_ID, class: "block", &body)
121
+ end
122
+
123
+ # Absent once nothing is working: the refreshed markup no longer
124
+ # carries the controller, and the timer stops of its own accord. The
125
+ # index needs no equivalent of RunProgress's +finished+ reload — the
126
+ # whole collection is inside this frame, so there are no stale fields
127
+ # beside it to catch up.
128
+ #
129
+ # The URL is the CURRENT one, filters, sort and page included. Polling
130
+ # the bare index would answer page 1 unfiltered and swap that into a
131
+ # frame the operator had scoped to something else.
132
+ def poll_attributes
133
+ return {} unless any_run_in_progress?
134
+
135
+ {
136
+ data: {
137
+ controller: "run-progress",
138
+ run_progress_url_value: current_page_url,
139
+ run_progress_interval_value: Plutonium::UI::Interaction::Async::RunProgress::POLL_INTERVAL_MS
140
+ }
141
+ }
142
+ end
143
+
144
+ # Asked of the SCOPE rather than the rendered page, through the same
145
+ # door render_running_banner uses. It can only over-poll (an operator
146
+ # reading last month's finished runs while something works elsewhere),
147
+ # never under-poll: a run visible and in progress makes this true.
148
+ def any_run_in_progress?
149
+ authorized_resource_scope(Run, relation: Run.in_progress).exists?
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # Authorization for run records themselves.
7
+ #
8
+ # Named +RunPolicy+ rather than +Policy+ so ActionPolicy's ordinary
9
+ # inference finds it: +INFER_FROM_CLASS+ looks for "#{record_class}Policy",
10
+ # and Plutonium's STI fallback looks for "#{base_class}Policy" — which is
11
+ # what routes a +TestPostRun+ (or any host subclass) to this one policy
12
+ # without every subclass declaring anything.
13
+ #
14
+ # == Read-only
15
+ #
16
+ # A run is the record of something that already happened. There is nothing
17
+ # to create through a form (a run is born from a dispatching interaction,
18
+ # which has its own policy), nothing to edit that would not be rewriting the
19
+ # audit trail, and deleting one destroys the only report of what a bulk
20
+ # action actually did. The four write predicates are therefore hard +false+
21
+ # rather than inherited defaults — subclasses can still open them, but they
22
+ # have to say so.
23
+ #
24
+ # == Scope
25
+ #
26
+ # Nothing is declared here. The base policy's +relation_scope+ already calls
27
+ # +default_relation_scope+, which applies +Run.associated_with(entity_scope)+
28
+ # — and that scope filters on the tenant the run RECORDED at dispatch (see
29
+ # Run). Re-declaring the macro to repeat that filter by hand would add a
30
+ # second, drifting definition of the same rule; declaring it as a
31
+ # +def relation_scope+ instance method would be worse still, since
32
+ # +apply_scope+ never calls one (Plutonium::Resource::Policy.method_added
33
+ # raises on that mistake at class load).
34
+ class RunPolicy < Plutonium::Resource::Policy
35
+ def read? = true
36
+
37
+ def create? = false
38
+
39
+ def update? = false
40
+
41
+ def destroy? = false
42
+
43
+ # Deliberately NOT the autodetected column list.
44
+ #
45
+ # +options+ is arbitrary JSON copied from the dispatching interaction's
46
+ # validated inputs — reasons, notes, recipient lists, whatever the author
47
+ # declared. The policy that governed who could SUBMIT those values was the
48
+ # interaction's; the set of people who can READ this run is different and
49
+ # wider (in a tenant portal, everyone in the tenant). Autodetection would
50
+ # quietly hand the second set the first set's input, so the readable
51
+ # attributes are enumerated instead, and +options+ is not among them.
52
+ #
53
+ # +target_ids+ is out for the same reason at one remove: it is a list of
54
+ # primary keys that were never filtered through the target resource's own
55
+ # policy for THIS reader.
56
+ # +outcome+ stands in for +state+, and +state+ is deliberately absent: the
57
+ # two differ only for a run that completed having failed some of its
58
+ # targets, and that is precisely the case where the raw column reads
59
+ # "completed" and tells the reader the opposite of what happened.
60
+ #
61
+ # +errors_log+ is absent because the progress panel renders it as a list —
62
+ # the raw JSON column beside it would be the same report twice, once
63
+ # unreadably.
64
+ def permitted_attributes_for_read
65
+ %i[
66
+ type outcome progress_done progress_total
67
+ target_label initiator started_at finished_at created_at
68
+ ]
69
+ end
70
+
71
+ # The show page drops the progress COUNTERS, for the reason errors_log
72
+ # is absent above: the panel already renders them, as "5 of 5 targets
73
+ # (100%)" over a bar. Two renderings of one number is the same report
74
+ # twice — and they disagree, because only the panel is inside the polled
75
+ # frame. A page that says "Completed" and "0 done" at once is worse than
76
+ # either alone.
77
+ #
78
+ # The INDEX keeps them. There is no progress bar in a table row, so the
79
+ # counters are the only progress it can show.
80
+ def permitted_attributes_for_show
81
+ permitted_attributes_for_read - %i[progress_done progress_total]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end