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
@@ -7,8 +7,11 @@ en:
7
7
  async:
8
8
  run:
9
9
  label: "%{name} #%{id}"
10
+ interaction: "Action"
11
+ progress: "%{done} / %{total}"
10
12
  target_type: "Target type"
11
13
  enqueue_failed: "could not be enqueued for execution"
14
+ reject_outside_pre_check: "%{run_class}#reject is only available inside pre_check"
12
15
  executor:
13
16
  target_missing: "Target %{id} is no longer available"
14
17
  target_unauthorized: "Target %{id} is no longer permitted by %{action}"
@@ -17,6 +20,12 @@ en:
17
20
  one: "stopped at the first target failure (%{policy} policy); %{count} target was not attempted"
18
21
  other: "stopped at the first target failure (%{policy} policy); %{count} targets were not attempted"
19
22
  partial_batch_refused: "%{unresolved} of %{total} targets could not be resolved; a %{policy} run does not apply a partial batch"
23
+ pre_check_arity:
24
+ targeted: "%{run_class}#pre_check must take the records: define pre_check(records) on a run with perform_on"
25
+ opaque: "%{run_class}#pre_check must take no arguments: define pre_check on a run with perform"
26
+ rejections_truncated:
27
+ one: "and %{count} more problem was found"
28
+ other: "and %{count} more problems were found"
20
29
  context:
21
30
  initiator_missing: "run %{run} has no initiator (%{type}#%{id} no longer exists)"
22
31
  tenant_missing: "run %{run} was scoped to %{type}#%{id}, which no longer exists; refusing to resolve targets without a tenant"
@@ -25,10 +34,11 @@ en:
25
34
  targets: "%{done} of %{total} targets (%{percent}%)"
26
35
  working: "Working…"
27
36
  no_total: "No progress total was recorded"
28
- failures: "Failures"
29
37
  error_count:
30
38
  one: "%{count} error"
31
39
  other: "%{count} errors"
32
40
  target_failure: "#%{target_id}: %{message}"
41
+ rejected: "Nothing was processed"
33
42
  banner:
34
43
  view_progress: "View progress"
44
+ view_run: "View run"
@@ -26,6 +26,8 @@ Plutonium.configure do |config|
26
26
  config.async_interactions.enabled = true
27
27
  config.async_interactions.queue = :default # ActiveJob queue for run jobs
28
28
  config.async_interactions.stall_after = 1.hour # see Stalled runs, below
29
+ config.async_interactions.max_rejections = 100 # pre_check problems stored per run
30
+ config.async_interactions.recent_runs_window = 10.minutes # finished runs stay in your banner this long
29
31
  end
30
32
  ```
31
33
 
@@ -123,6 +125,58 @@ Validating means staging first, since Shrine validates an assigned cached file,
123
125
 
124
126
  Where no `backend:` is declared: `config.async_interactions.attachment_backend`, then `config.attachment_backend`, then auto-detection (active_shrine loaded → Shrine, else ActiveStorage). Same layering wizards use.
125
127
 
128
+ ### Checking input before processing
129
+
130
+ Validations on the interaction run in the request, so they suit cheap checks (an uploader's size and type rules already fail the form there). A check too slow for a request, like reading every row of a large file, goes in the run's `pre_check`. It runs in the job, before any work, and refuses the input with `reject`:
131
+
132
+ ```ruby
133
+ require "csv"
134
+
135
+ class ImportTasks < ResourceInteraction
136
+ attribute :import_file
137
+ input :import_file, as: :file
138
+
139
+ async do
140
+ def pre_check
141
+ each_row do |row, line|
142
+ reject "line #{line}: title is missing" if row["title"].blank?
143
+ end
144
+ rescue CSV::MalformedCSVError => e
145
+ reject "the file is not valid CSV: #{e.message}"
146
+ end
147
+
148
+ # One transaction: an opaque run restarts from scratch after a reap, so a
149
+ # partial import would otherwise be imported twice.
150
+ def perform
151
+ Task.transaction { each_row { |row, _line| Task.create!(title: row["title"]) } }
152
+ end
153
+
154
+ private
155
+
156
+ # Read whole, as UTF-8 without Excel's byte-order mark: the stored file
157
+ # comes back as binary, and a BOM would become part of the first header.
158
+ def each_row
159
+ text = attachment(:import_file).download.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
160
+ CSV.parse(text, headers: true).each.with_index(2) do |row, line|
161
+ yield row, line unless row.fields.all?(&:blank?)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ ```
167
+
168
+ Rows are numbered from 2 so a message matches the line a spreadsheet shows (the header is line 1), and blank lines are skipped without shifting the numbers after them. The dummy app's `ImportTasksInteraction` is the full version, with a column check, an empty-file check and status validation.
169
+
170
+ - `reject` records a problem and returns, so one pass reports every bad row. It does not stop the check: `return` explicitly where nothing after it would make sense, such as a missing column.
171
+ - If anything was rejected, the run ends `failed` with `outcome == "rejected"`, the problems listed on its progress page, and `perform` / `perform_on` never runs. The page shows a warning "Rejected" badge and "Nothing was processed". On a `:continue` run, targets that had gone missing or lost permission are recorded before the check runs, so they still appear in the list (and count toward `progress_done`) even though nothing was performed.
172
+ - A run with `perform_on` declares `pre_check(records)` and receives the resolved, authorized records the loop is about to process. A `pre_check` with the wrong arity fails the run with a message naming the signature it expects.
173
+ - An exception raised inside `pre_check` fails the run the ordinary way (`outcome == "failed"`): the check broke, the input was not judged.
174
+ - `reject` called outside `pre_check` raises.
175
+ - Only the first `config.async_interactions.max_rejections` problems (default 100) are stored, followed by one "and N more problems were found" entry.
176
+ - The check runs again when a stalled run is resumed, so it must only read. An opaque run always re-checks. A resumed targeted run checks the records it has left, unless an earlier pass already applied a target (that pass's check passed, so it is not repeated).
177
+ - A targeted check receives the records as loaded when the run resolved its targets. `perform_on` gets each record re-read just before it runs, so a verdict based on record state can be stale by then; re-check anything that must hold at the moment of the work inside `perform_on`.
178
+ - A long check must call `heartbeat!` (see [Long work must say it is alive](#long-work-must-say-it-is-alive)).
179
+
126
180
  ### Sharing one run across interactions
127
181
 
128
182
  Pass a class instead of a block when several interactions do the same kind of work:
@@ -175,7 +229,9 @@ run.state # "completed"
175
229
  run.outcome # "completed_with_errors" (state == "completed" && errors_log.any?)
176
230
  ```
