plutonium 0.67.0 → 0.69.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  4. data/.claude/skills/plutonium-resource/SKILL.md +3 -2
  5. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  6. data/CHANGELOG.md +33 -0
  7. data/SECURITY.md +1 -1
  8. data/app/assets/plutonium.css +3 -1
  9. data/config/locales/en/async.yml +11 -1
  10. data/config/locales/en/dashboard.yml +1 -0
  11. data/config/locales/en/ui.yml +3 -0
  12. data/docs/public/images/reference/async-progress-page.png +0 -0
  13. data/docs/public/images/reference/async-running-banner.png +0 -0
  14. data/docs/reference/behavior/async-interactions.md +62 -5
  15. data/docs/reference/dashboard/dsl.md +54 -3
  16. data/docs/reference/generators/lite.md +30 -6
  17. data/docs/reference/i18n.md +1 -1
  18. data/docs/reference/resource/definition.md +2 -0
  19. data/docs/reference/ui/forms.md +23 -0
  20. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  21. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  22. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  23. data/gemfiles/rails_7.gemfile.lock +5 -5
  24. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  25. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  26. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  27. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  28. data/lib/plutonium/dashboard/base.rb +80 -1
  29. data/lib/plutonium/dashboard/card.rb +12 -13
  30. data/lib/plutonium/dashboard/controller.rb +28 -10
  31. data/lib/plutonium/dashboard/dsl.rb +59 -2
  32. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  33. data/lib/plutonium/dashboard/filter.rb +114 -0
  34. data/lib/plutonium/dashboard/link.rb +35 -0
  35. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  36. data/lib/plutonium/definition/presentable.rb +4 -1
  37. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  38. data/lib/plutonium/interaction/async/context.rb +12 -2
  39. data/lib/plutonium/interaction/async/executor.rb +54 -0
  40. data/lib/plutonium/interaction/async/run.rb +128 -14
  41. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  42. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  43. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  44. data/lib/plutonium/resource/query_object.rb +6 -0
  45. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  46. data/lib/plutonium/ui/avatar.rb +2 -2
  47. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  48. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  49. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  50. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  51. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  52. data/lib/plutonium/ui/form/base.rb +20 -0
  53. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  54. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  55. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  56. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  57. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  58. data/lib/plutonium/ui/form/theme.rb +15 -1
  59. data/lib/plutonium/ui/grid/card.rb +12 -4
  60. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  61. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  62. data/lib/plutonium/ui/layout/base.rb +0 -9
  63. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  64. data/lib/plutonium/ui/page/index.rb +23 -4
  65. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  66. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/plutonium.gemspec +1 -1
  70. data/src/css/components.css +9 -0
  71. data/src/css/font_awesome.css +2328 -0
  72. data/src/css/plutonium.css +1 -0
  73. metadata +16 -4
@@ -31,6 +31,7 @@ module Plutonium
31
31
  # GET the mount — the full page.
32
32
  def show
33
33
  authorize_dashboard!
34
+ ensure_filters_clear_of_path_params!
34
35
  set_page_title(current_dashboard_class.label)
35
36
  render Plutonium::UI::Page::Dashboard.new(dashboard: current_dashboard)
36
37
  end
@@ -38,6 +39,7 @@ module Plutonium
38
39
  # GET <mount>/cards/:card — one card, wrapped in its turbo frame.
39
40
  def card
40
41
  authorize_dashboard!
42
+ ensure_filters_clear_of_path_params!
41
43
  card = current_dashboard.visible_card!(params[:card])
42
44
  render Plutonium::UI::Dashboard::Frame.new(dashboard: current_dashboard, card:)
43
45
  end
@@ -77,24 +79,40 @@ module Plutonium
77
79
  )
78
80
  end
79
81
 
