plutonium 0.68.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/CHANGELOG.md +20 -0
  4. data/SECURITY.md +1 -1
  5. data/app/assets/plutonium.css +3 -1
  6. data/config/locales/en/async.yml +11 -1
  7. data/docs/public/images/reference/async-progress-page.png +0 -0
  8. data/docs/public/images/reference/async-running-banner.png +0 -0
  9. data/docs/reference/behavior/async-interactions.md +62 -5
  10. data/docs/reference/generators/lite.md +30 -6
  11. data/docs/reference/i18n.md +1 -1
  12. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  13. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  14. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  15. data/gemfiles/rails_7.gemfile.lock +5 -5
  16. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  17. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  18. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  19. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  20. data/lib/plutonium/definition/presentable.rb +4 -1
  21. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  22. data/lib/plutonium/interaction/async/context.rb +12 -2
  23. data/lib/plutonium/interaction/async/executor.rb +54 -0
  24. data/lib/plutonium/interaction/async/run.rb +128 -14
  25. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  26. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  27. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  28. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  29. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  30. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  31. data/lib/plutonium/ui/layout/base.rb +0 -9
  32. data/lib/plutonium/ui/page/index.rb +23 -4
  33. data/lib/plutonium/version.rb +1 -1
  34. data/package.json +1 -1
  35. data/src/css/components.css +9 -0
  36. data/src/css/font_awesome.css +2328 -0
  37. data/src/css/plutonium.css +1 -0
  38. metadata +6 -2
@@ -27,6 +27,15 @@ module Plutonium
27
27
  IN_PROGRESS_STATES = %w[pending running].freeze
28
28
  FAILURE_POLICIES = %i[halt continue transactional].freeze
29
29
 
30
+ # Tags the errors_log entries a pre_check rejection writes, which is what
31
+ # #outcome reads "rejected" from. A key in the existing JSON rather than a
32
+ # column: older entries simply lack it.
33
+ PRE_CHECK_STAGE = "pre_check"
34
+
35
+ # Raised by #reject called anywhere but inside pre_check, where nothing
36
+ # would collect the rejection.
37
+ class RejectOutsidePreCheckError < StandardError; end
38
+
30
39
  # Routes, paths and helpers are all derived from model_name, and the
31
40
  # default would spell the gem's own namespace out in every URL
32
41
  # (/admin/plutonium/interaction/runs) and every helper
@@ -65,6 +74,7 @@ module Plutonium
65
74
 
66
75
  scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }
67
76
  scope :for_target, ->(klass) { where(target_type: klass.to_s) }
77
+ scope :finished_since, ->(time) { where.not(state: IN_PROGRESS_STATES).where(finished_at: time..) }
68
78
 
69
79
  # In progress with no recorded activity since +before+ — see
70
80
  # Async::ReapJob. +last_activity_at+ is nil until a job actually claims
@@ -130,9 +140,17 @@ module Plutonium
130
140
  #
131
141
  # @return [String]
132
142
  def outcome
143
+ return "rejected" if rejected?
144
+
133
145
  (state == "completed" && errors_log.any?) ? "completed_with_errors" : state
134
146
  end
135
147
 
148
+ # Failed because its pre_check refused the input, as opposed to failing
149
+ # while doing the work. Nothing was processed. state stays "failed", so
150
+ # every reader keyed on state (ReapJob, in_progress, the banner) is
151
+ # unaffected.
152
+ def rejected? = state == "failed" && errors_log.any? { |entry| entry["stage"] == PRE_CHECK_STAGE }
153
+
136
154
  # @return [Integer] recorded target failures, run-level entries included
137
155
  def error_count = errors_log.size
138
156
 
@@ -153,23 +171,42 @@ module Plutonium
153
171
 
154
172
  # Runs have no name or title, so Labeling would fall back to
155
173
  # "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.
174
+ # distinguishes one row from the next. The dispatching interaction's
175
+ # label is that thing: "Import Posts #12".
167
176
  #