177
231
 
178
- The progress page, the table's `outcome` column, and the running banner all render `outcome`, never bare `state`.
232
+ A run whose `pre_check` rejected its input reads `outcome == "rejected"` (its `state` is `"failed"`, and nothing was processed). A run that failed while working reads `"failed"`.
233
+
234
+ The progress page, the table's `outcome` column, and the running banner's entries for finished runs render `outcome`, never bare `state`.
179
235
 
180
236
  ## Registering the Run resource
181
237
 
@@ -207,17 +263,18 @@ A registered run resource gets, for free:
207
263
 
208
264
  - **A progress page.** The show page IS the progress page. It self-refreshes via polling (not ActionCable) while `state` is `pending`/`running`, and stops carrying the poll once the run settles, so a finished run is a static page, not a background request per viewer.
209
265
  - **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option: `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
210
- - **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
266
+ - **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. It also lists the viewer's own runs that finished within `config.async_interactions.recent_runs_window` (default 10 minutes, newest first, at most 5), with their outcome and a "View run" link. A dispatch sends the user back to this index, and a short run, or one its `pre_check` rejected, has often finished before the page renders. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
211
267
  - **Tenant scoping.** A run's `associated_with` scope filters on the tenant it was dispatched in (recorded on the row), not on walking the object graph, since the two polymorphic tenant columns make the generic scope unusable.
268
+ - **Runs named by their interaction.** A run declared with an `async` block is titled by its interaction's label ("Archive all (async) #19"), from `presents label:` or, without one, the class name minus its `Interaction` suffix. A standalone run class passed to `async` can be shared by several interactions, so it is named after its own class. Only `presents label:` reaches the run: a `label:` on the `action` declaration, or a `plutonium.actions.<model>.<action>` translation, renames the button but not the runs it starts, so put the label on the interaction when both should match.
212
269
  - **A humanized target label.** `run.target_label` reads the target class through `model_name.human` ("Post", not "Blogging::Post"), falling back to the raw string if that class has since been renamed or removed.
213
270
 
214
271
  The progress page, mid-run:
215
272
 
216
- ![A run's progress page showing a Running badge, a progress bar at 27 of 50 targets, and the run's type, target type, initiator and timestamps](/images/reference/async-progress-page.png)
273
+ ![A run's progress page showing a Running badge, a progress bar at 27 of 50 targets, and the run's action, target type, initiator and timestamps](/images/reference/async-progress-page.png)
217
274
 
218
275
  And the banner it leaves on the target resource's index while it is still going:
219
276
 
220
- ![A Task index with a banner above the collection reading "Archive Tasks Async Interaction Run #19 Running", with a View progress link](/images/reference/async-running-banner.png)
277
+ ![A Task index with a banner above the collection reading "Archive all (async) #19 Running", with a View progress link](/images/reference/async-running-banner.png)
221
278
 
222
279
  ## Stalled runs and ReapJob
223
280
 
@@ -255,7 +312,7 @@ What bounds that is `lock_version`. Both the reaper's resume and the executor's
255
312
 
256
313
  `stall_after` is a **silence** threshold, not a runtime limit. Every write the executor makes refreshes the clock, so a targeted run with quick targets heartbeats once per target for free. Two shapes get nothing, and both are exactly the "long-running task" case:
257
314
 
258
- - **Opaque work.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and, having no `handled_target_ids` to resume from, re-runs **from scratch**.
315
+ - **Opaque work, and any `pre_check`.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and, having no `handled_target_ids` to resume from, re-runs **from scratch**.
259
316
  - **A single `perform_on`** that outlives `stall_after` on its own.
260
317
 
261
318
  Call `heartbeat!` from inside such work:
@@ -36,19 +36,43 @@ another environment is left untouched.
36
36
 
37
37
  ## `pu:lite:maintenance`
38
38
 
39
- Installs `app/jobs/sqlite_maintenance_job.rb` and (when `solid_queue` is present)
40
- schedules it in `config/recurring.yml`.
39
+ Installs `app/jobs/sqlite_maintenance_job.rb` and, when `solid_queue` is present,
40
+ schedules it in `config/recurring.yml` along with SolidQueue's finished-job cleanup.
41
41
 
42
42
  ```bash