82
+ # A path segment of the mount (the tenant on a path-scoped portal, a
83
+ # `(:locale)` scope) belongs to the route, so it cannot be refused when
84
+ # the filter is declared. A filter under that key would read the
85
+ # segment's value and overwrite it on every card's frame URL. The card
86
+ # route's own segments count even when optional and absent from this
87
+ # request, since its URL helper would fill them.
88
+ def ensure_filters_clear_of_path_params!
89
+ segments = (request.path_parameters.keys.map(&:to_sym) | dashboard_card_route.parts) - %i[controller action]
90
+ collision = current_dashboard_class.filters.map(&:key) & segments
91
+ return if collision.empty?
92
+
93
+ raise ArgumentError,
94
+ "#{current_dashboard_class.name} filter #{collision.first.inspect} collides with a path segment of the dashboard's route, pick another name"
95
+ end
96
+
80
97
  # The card endpoint's path, threading the entity scope segment through so
81
- # the frame stays inside the tenant. Resolved by the route's
82
- # `dashboard_class` default, so `at:` / `as:` and a scope prefix are honoured.
98
+ # the frame stays inside the tenant, and the dashboard's filter values
99
+ # through as query parameters so the card reads what the page read.
100
+ # Resolved by the route's `dashboard_class` default, so `at:` / `as:` and
101
+ # a scope prefix are honoured.
83
102
  def dashboard_card_path(card)
84
- options = {card: card.key}
103
+ options = current_dashboard.filter_values.merge(card: card.key)
85
104
  options[scoped_entity_param_key] = params[scoped_entity_param_key] if scoped_to_entity?
86
105
  current_engine.routes.url_helpers.public_send(dashboard_card_route_helper, **options)
87
106
  end
88
107
 
89
108
  def dashboard_card_route_helper
90
- @dashboard_card_route_helper ||= begin
91
- name = Plutonium::Dashboard::RouteResolution.route_name(
92
- current_engine.routes, current_dashboard_class, action: "card"
93
- )
94
- raise "no register_dashboard route found for #{current_dashboard_class.name}" unless name
109
+ :"#{dashboard_card_route.name}_path"
110
+ end
95
111
 
96
- :"#{name}_path"
97
- end
112
+ def dashboard_card_route
113
+ @dashboard_card_route ||=
114
+ Plutonium::Dashboard::RouteResolution.route(current_engine.routes, current_dashboard_class, action: "card") ||
115
+ raise("no register_dashboard route found for #{current_dashboard_class.name}")
98
116
  end
99
117
  end
100
118
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Plutonium
4
4
  module Dashboard
5
- # The author-facing class macros: `metric`, `chart`, `card`, `refresh`
6
- # and `width`. Mixed into {Base}.
5
+ # The author-facing class macros: `metric`, `chart`, `card`, `filter`,
6
+ # `link`, `refresh` and `width`. Mixed into {Base}.
7
7
  module DSL
8
8
  extend ActiveSupport::Concern
9
9
 
@@ -47,6 +47,61 @@ module Plutonium
47
47
  add_card(key, kind: :custom, options:, block:)
48
48
  end
49
49
 