168
177
  # @return [String]
169
178
  def to_label
170
- parts = self.class.name.split("::")
171
- name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
172
- I18n.t("plutonium.async.run.label", name: name.titleize, id: to_param)
179
+ I18n.t("plutonium.async.run.label", name: interaction_label, id: to_param)
180
+ end
181
+
182
+ # The interaction's label (its `presents label:`), or a name derived from
183
+ # the run class for a run with no interaction behind it.
184
+ #
185
+ # @return [String]
186
+ def interaction_label
187
+ interaction_class&.label || run_class_label
188
+ end
189
+
190
+ # The interaction this run belongs to. A run declared by
191
+ # Dispatchable#async is <Interaction>::Run, so it is the enclosing class.
192
+ # nil for a standalone run class, which may be shared by several
193
+ # interactions and so belongs to none of them.
194
+ #
195
+ # @return [Class, nil]
196
+ def interaction_class
197
+ parent = self.class.module_parent
198
+ parent if parent < Plutonium::Interaction::Base
199
+ end
200
+
201
+ # The index's progress cell: "1 / 4", or nil for opaque work. Two
202
+ # counter columns showed opaque work as "0" done of nothing, which reads
203
+ # as a run that did nothing rather than one with no count to report.
204
+ #
205
+ # @return [String, nil]
206
+ def progress
207
+ return nil if progress_total.nil?
208
+
209
+ I18n.t("plutonium.async.run.progress", done: progress_done, total: progress_total)
173
210
  end
174
211
 
175
212
  # nil means INDETERMINATE, not zero: opaque work has no denominator, and
@@ -222,6 +259,53 @@ module Plutonium
222
259
  )
223
260
  end
224
261
 
262
+ # Fails the run with its pre_check's rejections, in ONE write, for the
263
+ # reason record_target_failures! gives: a crash between two writes must
264
+ # not leave a failed run without its reasons, or reasons on a run still
265
+ # marked running. Entries carry the run-level nil target_id, so progress
266
+ # and handled_target_ids are untouched: nothing was processed.
267
+ #
268
+ # @param messages [Array<String>] the rejections that were stored
269
+ # @param overflow [Integer] how many more were rejected past max_rejections
270
+ def reject_run!(messages, overflow: 0)
271
+ entries = messages.map { |message| pre_check_entry(message) }
272
+ entries << pre_check_entry(I18n.t("plutonium.async.executor.rejections_truncated", count: overflow)) if overflow.positive?
273
+
274
+ now = Time.current
275
+ update!(errors_log: errors_log + entries, state: "failed", finished_at: now, last_activity_at: now)
276
+ end
277
+
278
+ # Runs the block with #reject live, and closes it again afterwards.
279
+ # Called by Async::Executor around pre_check.
280
+ #
281
+ # @return [Array(Array<String>, Integer)] the stored messages, and how
282
+ # many more were rejected past max_rejections
283
+ def collect_rejections
284
+ @rejections = []
285
+ @rejection_count = 0
286
+ yield
287
+ [@rejections, @rejection_count - @rejections.size]
288
+ ensure
289
+ @rejections = @rejection_count = nil
290
+ end
291
+
292
+ # Records one problem with the run's input and returns, so a check can
293
+ # report every bad row in one pass. Only live inside pre_check: a
294
+ # rejection from perform would be collected by nothing.
295
+ #
296
+ # Counts past max_rejections but stores no more than that, so memory
297
+ # stays bounded and the "and N more" count is exact.
298
+ #
299
+ # @param message [String]
300
+ # @return [void]
301
+ def reject(message)
302
+ raise RejectOutsidePreCheckError, I18n.t("plutonium.async.run.reject_outside_pre_check", run_class: self.class) if @rejections.nil?
303
+
304
+ @rejection_count += 1
305
+ @rejections << message.to_s if @rejections.size < Plutonium.configuration.async_interactions.max_rejections
306
+ nil
307
+ end
308
+
225
309
  # Target ids not yet dispositioned — what a resumed run still has to
226
310
  # do. See {#record_target_failures!} and Async::Executor#advance!, which
227
311
  # are what populate handled_target_ids.
@@ -232,6 +316,16 @@ module Plutonium
232
316
  target_ids.reject { |id| handled.include?(id.to_s) }
233
317
  end
234
318
 
319
+ # Whether an earlier pass got as far as applying a target, which it can
320
+ # only have done after its pre_check passed. A handled id with no
321
+ # errors_log entry was applied; failures (unresolved or raised) all carry one.
322
+ #
323
+ # @return [Boolean]
324
+ def any_target_applied?
325
+ failed = errors_log.filter_map { |entry| entry["target_id"]&.to_s }.to_set
326
+ handled_target_ids.any? { |id| !failed.include?(id.to_s) }
327
+ end
328
+
235
329
  # The interaction's validated inputs, with the types dispatch put in.
236
330
  #
237
331
  # The column is JSON, so Dispatchable#dispatch_options writes it through
@@ -377,8 +471,28 @@ module Plutonium
377
471
  "#perform_on(record) for work over targets, or #perform for opaque work"
378
472
  end
379
473
 
474
+ # Checks the run's input before any work, and refuses it with #reject.
475
+ # Optional: the default checks nothing. Override it as
476
+ # +pre_check(records)+ in a run with +perform_on+ (it receives the
477
+ # resolved, authorized records), or +pre_check+ for opaque work.
478
+ # See Async::Executor#pre_check_passed?.
479
+ def pre_check(*) = nil
480
+
380
481
  private
381
482
 
483
+ def pre_check_entry(message) = {"target_id" => nil, "message" => message, "stage" => PRE_CHECK_STAGE}
484
+
485
+ # For a standalone run class, passed as `async Billing::ReissueInvoicesRun`,
486
+ # which has no interaction around it to take a label from. Demodulized,
487
+ # since the namespace adds nothing under the run's own breadcrumb. A
488
+ # <Something>::Run that is not inside an interaction keeps the enclosing
489
+ # segment, or every one of them would read "Run".
490
+ def run_class_label
491
+ parts = self.class.name.split("::")
492
+ name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
493
+ name.titleize
494
+ end
495
+
382
496
  # Not swallowed: after_commit runs synchronously, so re-raising here
383
497
  # still surfaces to the dispatching request rather than leaving a
384
498
  # permanently invisible, un-enqueued ghost.
@@ -25,19 +25,20 @@ module Plutonium
25
25
  # neither a type to infer nor a badge to infer one from (`state` is a
26
26
  # plain string column with an inclusion validation, not a Rails enum).
27
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
28
+ # `running`, `completed_with_errors` and `rejected` are coloured explicitly:
29
+ # none is in the badge component's semantic table, and its fallback is a
30
30
  # stable-but-meaningless colour.
31
31
  field :outcome, as: :string
32
32
  display :outcome, as: :badge,
33
- colors: {running: :info, completed_with_errors: :warning}
33
+ colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
34
34
  # Declared for the table too: the index is where an operator scans for the
35
35
  # run that did not do what it was asked, and a bare
36
36
  # "completed_with_errors" string is the one place that reads worse than
37
37
  # the badge.
38
38
  column :outcome, as: :badge,
39
- colors: {running: :info, completed_with_errors: :warning}
39
+ colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
40
40
 
41
+ field :interaction_label, label: t("plutonium.async.run.interaction")
41
42
  field :target_label, label: t("plutonium.async.run.target_type")
42
43
 
43
44
  # The details tab, with the live panel above the fields.
@@ -63,22 +63,22 @@ module Plutonium
63
63
  # unreadably.
64
64
  def permitted_attributes_for_read
65
65
  %i[
66
- type outcome progress_done progress_total
66
+ interaction_label outcome progress
67
67
  target_label initiator started_at finished_at created_at
68
68
  ]
69
69
  end
70
70
 
71
- # The show page drops the progress COUNTERS, for the reason errors_log
71
+ # The show page drops the progress cell, for the reason errors_log
72
72
  # is absent above: the panel already renders them, as "5 of 5 targets
73
73
  # (100%)" over a bar. Two renderings of one number is the same report
74
74
  # twice — and they disagree, because only the panel is inside the polled
75
75
  # frame. A page that says "Completed" and "0 done" at once is worse than
76
76
  # either alone.
77
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.
78
+ # The INDEX keeps it. There is no progress bar in a table row, so the
79
+ # cell is the only progress it can show.
80
80
  def permitted_attributes_for_show
81
- permitted_attributes_for_read - %i[progress_done progress_total]
81
+ permitted_attributes_for_read - %i[progress]
82
82
  end
83
83
  end
84
84
  end
@@ -24,10 +24,13 @@ module Plutonium
24
24
  # appending the options hash raises ArgumentError rather than
25
25
  # producing a formatted URL. A caller handing over a complete URL has
26
26
  # already decided what it points at.
27
+ #
28
+ # Also skipped for a turbo-stream request: it is answered below with a
29
+ # visit, and a visit wants the page, not a .turbo_stream URL.
27
30
  fully_formed_url = redirect_args.size == 1 && redirect_args.first.is_a?(String)
28
31
 
29
32
  url_options = redirect_args.last.is_a?(Hash) ? redirect_args.last : {}
30
- if !fully_formed_url && !url_options.key?(:format) && request.format.symbol != :html
33
+ if !fully_formed_url && !url_options.key?(:format) && !request.format.symbol.in?(%i[html turbo_stream])
31
34
  url_options = url_options.merge(format: request.format.symbol)
32
35
  redirect_args = [*redirect_args[0...-1], url_options] if redirect_args.last.is_a?(Hash)
33
36
  redirect_args = [*redirect_args, url_options] unless redirect_args.last.is_a?(Hash)
@@ -36,14 +39,23 @@ module Plutonium
36
39
  url = fully_formed_url ? redirect_args.first : url_for(*redirect_args)
37
40
 
38
41
  respond_to do |format|
42
+ # Answered with a stream action wherever the form lives, the way
43
+ # every other Plutonium success path answers a turbo-stream post.
44
+ # A plain redirect_to here is followed by fetch and its page
45
+ # rendered into whatever frame submitted the form.
46
+ #
47
+ # turbo_stream_redirect is a client-side visit, so it skips the
48
+ # open-redirect check redirect_to applies. It is applied here the
49
+ # same way, with redirect_to's own default for allow_other_host:
50
+ # resolved up front from raise_on_open_redirects before Rails 8.1,
51
+ # left nil from 8.1 on, where the check resolves it and nil also
52
+ # drives action_on_open_redirect (:log / :notify).
39
53
  format.turbo_stream do
40
- if helpers.current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
41
- render turbo_stream: [
42
- helpers.turbo_stream_redirect(url)
43
- ]
44
- else
45
- redirect_to(url, **redirect_options)
54
+ allow_other_host = redirect_options.fetch(:allow_other_host) do
55
+ _allow_other_host if ActionPack.gem_version < Gem::Version.new("8.1")
46
56
  end
57
+ _enforce_open_redirect_protection(url, allow_other_host:)
58
+ render turbo_stream: helpers.turbo_stream_redirect(url)
47
59
  end
48
60
 
49
61
  format.any { redirect_to(url, **redirect_options) }
@@ -48,10 +48,24 @@ module Plutonium
48
48
  def breadcrumb_items
49
49
  @breadcrumb_items ||= [].tap do |items|
50
50
  collect_parent_breadcrumbs(items) if current_parent.present?
51
- collect_resource_breadcrumbs(items) if resource_record?
51
+ if resource_record?
52
+ collect_resource_breadcrumbs(items)
53
+ elsif collection_page?
54
+ collect_resource_index_breadcrumb(items)
55
+ end
52
56
  end
53
57
  end
54
58
 
59
+ # A page that hangs off the resource list without being about one record:
60
+ # a resource action (import), a wizard launched from the index, or a
61
+ # custom collection route (calendar). It has no record to name, but the
62
+ # list it came from is still a level up. The index itself is excluded
63
+ # because its page title already says where you are, and a singular
64
+ # resource has no index to point back to.
65
+ def collection_page?
66
+ action_name != "index" && !singular_resource_context?
67
+ end
68
+
55
69
  # The segments eligible to be folded into the overflow menu. The last one
56
70
  # is never foldable — it is the deepest thing the trail knows about, which
57
71
  # is the record on new/edit pages but the resource index on show pages,
@@ -151,21 +165,25 @@ module Plutonium
151
165
  end
152
166
 
153
167
  def collect_plural_resource_breadcrumbs(items)
168
+ collect_resource_index_breadcrumb(items)
169
+
170
+ # Record itself (for non-singular routes only)
171
+ return unless resource_record!.persisted? && action_name != "show"
172
+
173
+ items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
174
+ end
175
+
176
+ def collect_resource_index_breadcrumb(items)
154
177
  # No `parent:` here on purpose: Resource::Controller#resource_url_args_for
155
178
  # already defaults it to `current_parent` (and supplies
156
- # `current_nested_association` with it), so both links nest themselves
179
+ # `current_nested_association` with it), so the link nests itself
157
180
  # when the request arrived through a nesting. Passing it explicitly reads
158
- # like it changes something; it doesn't. The parent segments above pass
181
+ # like it changes something; it doesn't. The parent segments pass
159
182
  # `parent: nil` precisely to opt OUT of that default.
160
183
  items << segment(
161
184
  nestable_resource_name_plural(resource_class),
162
185
  resource_url_for(resource_class)
163
186
  )
164
-
165
- # Record itself (for non-singular routes only)
166
- return unless resource_record!.persisted? && action_name != "show"
167
-
168
- items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
169
187
  end
170
188
 
171
189
  # Stands in for whatever the controller folded away, the way GitHub
@@ -43,6 +43,8 @@ module Plutonium
43
43
  "running" => "info",
44
44
  "completed" => "success",
45
45
  "completed_with_errors" => "warning",
46
+ # The input was refused, nothing broke: see Run#rejected?.
47
+ "rejected" => "warning",
46
48
  "failed" => "danger"
47
49
  }.freeze
48
50
 
@@ -83,7 +85,9 @@ module Plutonium
83
85
  div(class: "pu-card pu-run-progress", **poll_attributes) do
84
86
  div(class: "pu-card-body space-y-4") do
85
87
  render_status
86
- render_progress
88
+ # A rejected run never started: an empty progress line beside
89
+ # "Nothing was processed" would say the same thing twice.
90
+ run.rejected? ? render_rejected : render_progress
87
91
  render_failures if run.errors_log.any?
88
92
  end
89
93
  end
@@ -123,15 +127,24 @@ module Plutonium
123
127
  # The count rides alongside the badge rather than only in the list
124
128
  # below: a partially-applied :continue run ends as "completed", and
125
129
  # the one thing a reader must not be able to miss is that it did not
126
- # do everything it was asked to.
130
+ # do everything it was asked to. Muted, because the alert below
131
+ # carries the colour (danger or warning) for what the problems mean.
127
132
  if run.error_count.positive?
128
- span(class: "text-xs font-medium text-[var(--pu-text-danger)]") do
133
+ span(class: "text-xs font-medium text-[var(--pu-text-muted)]") do
129
134
  plain t("plutonium.async.progress.error_count", count: run.error_count)
130
135
  end
131
136
  end
132
137
  end
133
138
  end
134
139
 
140
+ # In place of the progress line: "0 of 50 targets" reads as a run that
141
+ # died at its first target. A rejected run never started, and says so.
142
+ def render_rejected
143
+ div(class: "text-xs text-[var(--pu-text-muted)]") do
144
+ plain t("plutonium.async.progress.rejected")
145
+ end
146
+ end
147
+
135
148
  # NEVER rendered without {#render_status} above it. After a refused
136
149
  # partial batch (:halt / :transactional), progress_done counts targets
137
150
  # DISPOSITIONED, not applied — so a run that performed nothing at all can
@@ -201,19 +214,20 @@ module Plutonium
201
214
  ((Time.current - started) * 1000).to_i % SWEEP_DURATION_MS
202
215
  end
203
216
 
217
+ # The framework's inline alert, so the problems read like every other
218
+ # message Plutonium shows rather than as a panel of their own.
204
219
  def render_failures
205
- div(class: "space-y-1") do
206
- div(class: "text-[10px] font-semibold uppercase tracking-wider text-[var(--pu-text-muted)]") do
207
- plain t("plutonium.async.progress.failures")
208
- end
209
- ul(class: "pu-run-progress-failures text-xs space-y-1") do
210
- run.errors_log.each do |entry|
211
- li(class: "text-[var(--pu-text-danger)]") { plain failure_text(entry) }
212
- end
220
+ div(class: "pu-alert pu-alert-#{failures_variant} pu-run-progress-failures", role: "alert") do
221
+ ul(class: "pu-alert-message") do
222
+ run.errors_log.each { |entry| li { plain failure_text(entry) } }
213
223
  end
214
224
  end
215
225
  end
216
226
 
227
+ # Danger only for a run that broke. Refused input (rejected) and partial
228
+ # application (completed_with_errors) are warnings, matching the badge.
229
+ def failures_variant = (run.outcome == "failed") ? "danger" : "warning"
230
+
217
231
  # A nil target_id is the RUN-LEVEL sentinel (see AsyncRun#record_target_failure!),
218
232
  # not a target whose id happens to be blank.
219
233
  def failure_text(entry)
@@ -4,7 +4,9 @@ module Plutonium
4
4
  module UI
5
5
  module Interaction
6
6
  module Async
7
- # Lists the runs currently working on THIS resource, above its collection.
7
+ # Lists the runs currently working on THIS resource, above its collection,
8
+ # followed by the viewer's own runs that finished within
9
+ # +recent_runs_window+ (see Page::Index#render_running_banner).
8
10
  #
9
11
  # == Why it exists
10
12
  #
@@ -16,8 +18,9 @@ module Plutonium
16
18
  #
17
19
  # +target_type+ is a real column rather than a key inside the +options+
18
20
  # JSON precisely so this predicate can be indexed — see the migration's own
19
- # comment. An opaque run has no +target_type+ at all and therefore appears
20
- # on no resource index: it belongs to no resource.
21
+ # comment. An opaque run dispatched from a resource still records that
22
+ # resource as its +target_type+, so it shows on that resource's index; one
23
+ # dispatched from nowhere has none and appears on no resource index.
21
24
  #
22
25
  # == Why it can render nothing
23
26
  #
@@ -51,13 +54,26 @@ module Plutonium
51
54
  # own page renders the pair.
52
55
  span(class: "font-medium text-[var(--pu-text)]") { plain run.to_label }
53
56
  plain " "
54
- span(class: "text-[var(--pu-text-muted)]") { plain(Plutonium::Translation.value_label(run.class, :state, run.state) || run.state.humanize) }
57
+ run.in_progress? ? render_state(run) : render_outcome(run)
55
58
  end
56
59
  a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary shrink-0") do
57
- plain t("plutonium.async.banner.view_progress")
60
+ plain t(run.in_progress? ? "plutonium.async.banner.view_progress" : "plutonium.async.banner.view_run")
58
61
  end
59
62
  end
60
63
  end
64
+
65
+ def render_state(run)
66
+ span(class: "text-[var(--pu-text-muted)]") { plain(Plutonium::Translation.value_label(run.class, :state, run.state) || run.state.humanize) }
67
+ end
68
+
69
+ # A finished run is listed for what it ended as, so outcome rather than
70
+ # state ("completed" hides a partial :continue run; "failed" hides a
71
+ # rejection), in the progress panel's colours.
72
+ def render_outcome(run)
73
+ span(class: "pu-badge pu-badge-#{RunProgress::BADGE_VARIANTS.fetch(run.outcome, "neutral")}") do
74
+ plain(Plutonium::Translation.value_label(run.class, :outcome, run.outcome) || run.outcome.humanize)
75
+ end
76
+ end
61
77
  end
62
78
  end
63
79
  end
@@ -244,15 +244,6 @@ module Plutonium
244
244
  "sha384-uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
245
245
  crossorigin: "anonymous"
246
246
  )
247
- # EasyMDE's toolbar uses Font Awesome 4 class names (renamed in 5+).
248
- link(
249
- rel: "stylesheet",
250
- href:
251
- "https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css",
252
- integrity:
253
- "sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN",
254
- crossorigin: "anonymous"
255
- )
256
247
  end
257
248
 
258
249
  def render_scripts
@@ -4,6 +4,11 @@ module Plutonium
4
4
  module UI
5
5
  module Page
6
6
  class Index < Base
7
+ # How many recently finished runs the running banner lists at most. It
8
+ # sits above the collection; a burst of dispatches must not push the
9
+ # collection off the screen. The run index has the rest.
10
+ RECENT_RUNS_LIMIT = 5
11
+
7
12
  # Cookie name carrying a per-resource view preference. Single
8
13
  # source of truth — Table::Resource, Grid::Resource, and the
9
14
  # Stimulus view-switcher controller all read from here. Underscored
@@ -101,12 +106,26 @@ module Plutonium
101
106
  # a URL for a route that doesn't exist here.
102
107
  return unless registered_resources.include?(Plutonium::Interaction::Async::Run)
103
108
 
104
- runs = authorized_resource_scope(
105
- Plutonium::Interaction::Async::Run,
106
- relation: Plutonium::Interaction::Async::Run.for_target(resource_class).in_progress
109
+ run_class = Plutonium::Interaction::Async::Run
110
+ in_progress = authorized_resource_scope(
111
+ run_class, relation: run_class.for_target(resource_class).in_progress
112
+ ).to_a
113
+
114
+ # The current user's own runs that settled moments ago. A dispatch
115
+ # sends them back here, and a short run (or one its pre_check rejected)
116
+ # finishes before this page renders, so "in progress" alone would show
117
+ # them nothing. Only their own: someone else's finished run is not news.
118
+ recent = authorized_resource_scope(
119
+ run_class,
120
+ relation: run_class.for_target(resource_class)
121
+ .where(initiator: current_user)
122
+ .finished_since(Plutonium.configuration.async_interactions.recent_runs_window.ago)
123
+ .order(finished_at: :desc)
124
+ .limit(RECENT_RUNS_LIMIT)
107
125
  ).to_a
108
126
 
109
- render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: runs)
127
+ # uniq: a run that finishes between the two queries is in both.
128
+ render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: (in_progress + recent).uniq)
110
129
  end
111
130
 
112
131
  def selected_view
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.68.0"
2
+ VERSION = "0.69.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -1222,6 +1222,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1222
1222
  margin-left: var(--pu-drag-marker-gap, 4px);
1223
1223
  }
1224
1224
 
1225
+ /* The failures on an async run's progress panel: an inline alert holding a
1226
+ list, so it sits flush in the card and the list keeps its bullets. */
1227
+ .pu-run-progress-failures {
1228
+ @apply mb-0 items-start;
1229
+ }
1230
+ .pu-run-progress-failures .pu-alert-message {
1231
+ @apply ms-0 ps-4 list-disc space-y-1;
1232
+ }
1233
+
1225
1234
  /* Indeterminate progress for an async run with no denominator (opaque work).
1226
1235
  A short segment sweeping the track: the motion is what says "alive", which a
1227
1236
  static bar at any width cannot, and a full-width bar would misread as done. */