43
43
  rails g pu:lite:maintenance
44
- # custom schedule:
45
- rails g pu:lite:maintenance --schedule="every day at 4am"
44
+ # custom schedules:
45
+ rails g pu:lite:maintenance --schedule="every day at 4am" \
46
+ --clear-finished-schedule="every 30 minutes"
46
47
  ```
47
48
 
48
49
  The job runs `PRAGMA optimize` on every configured SQLite database and `VACUUM`
49
50
  only on databases without live 24/7 writers (`primary`, `errors`, `rails_pulse`
50
51
  by default; edit `VACUUM_DBS` in the generated job to suit your app).
51
52
 
53
+ Even on those databases, `VACUUM` is skipped unless at least 10% of the file's
54
+ pages are free (`VACUUM_MIN_FREE_RATIO`). The rewrite holds its exclusive lock for
55
+ as long as it takes, which on a large `primary` can be long enough for requests to
56
+ time out, and WAL reuses free pages anyway. In practice it runs after a bulk delete
57
+ and is skipped (with a log line) the rest of the time.
58
+
59
+ ### Clearing finished SolidQueue jobs
60
+
61
+ SolidQueue keeps finished jobs by default (`preserve_finished_jobs`) and never
62
+ deletes them on its own, so the queue database grows without bound. The generator
63
+ adds this task:
64
+
65
+ ```yaml
66
+ clear_solid_queue_finished_jobs:
67
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
68
+ schedule: "every hour at minute 12"
69
+ ```
70
+
71
+ It deletes jobs finished longer ago than `clear_finished_jobs_after` (one day by
72
+ default) in batches, sleeping between them so the scheduler and workers keep their
73
+ turn at the write lock. New Rails 8 apps already ship this task; the generator
74
+ detects any existing `clear_finished_in_batches` entry and skips.
75
+
52
76
  **Why VACUUM only some databases?** SolidQueue, Solid Cache and Solid Cable write
53
77
  to their databases constantly. `VACUUM` takes a global *exclusive* lock for its
54
78
  whole duration, which stalls and errors those processes (e.g. SolidQueue process
@@ -61,5 +85,5 @@ Databases listed in the job that don't exist in `config/database.yml` are skippe
61
85
  at runtime, so the same job is safe regardless of which `pu:lite:*` generators you
62
86
  have run.
63
87
 
64
- If `solid_queue` is not installed, the job file is still created but not scheduled:
65
- add a `sqlite_maintenance` entry to whatever scheduler you use.
88
+ If `solid_queue` is not installed, the job file is still created but nothing is
89
+ scheduled: add a `sqlite_maintenance` entry to whatever scheduler you use.
@@ -145,7 +145,7 @@ end
145
145
 
146
146
  ## Derived labels
147
147
 
148
- Actions, scopes, filters, kanban columns and wizard steps default to a humanized version of their key. Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.
148
+ Scopes, filters, kanban columns and wizard steps default to a humanized version of their key. An action backed by an interaction defaults to the interaction's class name without its `Interaction` suffix (`ArchivePostsInteraction` reads "Archive Posts"). Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.
149
149
 
150
150
  ```yaml
151
151
  en: