plutonium 0.62.2 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Async Interactions
|
|
2
|
+
|
|
3
|
+
::: warning Experimental
|
|
4
|
+
Async interactions are experimental — the DSL and behavior may change in a future release.
|
|
5
|
+
:::
|
|
6
|
+
|
|
7
|
+
For the task-oriented walkthrough — declaring the action, its policy, and where it appears — start with the [Custom actions guide](/guides/custom-actions). This page is the reference.
|
|
8
|
+
|
|
9
|
+
`async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it."
|
|
10
|
+
|
|
11
|
+
Reach for it once the work stops being something a user can reasonably wait on: archiving ten thousand records, generating a report, calling a third party that takes its time. The run outlives the request, gets a progress page for free, and stays queryable after the fact.
|
|
12
|
+
|
|
13
|
+
## 🚨 Critical
|
|
14
|
+
|
|
15
|
+
- **Opt-in and migrated.** `config.async_interactions.enabled = true` + `rails db:migrate` (off by default).
|
|
16
|
+
- **`async` replaces `execute` entirely.** The interaction still validates, authorizes and renders its form exactly as before; only what happens on submit changes.
|
|
17
|
+
- **Permissions are re-derived at perform time, never replayed from dispatch.** A run created a job, not a snapshot of "what the initiator could do then." See [Authorization](#authorization-is-re-derived-not-replayed).
|
|
18
|
+
- **The run itself is a resource.** Register it once per portal (`rails g pu:async_interactions:install --dest=your_portal`); its show page IS its progress page.
|
|
19
|
+
- **A stalled run does not silently replay.** `pu:async_interactions:install` schedules `Async::ReapJob` when Solid Queue is in the bundle; on any other scheduler you must add it yourself, or a crash mid-batch leaves the row `"running"` forever. See [Stalled runs](#stalled-runs-and-reapjob).
|
|
20
|
+
|
|
21
|
+
## Enabling
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# config/initializers/plutonium.rb
|
|
25
|
+
Plutonium.configure do |config|
|
|
26
|
+
config.async_interactions.enabled = true
|
|
27
|
+
config.async_interactions.queue = :default # ActiveJob queue for run jobs
|
|
28
|
+
config.async_interactions.stall_after = 1.hour # see Stalled runs, below
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
rails db:migrate # creates plutonium_async_runs
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The flag gates the migration, not just the behaviour: while it is off the runs migration path is never registered, so the table does not exist. A `async` interaction that runs anyway raises `Plutonium::Interaction::Concerns::Dispatchable::NotEnabledError` naming the flag, rather than a raw "no such table" from inside ActiveRecord.
|
|
37
|
+
|
|
38
|
+
## Declaring the work
|
|
39
|
+
|
|
40
|
+
`async` takes a block, and the block is the run's class body. Define `perform_on(record)` for **targeted** work (bulk/record actions, one call per target) or `perform` for **opaque** work (resource actions with no subject):
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class Blogging::ArchivePosts < ResourceInteraction
|
|
44
|
+
presents label: "Archive", icon: Phlex::TablerIcons::Archive
|
|
45
|
+
attribute :resources # bulk — perform_on runs once per record
|
|
46
|
+
attribute :reason, :string
|
|
47
|
+
|
|
48
|
+
async do
|
|
49
|
+
on_failure :continue # :halt (default) | :continue | :transactional
|
|
50
|
+
|
|
51
|
+
def perform_on(post)
|
|
52
|
+
post.archive!(reason: options["reason"])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
One class. There is no second file, and no name to invent for the run.
|
|
59
|
+
|
|
60
|
+
### Why the block declares `perform_on` rather than executing
|
|
61
|
+
|
|
62
|
+
The block is **not** the body of `#execute`. The work happens later, in a job, in a process with no controller, no request and no `view_context` — it cannot be a closure over anything in the interaction, which is the same reason the row records the initiator and tenant instead of serialising them. So the block declares a `Plutonium::Interaction::Async::Run` subclass with exactly the API a standalone run has, and the validated attributes arrive through `options`.
|
|
63
|
+
|
|
64
|
+
`def` opens a fresh scope, so those method bodies cannot accidentally capture the interaction's locals.
|
|
65
|
+
|
|
66
|
+
The generated class is named `<Interaction>::Run` rather than left anonymous, because the class name is persisted in the run's `type` column and constantized in another process. An anonymous class would write a row nothing could read back.
|
|
67
|
+
|
|
68
|
+
| `on_failure` | Behavior when a target raises |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `:halt` (default) | Stops at the first failure; the run ends `"failed"`, targets after the failure point are never attempted |
|
|
71
|
+
| `:continue` | Records the failure, keeps going; the run ends `"completed"` (see [Outcome vs state](#outcome-vs-state) for why that's not the same as a clean success) |
|
|
72
|
+
| `:transactional` | Wraps the whole batch in one DB transaction; any failure rolls back everything applied so far |
|
|
73
|
+
|
|
74
|
+
### Attributes reach the run through `options`
|
|
75
|
+
|
|
76
|
+
Everything the interaction validated, except the targets, is written to the run's `options` and read back in the job:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
attribute :reason, :string
|
|
80
|
+
async do
|
|
81
|
+
def perform_on(post) = post.archive!(reason: options["reason"])
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`options` is a JSON column, so dispatch writes it through `ActiveJob::Arguments`. Primitives are stored verbatim — a String stays a String in the column — and only values needing one get a serializer envelope, so a `Date` arrives as a `Date` and a `BigDecimal` as a `BigDecimal` rather than as strings. Hosts can register their own serializers.
|
|
86
|
+
|
|
87
|
+
An attribute that can't be carried at all is refused at dispatch, naming the interaction, rather than being written to a row whose work then fails deep in a job.
|
|
88
|
+
|
|
89
|
+
### Files
|
|
90
|
+
|
|
91
|
+
A file can't ride the options column: JSON has no files, and the request's tempfile is deleted on the way out. So an uploaded file is staged to its backend's cache at dispatch and carried as the token — `options["import_file"]` is that token, a String.
|
|
92
|
+
|
|
93
|
+
Use `attachment` to read it back:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Catalog::ImportProducts < ResourceInteraction
|
|
97
|
+
attribute :import_file
|
|
98
|
+
|
|
99
|
+
async do
|
|
100
|
+
def perform
|
|
101
|
+
attachment(:import_file).open do |file|
|
|
102
|
+
CSV.foreach(file, headers: true) { |row| Catalog::Product.create!(row.to_h) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`attachment(:key)` returns one, `attachments(:key)` all of them for a multiple-file attribute, each exposing `filename`, `content_type`, `url`, `open` and `download`. Reviving reaches storage, so it is not folded into `options`: the progress page reads options on every poll and has no need of the file.
|
|
110
|
+
|
|
111
|
+
#### Per-field backend and uploader
|
|
112
|
+
|
|
113
|
+
`backend:` and `uploader:` are read off the attribute's own `input` declaration — the same options, in the same place, a wizard step reads them from:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
attribute :import_file
|
|
117
|
+
input :import_file, as: :uppy, uploader: Catalog::ImportUploader
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The uploader's `Attacher.validate` rules run when the interaction validates, so a file that breaks them **fails the form** — the submitter sees a field error and nothing is dispatched. Without that the interaction would validate clean, dispatch, and the author's `validate_max_size` would surface as a run failure on a page the submitter has already left. A no-op for ActiveStorage fields and for uploaders declaring no rules.
|
|
121
|
+
|
|
122
|
+
Validating means staging first, since Shrine validates an assigned cached file — so an upload that fails validation has still been written to the cache, and is reaped by the backend's own unattached-cache cleanup. Wizards make the same trade on every step submit.
|
|
123
|
+
|
|
124
|
+
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
|
+
|
|
126
|
+
### Sharing one run across interactions
|
|
127
|
+
|
|
128
|
+
Pass a class instead of a block when several interactions do the same kind of work:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class Blogging::ArchivePostsRun < Plutonium::Interaction::Async::Run
|
|
132
|
+
on_failure :continue
|
|
133
|
+
def perform_on(post) = post.archive!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class Blogging::ArchivePosts < ResourceInteraction
|
|
137
|
+
async Blogging::ArchivePostsRun
|
|
138
|
+
attribute :resources
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Passing both a class and a block raises `ArgumentError` — the block *is* a run class, so there is nothing to combine.
|
|
143
|
+
|
|
144
|
+
`async` must be the only thing that defines `#execute` on the class. Declaring it on a class that already has its own `execute` raises `ArgumentError` at load time (an interaction either executes inline or runs async, never both).
|
|
145
|
+
|
|
146
|
+
## What gets recorded at dispatch
|
|
147
|
+
|
|
148
|
+
Nothing is passed in explicitly. Everything the run needs is already reachable from the interaction's own state:
|
|
149
|
+
|
|
150
|
+
- **Targets.** `attribute :resource` / `attribute :resources`, already narrowed by the controller's policy scope, stored as ids (`target_ids`) and re-resolved at perform time, never serialized as records.
|
|
151
|
+
- **Initiator + tenant.** `current_user` / `current_scoped_entity`, two of the things every Plutonium policy authorizes on.
|
|
152
|
+
- **Nested-route parent.** `current_parent` and `current_nested_association` — `/orgs/1/posts/5/comments` records `(Post#5, :comments)`. Both halves or neither, since `Policy#default_relation_scope` raises on one alone. This is the third policy input, and it is not optional detail: that method picks **one** branch, parent *or* entity, so a nested run without its parent re-derives targets under the tenant where dispatch used the parent — wider than the scope the initiator was shown. It also leaves a host predicate reading `parent` looking at `nil`, which (being declared `optional: true`) answers false rather than raising, refusing every target for a reason that names the predicate instead of the missing context.
|
|
153
|
+
- **The policy actually resolved.** `policy_class_name`, not an inferred `"#{Model}Policy"` (a namespaced portal or an STI fallback would make that guess wrong), plus `policy_action`, the predicate dispatch checked (e.g. `"archive?"`).
|
|
154
|
+
- **`authorization_namespace`.** The portal's module name, so perform-time policy lookup finds the same narrowed policy dispatch did.
|
|
155
|
+
|
|
156
|
+
An opaque (untargeted) run records none of the target/policy columns: there's no subject to check, so there's nothing to re-verify.
|
|
157
|
+
|
|
158
|
+
## Authorization is re-derived, not replayed
|
|
159
|
+
|
|
160
|
+
The job has no controller, no request, no `current_user`. `Async::Context` rebuilds the authorization triple from the row and re-checks it from scratch. It does not trust anything the dispatching request already decided:
|
|
161
|
+
|
|
162
|
+
- The **scope** check re-runs `Post.associated_with(tenant)` style filtering — or, for a nested dispatch, the parent's association. A target that left the tenant or the parent, or was deleted, between dispatch and perform is reported `missing`/`unauthorized`, never silently skipped.
|
|
163
|
+
- The **predicate** check re-asks the policy the same question dispatch asked (`policy_action`), per target, immediately before `perform_on`, not once up front. An initiator whose permission was revoked mid-run stops applying to the remaining targets.
|
|
164
|
+
- A **policy mismatch** (the class renamed/re-parented/re-namespaced since dispatch) refuses to run at all, rather than silently authorizing under a different policy than the initiator was ever subject to.
|
|
165
|
+
- A **deleted subject** — initiator, tenant or parent — refuses the run. In each case the association nils out, and nil reads as "there was never one": no tenant, or not a nested dispatch. Both of those drop a filter rather than narrowing, so the `*_type` column is what tells "carries none" apart from "carries one that is gone".
|
|
166
|
+
|
|
167
|
+
This is deliberate and asymmetric: a resolution failure always fails **closed** (refuse / report missing), never open (never "assume permitted").
|
|
168
|
+
|
|
169
|
+
## Outcome vs state
|
|
170
|
+
|
|
171
|
+
Read `run.outcome`, not `run.state`, when displaying or branching on the result. A `:continue` run that couldn't apply every target still ends with `state == "completed"`, since the author declared partial application acceptable. `outcome` is what distinguishes that from a clean pass:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
run.state # "completed"
|
|
175
|
+
run.outcome # "completed_with_errors" (state == "completed" && errors_log.any?)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The progress page, the table's `outcome` column, and the running banner all render `outcome`, never bare `state`.
|
|
179
|
+
|
|
180
|
+
## Registering the Run resource
|
|
181
|
+
|
|
182
|
+
Register it once per portal so its show page becomes routable:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
rails g pu:async_interactions:install --dest=admin_portal
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# packages/admin_portal/config/routes.rb
|
|
190
|
+
register_resource ::Plutonium::Interaction::Async::Run
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# packages/admin_portal/app/controllers/admin_portal/async_runs_controller.rb
|
|
195
|
+
class AdminPortal::AsyncRunsController < AdminPortal::ResourceController
|
|
196
|
+
controller_for ::Plutonium::Interaction::Async::Run
|
|
197
|
+
|
|
198
|
+
include AdminPortal::Concerns::Controller
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`controller_for` is required: the controller's name doesn't match `Run`'s real, namespaced class, so inference can't find it on its own. No policy/definition files are generated: `Plutonium::Interaction::Async::RunPolicy`/`Async::RunDefinition` already resolve automatically (Rails matches `Plutonium::Interaction::Async::RunDefinition` by the exact class name, and ActionPolicy's own lookup finds `AsyncRunPolicy` the same way).
|
|
203
|
+
|
|
204
|
+
If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` (`--schedule` to override the default `every 15 minutes`) — see [Stalled runs and ReapJob](#stalled-runs-and-reapjob). Idempotent, so running the generator against a second portal doesn't duplicate the entry.
|
|
205
|
+
|
|
206
|
+
A registered run resource gets, for free:
|
|
207
|
+
|
|
208
|
+
- **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
|
+
- **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.
|
|
211
|
+
- **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.
|
|
212
|
+
- **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
|
+
|
|
214
|
+
The progress page, mid-run:
|
|
215
|
+
|
|
216
|
+

|
|
217
|
+
|
|
218
|
+
And the banner it leaves on the target resource's index while it is still going:
|
|
219
|
+
|
|
220
|
+

|
|
221
|
+
|
|
222
|
+
## Stalled runs and ReapJob
|
|
223
|
+
|
|
224
|
+
A worker crash mid-batch (or a job the queue silently drops) leaves a run `"running"` (or `"pending"`) forever; nothing else ever revisits it on its own. `Plutonium::Interaction::Async::ReapJob` finds runs with no recorded activity (`last_activity_at`, falling back to `created_at` for a run never even picked up) past `config.async_interactions.stall_after`, and resumes them: resets to `"pending"` and re-enqueues.
|
|
225
|
+
|
|
226
|
+
This is safe, not a replay. The executor tracks `handled_target_ids` (every target already dispositioned, success or failure) and resumes only the remainder, so a target already applied before the interruption is not redone.
|
|
227
|
+
|
|
228
|
+
`rails g pu:async_interactions:install` schedules this for you when Solid Queue is in the bundle:
|
|
229
|
+
|
|
230
|
+
```yaml
|
|
231
|
+
# config/recurring.yml (Solid Queue)
|
|
232
|
+
production:
|
|
233
|
+
reap_stalled_async_runs:
|
|
234
|
+
class: Plutonium::Interaction::Async::ReapJob
|
|
235
|
+
schedule: every 15 minutes
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Without Solid Queue — or for another scheduler like `whenever` — add it yourself:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# whenever gem
|
|
242
|
+
every 15.minutes do
|
|
243
|
+
runner "Plutonium::Interaction::Async::ReapJob.perform_later"
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A 15 to 30 minute cadence is reasonable against the default 1-hour `stall_after`: frequent enough that a stalled run doesn't sit for long, with enough margin that clock jitter doesn't matter.
|
|
248
|
+
|
|
249
|
+
::: warning This is a time heuristic, not a lease
|
|
250
|
+
Resuming is based on elapsed time, not a true distributed lock. A run that is merely slow (not dead) and happens to cross `stall_after` gets resumed too.
|
|
251
|
+
|
|
252
|
+
What bounds that is `lock_version`. Both the reaper's resume and the executor's claim bump it, so the worker that is still alive holds a version the row no longer has: its very next write raises `ActiveRecord::StaleObjectError`, and the executor treats that as "no longer mine" — it abandons the pass without marking the run failed and without overwriting the new worker's progress. Two things it deliberately does not do: it cannot interrupt a `perform_on` already in flight, so one target may be applied twice (once by each side), and it cannot roll back what the superseded worker already committed. Set `stall_after` well above this app's slowest legitimate run — the fence bounds the damage of a bad value, it does not make one free.
|
|
253
|
+
|
|
254
|
+
### Long work must say it is alive
|
|
255
|
+
|
|
256
|
+
`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
|
+
|
|
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**.
|
|
259
|
+
- **A single `perform_on`** that outlives `stall_after` on its own.
|
|
260
|
+
|
|
261
|
+
Call `heartbeat!` from inside such work:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
class Billing::ReissueInvoicesRun < Plutonium::Interaction::Async::Run
|
|
265
|
+
def perform
|
|
266
|
+
invoices.each_slice(500) do |slice|
|
|
267
|
+
reissue(slice)
|
|
268
|
+
heartbeat! # "still working" — resets the stall clock
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
This is deliberately not automatic. A background thread would have to guess a cadence, and would go on reporting a wedged worker as healthy; only the work itself knows it is making progress.
|
|
275
|
+
|
|
276
|
+
`heartbeat!` also **answers**. The write is conditional on this worker still holding the row's `lock_version`, so one that was superseded inside a long `perform` raises `ActiveRecord::StaleObjectError` at its next beat and abandons the pass — for opaque work that is the only place it can find out before `finish!`. Under `:transactional` the beat is inside the batch transaction like everything else, so it stays invisible to the reaper until the batch commits.
|
|
277
|
+
|
|
278
|
+
### Queue-level concurrency
|
|
279
|
+
|
|
280
|
+
On a queue that provides ActiveJob concurrency controls — Solid Queue does, whenever it is in the bundle — the run job also declares a per-run semaphore, and the reaper a global one:
|
|
281
|
+
|
|
282
|
+
| Job | Key | Limit | Duration |
|
|
283
|
+
|---|---|---|---|
|
|
284
|
+
| `Async::Job` | the run's id | 1 | `config.async_interactions.stall_after` |
|
|
285
|
+
| `Async::ReapJob` | constant | 1 | Solid Queue's default |
|
|
286
|
+
|
|
287
|
+
This is declared only when the method exists; Plutonium depends on no queue backend, and nothing above requires one.
|
|
288
|
+
|
|
289
|
+
It is not a second copy of the claim. `claim!` can only *refuse* a duplicate delivery, and only once a worker is already running it — by which point a reaper's resume has re-entered `perform_on` for one target. The semaphore removes the race a step earlier: the second delivery waits instead of racing, so on a queue that supports it the double-applied target does not happen at all. Keying the run job on `stall_after` matters here — Solid Queue's 3-minute default would expire the semaphore mid-batch on any run big enough to be worth dispatching.
|
|
290
|
+
:::
|
|
291
|
+
|
|
292
|
+
## Related
|
|
293
|
+
|
|
294
|
+
- [Interactions](/reference/behavior/interactions) — `async` is declared inside `Plutonium::Resource::Interaction`; everything else about inputs, validation and outcomes is unchanged.
|
|
295
|
+
- [Policies](/reference/behavior/policies) — the policy dispatch checks and the job re-checks are the same predicate.
|
|
@@ -59,7 +59,8 @@ Plus interactive-action routes for every action declared in the definition (`/po
|
|
|
59
59
|
|---|---|
|
|
60
60
|
| Field rendering (inputs, displays, columns) | [Definition](/reference/resource/definition) |
|
|
61
61
|
| Search, filters, scopes, sorting | [Query](/reference/resource/query) |
|
|
62
|
-
| Custom operations (publish, archive, import) | [Interaction](./interactions) + action on definition |
|
|
62
|
+
| Custom operations (publish, archive, import) — the *button* | [Interaction](./interactions) + action on definition |
|
|
63
|
+
| The operation itself, once a job/API/task also needs it | The **model** — see [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for) |
|
|
63
64
|
| Authorization rules | [Policy](./policies) |
|
|
64
65
|
| Form / show / page chrome | Definition (custom page classes — see [UI › Pages](/reference/ui/pages)) |
|
|
65
66
|
| **Custom redirect logic** | **[Controller hook](#redirect-hooks)** |
|
|
@@ -108,6 +109,14 @@ def filtered_resource_collection
|
|
|
108
109
|
end
|
|
109
110
|
```
|
|
110
111
|
|
|
112
|
+
Also where you eager-load associations the index renders. `super` keeps scoping, search, filters and sorting:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
def filtered_resource_collection = super.includes(:author, :category)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
See [Guides › Performance](/guides/performance).
|
|
119
|
+
|
|
111
120
|
### Presentation hooks
|
|
112
121
|
|
|
113
122
|
Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
|
|
@@ -147,13 +156,15 @@ end
|
|
|
147
156
|
|
|
148
157
|
## Custom actions
|
|
149
158
|
|
|
150
|
-
Prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) for anything
|
|
159
|
+
Prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) for anything a user triggers from a page: you get the button, the policy check, the form, and the flash for free. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
|
|
160
|
+
|
|
161
|
+
Either way the *operation* should be a named method on the model — that's what keeps it reachable from a job or an API. The controller and the interaction are two different front doors to the same `post.publish!`.
|
|
151
162
|
|
|
152
163
|
```ruby
|
|
153
164
|
class PostsController < ::ResourceController
|
|
154
165
|
def publish
|
|
155
166
|
authorize_current!(resource_record!, to: :publish?)
|
|
156
|
-
resource_record!.
|
|
167
|
+
resource_record!.publish!
|
|
157
168
|
redirect_to resource_url_for(resource_record!), notice: "Published!"
|
|
158
169
|
end
|
|
159
170
|
end
|
|
@@ -252,11 +263,13 @@ Routes prefixed `nested_` automatically resolve the parent. See [Tenancy › Nes
|
|
|
252
263
|
|
|
253
264
|
```ruby
|
|
254
265
|
current_parent # parent record
|
|
266
|
+
current_parent_class # User
|
|
255
267
|
current_nested_association # :posts
|
|
256
|
-
parent_route_param # :user_id
|
|
257
268
|
parent_input_param # :user
|
|
258
269
|
```
|
|
259
270
|
|
|
271
|
+
The nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and `current_parent_class` / `current_nested_association` read it back. (There is no `parent_route_param` — the id parameter is derived from the parent's own route, and a singular parent contributes none at all.)
|
|
272
|
+
|
|
260
273
|
Parent fields are excluded from forms/displays by default. Toggle with the [presentation hooks](#presentation-hooks).
|
|
261
274
|
|
|
262
275
|
Custom parent resolution:
|
|
@@ -4,10 +4,15 @@ The behavior layer is intentionally thin:
|
|
|
4
4
|
|
|
5
5
|
- **[Controllers](./controllers) route** — handle requests, redirect after submit, transform params.
|
|
6
6
|
- **[Policies](./policies) authorize** — decide who can do what, which fields they can see, which records they can access.
|
|
7
|
-
- **[Interactions](./interactions)
|
|
7
|
+
- **[Interactions](./interactions) present** — declare the inputs for a custom operation (publish, archive, import, send invitation), render as a button and a form, and hand back an outcome.
|
|
8
|
+
- **[Async Interactions](./async-interactions)** — `async` declares a persisted, resumable run instead of executing inline, for bulk operations and anything else too slow to hold a request open.
|
|
8
9
|
|
|
9
10
|
Registering an action and rendering it lives in [Resource › Definition](/reference/resource/definition) and [Resource › Actions](/reference/resource/actions). This section covers **writing** the controller hook, policy method, or interaction class behind it.
|
|
10
11
|
|
|
12
|
+
::: tip And the operation itself lives on the model
|
|
13
|
+
An interaction is a presentation object — it can only be constructed with a `view_context`. Logic may *start* in `execute`, but the second caller (a job, an API controller, a rake task, the console) is the trigger to move it onto the record, Rails-style. See [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for).
|
|
14
|
+
:::
|
|
15
|
+
|
|
11
16
|
For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
|
|
12
17
|
|
|
13
18
|
## At a glance
|
|
@@ -16,6 +21,7 @@ For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity sc
|
|
|
16
21
|
|---|---|
|
|
17
22
|
| Field rendering (inputs, displays, columns, search/filters) | [Definition](/reference/resource/definition) |
|
|
18
23
|
| Custom operations (publish, archive, import) | [Interaction](./interactions) + [Action](/reference/resource/actions) on the definition |
|
|
24
|
+
| Work too slow or too large for a request | [Async Interactions](./async-interactions) |
|
|
19
25
|
| Authorization rules | [Policy](./policies) |
|
|
20
26
|
| Tenant scoping (`relation_scope`) | [Policy](./policies) + [Tenancy](/reference/tenancy/entity-scoping) |
|
|
21
27
|
| Custom redirect logic, param munging, custom index query shape | [Controller hook](./controllers) |
|