50
+ # The declared filters, in declaration order.
51
+ def filters
52
+ @filters ||= []
53
+ end
54
+
55
+ # The declared toolbar links, in declaration order.
56
+ def links
57
+ @links ||= []
58
+ end
59
+
60
+ # A page-level control every card reads, such as a period or a region.
61
+ # The chosen value comes from the query string, is carried onto every
62
+ # lazy card's frame URL so each card sees what the page saw, and is
63
+ # read in a card block with `filter_value(key)`. The page renders the
64
+ # filters as a segmented control above the cards.
65
+ #
66
+ # filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days"}, default: "30"
67
+ # metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
68
+ #
69
+ # `scopes:` takes a resource instead of choices: "All" plus the scopes
70
+ # its definition declares. `filtered(Post)` then counts what the index
71
+ # would list, and `filtered_url(Post)` opens that list.
72
+ #
73
+ # filter :status, scopes: Post
74
+ # metric(:posts, href: -> { filtered_url(Post) }) { filtered(Post).count }
75
+ def filter(key, choices: nil, scopes: nil, default: nil, label: nil)
76
+ key = key.to_sym
77
+ raise ArgumentError, "#{name || "dashboard"} already declares a filter #{key.inspect}" if find_filter(key)
78
+ if scopes && filters.any? { |filter| filter.resource_class == scopes }
79
+ raise ArgumentError, "#{name || "dashboard"} already declares a scope filter for #{scopes}"
80
+ end
81
+
82
+ filters << Filter.new(key, choices:, scopes:, default:, label:)
83
+ filters.last
84
+ end
85
+
86
+ # A link in the toolbar above the cards, such as the dashboard's
87
+ # settings or a full report. `href:` and `condition:` are evaluated on
88
+ # the dashboard instance, like a card's.
89
+ #
90
+ # link :settings, href: -> { resource_url_for(Setting) }, icon: Phlex::TablerIcons::Settings
91
+ def link(key, href:, label: nil, icon: nil, condition: nil)
92
+ key = key.to_sym
93
+ raise ArgumentError, "#{name || "dashboard"} already declares a link #{key.inspect}" if links.any? { |link| link.key == key }
94
+
95
+ links << Link.new(key, href:, label:, icon:, condition:)
96
+ links.last
97
+ end
98
+
99
+ # @return [Filter, nil]
100
+ def find_filter(key)
101
+ key = key.to_sym
102
+ filters.find { |filter| filter.key == key }
103
+ end
104
+
50
105
  # Default refresh interval (seconds) for every lazy card. A card's own
51
106
  # `refresh:` overrides it; `refresh: false` on a card opts out.
52
107
  def refresh(seconds = UNSET)
@@ -95,6 +150,8 @@ module Plutonium
95
150
  def inherited(subclass)
96
151
  super
97
152
  subclass.instance_variable_set(:@cards, cards.dup)
153
+ subclass.instance_variable_set(:@filters, filters.dup)
154
+ subclass.instance_variable_set(:@links, links.dup)
98
155
  subclass.instance_variable_set(:@refresh, @refresh)
99
156
  subclass.instance_variable_set(:@width, @width)
100
157
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # How a card or toolbar link runs its author-supplied procs and
6
+ # `condition:` against the dashboard instance, so both accept the same
7
+ # forms.
8
+ module Evaluation
9
+ private
10
+
11
+ # A zero-arity proc runs on the dashboard instance; a proc taking an
12
+ # argument also receives the dashboard.
13
+ def evaluate_on(dashboard, callable)
14
+ callable.arity.zero? ? dashboard.instance_exec(&callable) : dashboard.instance_exec(dashboard, &callable)
15
+ end
16
+
17
+ # `condition:` is nil (always), a proc (see {#evaluate_on}), a symbol
18
+ # naming a dashboard method, or a plain value.
19
+ def condition_passes?(dashboard, condition)
20
+ result = case condition
21
+ when nil then true
22
+ when Symbol then dashboard.send(condition)
23
+ when Proc then evaluate_on(dashboard, condition)
24
+ else condition
25
+ end
26
+ result ? true : false
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # A page-level control every card reads, declared with `filter`. Its value
6
+ # comes from the request's query string under the filter's key, and a value
7
+ # that is not one of the choices reads as the default, so a hand-edited URL
8
+ # can never reach a card block.
9
+ #
10
+ # The choices are either given (`choices:`) or are a resource's scopes
11
+ # (`scopes: Post`): "All" plus every scope the resource's definition
12
+ # declares, labelled and gated the way the index's scope pills are.
13
+ class Filter
14
+ # Keys a filter cannot take: the card route's own params and the options
15
+ # `url_for` reads, all of which the filter values are merged into when
16
+ # building each card's frame URL. A key that is a path segment of the
17
+ # mount (an entity scope, a `(:locale)` scope) is refused per request,
18
+ # since only the route knows it.
19
+ RESERVED_KEYS = %i[
20
+ card dashboard_class controller action format anchor params path_params
21
+ host protocol port subdomain domain tld_length user password
22
+ script_name original_script_name only_path trailing_slash
23
+ use_route _recall
24
+ ].freeze
25
+
26
+ # The value of a scope filter's "All" choice.
27
+ ALL = "all"
28
+
29
+ attr_reader :key, :resource_class
30
+
31
+ # @param choices [Hash{#to_s => String, Proc}, nil] value => label, in display order
32
+ # @param scopes [Class, nil] a resource whose definition's scopes are the choices
33
+ # @param default [#to_s, nil] one of the choices; the first (or, for
34
+ # scopes, the definition's `default_scope`, else "All") when nil
35
+ def initialize(key, choices: nil, scopes: nil, default: nil, label: nil)
36
+ @key = key.to_sym
37
+ if RESERVED_KEYS.include?(@key)
38
+ raise ArgumentError, "filter #{@key.inspect}: the name is reserved by the card route, pick another"
39
+ end
40
+ unless choices.nil? ^ scopes.nil?
41
+ raise ArgumentError, "filter #{@key.inspect}: pass exactly one of choices: or scopes:"
42
+ end
43
+
44
+ @label = label
45
+ @default = default&.to_s
46
+
47
+ if scopes
48
+ raise ArgumentError, "filter #{@key.inspect}: scopes: takes a resource class, got #{scopes.inspect}" unless scopes.is_a?(Class)
49
+
50
+ @resource_class = scopes
51
+ else
52
+ @choices = choices.to_h.transform_keys(&:to_s).freeze
53
+ raise ArgumentError, "filter #{@key.inspect}: choices must not be empty" if @choices.empty?
54
+
55
+ @default ||= @choices.keys.first
56
+ validate_default!
57
+ end
58
+ end
59
+
60
+ def scopes? = !@resource_class.nil?
61
+
62
+ # The control's accessible name.
63
+ def label
64
+ Plutonium::Translation.resolve(@label) || key.to_s.humanize
65
+ end
66
+
67
+ # value => label for this request. Labels may be lazy `t` procs. Read it
68
+ # through {Base#filter_choices}, which builds it once per request.
69
+ def choices_for(dashboard)
70
+ scopes? ? scope_choices(dashboard) : @choices
71
+ end
72
+
73
+ # An explicit `default:` must be one of the definition's scopes. A
74
+ # default (explicit or the definition's `default_scope`) whose
75
+ # `condition:` hides it from this user reads as "All", as does a
76
+ # `default_scope` the definition never declares as a scope.
77
+ def default_for(dashboard)
78
+ return @default unless scopes?
79
+
80
+ if @default && !dashboard.scope_query_for(resource_class).scope_definitions.key?(@default)
81
+ raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not a scope of #{resource_class}'s definition"
82
+ end
83
+
84
+ default = @default || dashboard.resource_definition_for(resource_class).default_scope&.to_s
85
+ dashboard.filter_choices(self).key?(default) ? default : ALL
86
+ end
87
+
88
+ # The chosen value for this request.
89
+ def value_from(dashboard)
90
+ value = dashboard.params[key].to_s
91
+ dashboard.filter_choices(self).key?(value) ? value : default_for(dashboard)
92
+ end
93
+
94
+ private
95
+
96
+ def scope_choices(dashboard)
97
+ query = dashboard.scope_query_for(resource_class)
98
+ if query.scope_definitions.key?(ALL)
99
+ raise ArgumentError, "filter #{key.inspect}: #{resource_class}'s definition declares a scope named :#{ALL}, which is the filter's \"All\" choice"
100
+ end
101
+
102
+ visible = query.scope_definitions.keys.select { |name| query.scope_visible?(name, dashboard.view_context) }
103
+ {ALL => -> { Plutonium::Translation.t("plutonium.ui.table.all") }}
104
+ .merge(visible.to_h { |name| [name, query.scope_label(name)] })
105
+ end
106
+
107
+ def validate_default!
108
+ return if @choices.key?(@default)
109
+
110
+ raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not one of #{@choices.keys.inspect}"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # A link in the dashboard's toolbar, declared with `link`: the way to a
6
+ # related page such as the dashboard's settings or a full report.
7
+ class Link
8
+ include Evaluation
9
+
10
+ attr_reader :key, :icon
11
+
12
+ def initialize(key, href:, label: nil, icon: nil, condition: nil)
13
+ @key = key.to_sym
14
+ @href = href
15
+ @label = label
16
+ @icon = icon
17
+ @condition = condition
18
+ end
19
+
20
+ def label
21
+ Plutonium::Translation.resolve(@label) || key.to_s.humanize
22
+ end
23
+
24
+ # Evaluated like a card's `href:`.
25
+ def href_for(dashboard)
26
+ @href.is_a?(Proc) ? evaluate_on(dashboard, @href) : @href
27
+ end
28
+
29
+ # Evaluated like a card's `condition:`.
30
+ def visible?(dashboard)
31
+ condition_passes?(dashboard, @condition)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -17,13 +17,17 @@ module Plutonium
17
17
  # @param action [String, Symbol] "show" or "card"
18
18
  # @return [Symbol, nil] the route name (e.g. :sales_dashboard_card)
19
19
  def route_name(route_set, dashboard_class, action:)
20
- route = route_set.routes.find do |r|
20
+ route(route_set, dashboard_class, action:)&.name&.to_sym
21
+ end
22
+
23
+ # @return [ActionDispatch::Journey::Route, nil] the named route itself
24
+ def route(route_set, dashboard_class, action:)
25
+ route_set.routes.find do |r|
21
26
  d = r.defaults
22
27
  r.name.present? &&
23
28
  d[:action].to_s == action.to_s &&
24
29
  d[:dashboard_class].to_s == dashboard_class.name
25
30
  end
26
- route&.name&.to_sym
27
31
  end
28
32
  end
29
33
  end
@@ -36,8 +36,11 @@ module Plutonium
36
36
  # Returns the label for the interaction.
37
37
  #
38
38
  # @return [String] The label defined in the presentation metadata or a default generated from the class name.
39
+ # The conventional "Interaction" suffix is dropped: ImportPostsInteraction reads "Import Posts".
40
+ # Only a suffix: a class named just Interaction keeps its name rather than going blank.
39
41
  def label
40
- Plutonium::Translation.resolve(presentation_metadata[:label]) || name.demodulize.titleize
42
+ Plutonium::Translation.resolve(presentation_metadata[:label]) ||
43
+ name.demodulize.sub(/(?<=.)Interaction\z/, "").titleize
41
44
  end
42
45
 
43
46
  # Returns the icon for the interaction.
@@ -27,10 +27,25 @@ module Plutonium
27
27
  # This is the knob for which backend does that, for runs alone.
28
28
  attr_accessor :attachment_backend
29
29
 
30
+ # @return [Integer] how many pre_check rejections a run stores. The rest
31
+ # are counted and reported as one "and N more" entry, so a file that is
32
+ # bad on every row cannot write tens of thousands of entries into the
33
+ # errors_log column (and onto the progress page). Must be a positive Integer.
34
+ attr_accessor :max_rejections
35
+
36
+ # @return [ActiveSupport::Duration] how long a run stays in the running
37
+ # banner after it finishes, for the user who started it. A dispatch
38
+ # sends that user back to the index, and a short run (or one its
39
+ # pre_check rejected) settles before the page loads; without this it
40
+ # would never show there at all.
41
+ attr_accessor :recent_runs_window
42
+
30
43
  def initialize
31
44
  @enabled = false
32
45
  @queue = :default
33
46
  @stall_after = 1.hour
47
+ @max_rejections = 100
48
+ @recent_runs_window = 10.minutes
34
49
  end
35
50
  end
36
51
  end
@@ -225,9 +225,14 @@ module Plutonium
225
225
  #
226
226
  # @return [Targets]
227
227
  def targets
228
+ unless run.targeted?
229
+ raise UnresolvableError,
230
+ "run #{run.id} (#{run.class}) is not a targeted run; it has no targets to resolve"
231
+ end
232
+
228
233
  unless run.target_type
229
234
  raise UnresolvableError,
230
- "run #{run.id} (#{run.class}) has no target_type; it is not a targeted run"
235
+ "run #{run.id} (#{run.class}) has no target_type; it cannot resolve its targets"
231
236
  end
232
237
 
233
238
  key = target_class.primary_key
@@ -380,8 +385,13 @@ module Plutonium
380
385
  #
381
386
  # Only meaningful for a targeted run — untargeted work has no target
382
387
  # resource and therefore no policy to pin.
388
+ #
389
+ # Keyed on Run#targeted?, not on target_type: an opaque run dispatched
390
+ # from a resource still records target_type (it is what puts the run in
391
+ # that resource's banner), but it resolves no targets, so it pins no
392
+ # policy and there is nothing here to verify.
383
393
  def verify_policy!
384
- return if run.target_type.nil?
394
+ return unless run.targeted? && run.target_type
385
395
 
386
396
  # A targeted run with no recorded policy is not "unasserted", it is
387
397
  # unverifiable: there is nothing to compare today's lookup against, and
@@ -190,6 +190,8 @@ module Plutonium
190
190
  # the framework is meant to call, and it must not change what the
191
191
  # executor does with it.
192
192
  def perform_opaque
193
+ return unless pre_check_passed?
194
+
193
195
  run.send(:perform)
194
196
  run.finish!
195
197
  end
@@ -200,6 +202,17 @@ module Plutonium
200
202
 
201
203
  return refuse_partial_batch(resolved) if unresolved?(resolved) && !continue?
202
204
 
205
+ # A resumed run whose earlier pass already applied a target was
206
+ # approved by that pass's check: targets are only performed after it
207
+ # passes. Re-checking the remainder could reject a run that is
208
+ # half-applied and report "nothing was processed". Opaque runs restart
209
+ # from scratch, so they re-check.
210
+ #
211
+ # After record_unresolved, so a rejected :continue run still reports
212
+ # the targets that had gone; those count toward progress_done, though
213
+ # nothing was performed.
214
+ return unless run.any_target_applied? || pre_check_passed?(resolved.records)
215
+
203
216
  if transactional?
204
217
  perform_all_or_nothing(resolved.records)
205
218
  run.finish!
@@ -217,6 +230,47 @@ module Plutonium
217
230
  end
218
231
  end
219
232
 
233
+ # Runs the run's pre_check with #reject live, and fails the run with
234
+ # whatever it rejected. Before any work: a rejected run touches no
235
+ # target. Outside the :transactional batch, which never opens for a
236
+ # rejected run.
237
+ #
238
+ # An exception from the check is NOT a rejection. It propagates to #call
239
+ # and fails the run the ordinary way, so "your input is wrong" and "the
240
+ # check itself broke" stay distinguishable (Run#outcome).
241
+ #
242
+ # send, for the reason #perform_opaque gives.
243
+ #
244
+ # @return [Boolean] whether the work may go ahead
245
+ def pre_check_passed?(*args)
246
+ verify_pre_check_arity!(args.size)
247
+
248
+ messages, overflow = run.collect_rejections { run.send(:pre_check, *args) }
249
+ return true if messages.empty? && overflow.zero?
250
+
251
+ run.reject_run!(messages, overflow: overflow)
252
+ false
253
+ end
254
+
255
+ # A targeted check declared opaque-style (or the reverse) would raise a
256
+ # bare "wrong number of arguments" from inside the executor, naming
257
+ # neither the class nor the fix.
258
+ #
259
+ # Raised as NotImplementedError ("the run class is written wrong"), which
260
+ # #call records like any other failure.
261
+ def verify_pre_check_arity!(given)
262
+ params = run.method(:pre_check).parameters.group_by(&:first)
263
+ required = params.fetch(:req, []).size
264
+ positional = required + params.fetch(:opt, []).size
265
+ fits = params[:keyreq].nil? && required <= given && (params.key?(:rest) || positional >= given)
266
+ return if fits
267
+
268
+ raise NotImplementedError, I18n.t(
269
+ "plutonium.async.executor.pre_check_arity.#{run.targeted? ? "targeted" : "opaque"}",
270
+ run_class: run.class
271
+ )
272
+ end
273
+
220
274
  # @return [Integer, nil] how many records were never attempted, if the
221
275
  # failure policy stopped the loop early — nil if it ran to the end
222
276
  def perform_each(records)