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,1414 @@
|
|
|
1
|
+
# Async Interactions Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Let an interaction enqueue its work as a persisted run that reports progress, records who started it, and appears as a browsable resource.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A single STI table `plutonium_interaction_runs` holds every run; authors subclass `Plutonium::Interaction::Run` to define execution and failure policy, and options ride in a JSON column so an async action costs zero migrations. Interactions keep their existing job (inputs, validation, authorization) and only dispatch, so `Outcome` stays synchronous. The job rebuilds the `(initiator, scoped_entity)` policy context rather than inheriting one, and re-resolves targets through the policy scope at perform time.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Rails 7.2+/8.x, ActiveJob, ActiveRecord STI, Phlex views, Turbo, minitest + appraisal.
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — no user verification required. The spec asks for a working subsystem; correctness is established by the test suite, particularly the authorization tests in Task 3.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
| File | Responsibility |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `lib/plutonium/interaction/runs/configuration.rb` | `enabled`, `queue`, `cleanup_after` config |
|
|
20
|
+
| `db/migrate/interaction_runs/20260817000001_create_plutonium_interaction_runs.rb` | The table |
|
|
21
|
+
| `lib/plutonium/interaction/run.rb` | STI base: state, progress, options, targets |
|
|
22
|
+
| `lib/plutonium/interaction/runs/context.rb` | Rebuilds `(initiator, scoped_entity)`; resolves targets through the policy scope |
|
|
23
|
+
| `lib/plutonium/interaction/runs/executor.rb` | Perform loop + failure policies |
|
|
24
|
+
| `lib/plutonium/interaction/runs/job.rb` | ActiveJob entry point |
|
|
25
|
+
| `lib/plutonium/interaction/concerns/dispatchable.rb` | `dispatches_to` on the interaction |
|
|
26
|
+
| `lib/plutonium/interaction/runs/definition.rb` | Resource definition for the run |
|
|
27
|
+
| `lib/plutonium/interaction/runs/policy.rb` | Who may see a run |
|
|
28
|
+
| `lib/plutonium/ui/interaction/run_progress.rb` | Progress panel (poll frame) |
|
|
29
|
+
| `lib/plutonium/ui/interaction/running_banner.rb` | In-progress list on a target index |
|
|
30
|
+
|
|
31
|
+
Task 3 is the security core. Tasks 1–2 are prerequisites for everything; 4–7 depend on 3.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
### Task 1: Config, migration and registration
|
|
36
|
+
|
|
37
|
+
**Goal:** The `plutonium_interaction_runs` table exists in a host app when the feature is enabled, following the wizard subsystem's exact pattern.
|
|
38
|
+
|
|
39
|
+
**Files:**
|
|
40
|
+
- Create: `lib/plutonium/interaction/runs/configuration.rb`
|
|
41
|
+
- Create: `db/migrate/interaction_runs/20260817000001_create_plutonium_interaction_runs.rb`
|
|
42
|
+
- Modify: `lib/plutonium/railtie.rb` (beside the existing `Migrations.register(:wizards, ...)` call)
|
|
43
|
+
- Modify: `lib/plutonium/configuration.rb` (expose `interaction_runs`)
|
|
44
|
+
- Test: `test/plutonium/interaction/runs/configuration_test.rb`
|
|
45
|
+
|
|
46
|
+
**Acceptance Criteria:**
|
|
47
|
+
- [ ] `Plutonium.configuration.interaction_runs.enabled` defaults to `false`
|
|
48
|
+
- [ ] `Plutonium::Migrations.enabled_paths` includes the runs path only when enabled
|
|
49
|
+
- [ ] Polymorphic `*_id` columns are `string`, so uuid-keyed host apps work
|
|
50
|
+
- [ ] The migration runs cleanly on the dummy app
|
|
51
|
+
|
|
52
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/configuration_test.rb` → 0 failures
|
|
53
|
+
|
|
54
|
+
**Steps:**
|
|
55
|
+
|
|
56
|
+
- [ ] **Step 1: Write the failing test**
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# test/plutonium/interaction/runs/configuration_test.rb
|
|
60
|
+
require "test_helper"
|
|
61
|
+
|
|
62
|
+
class Plutonium::Interaction::Runs::ConfigurationTest < ActiveSupport::TestCase
|
|
63
|
+
test "is disabled by default" do
|
|
64
|
+
refute Plutonium::Interaction::Runs::Configuration.new.enabled
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
test "migration path is surfaced only when enabled" do
|
|
68
|
+
Plutonium::Migrations.reset!
|
|
69
|
+
path = Plutonium.root.join("db/migrate/interaction_runs").to_s
|
|
70
|
+
Plutonium::Migrations.register(:interaction_runs, path)
|
|
71
|
+
|
|
72
|
+
Plutonium.configuration.interaction_runs.enabled = false
|
|
73
|
+
refute_includes Plutonium::Migrations.enabled_paths, path
|
|
74
|
+
|
|
75
|
+
Plutonium.configuration.interaction_runs.enabled = true
|
|
76
|
+
assert_includes Plutonium::Migrations.enabled_paths, path
|
|
77
|
+
ensure
|
|
78
|
+
Plutonium.configuration.interaction_runs.enabled = false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
84
|
+
|
|
85
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/configuration_test.rb`
|
|
86
|
+
Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs`
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 3: Add the configuration class**
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# lib/plutonium/interaction/runs/configuration.rb
|
|
92
|
+
# frozen_string_literal: true
|
|
93
|
+
|
|
94
|
+
module Plutonium
|
|
95
|
+
module Interaction
|
|
96
|
+
module Runs
|
|
97
|
+
# Configuration for persisted interaction runs. Mirrors
|
|
98
|
+
# Plutonium::Wizard::Configuration: `enabled` gates both the subsystem and
|
|
99
|
+
# its migrations (see Plutonium::Migrations).
|
|
100
|
+
class Configuration
|
|
101
|
+
# @return [Boolean] whether runs (and their migrations) are enabled
|
|
102
|
+
attr_accessor :enabled
|
|
103
|
+
|
|
104
|
+
# @return [Symbol] ActiveJob queue for run jobs
|
|
105
|
+
attr_accessor :queue
|
|
106
|
+
|
|
107
|
+
def initialize
|
|
108
|
+
@enabled = false
|
|
109
|
+
@queue = :default
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 4: Expose it on the main configuration**
|
|
118
|
+
|
|
119
|
+
In `lib/plutonium/configuration.rb`, beside `@wizards = Plutonium::Wizard::Configuration.new` in `initialize`:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
@interaction_runs = Plutonium::Interaction::Runs::Configuration.new
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
and add `:interaction_runs` to the same `attr_reader` that exposes `:wizards`.
|
|
126
|
+
|
|
127
|
+
- [ ] **Step 5: Register the migration path**
|
|
128
|
+
|
|
129
|
+
In `lib/plutonium/railtie.rb`, directly below the existing wizard registration:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Plutonium::Migrations.register(:interaction_runs, Plutonium.root.join("db/migrate/interaction_runs").to_s)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- [ ] **Step 6: Write the migration**
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# db/migrate/interaction_runs/20260817000001_create_plutonium_interaction_runs.rb
|
|
139
|
+
# frozen_string_literal: true
|
|
140
|
+
|
|
141
|
+
class CreatePlutoniumInteractionRuns < ActiveRecord::Migration[7.2]
|
|
142
|
+
def change
|
|
143
|
+
create_table :plutonium_interaction_runs do |t|
|
|
144
|
+
# STI discriminator — the author's Run subclass.
|
|
145
|
+
t.string :type, null: false
|
|
146
|
+
t.string :state, null: false, default: "pending" # pending | running | completed | failed
|
|
147
|
+
|
|
148
|
+
# JSON payloads are jsonb, matching plutonium_wizard_sessions: Postgres
|
|
149
|
+
# hosts get equality and GIN indexing (plain json has neither), and SQLite
|
|
150
|
+
# hosts get the type via PLUTONIUM_SQLITE_TYPE_ALIASES, which aliases
|
|
151
|
+
# jsonb -> json. Changing a column type post-release costs every host app a
|
|
152
|
+
# migration, so pick the queryable one now.
|
|
153
|
+
#
|
|
154
|
+
# The dispatching interaction's validated inputs.
|
|
155
|
+
t.public_send(:jsonb, :options, null: false, default: {})
|
|
156
|
+
|
|
157
|
+
# Targets. target_type is a REAL column, not JSON: the index feature
|
|
158
|
+
# queries "runs for this resource", which cannot be indexed out of a
|
|
159
|
+
# JSON array. Ids are stored as given and re-resolved through the policy
|
|
160
|
+
# scope at perform time (see Runs::Context).
|
|
161
|
+
t.string :target_type
|
|
162
|
+
t.public_send(:jsonb, :target_ids, null: false, default: [])
|
|
163
|
+
|
|
164
|
+
# Who started it, and in which tenant.
|
|
165
|
+
#
|
|
166
|
+
# The initiator is ALWAYS required: a nullable initiator would drift into
|
|
167
|
+
# meaning "unscoped", which is the fail-open case this design guards.
|
|
168
|
+
#
|
|
169
|
+
# The scoped entity is nullable BY DESIGN — only entity-scoped portals
|
|
170
|
+
# have one, mirroring plutonium_wizard_sessions.scope_type/scope_id. nil
|
|
171
|
+
# means "no tenant", NOT "tenant unknown", and code rebuilding the policy
|
|
172
|
+
# context must handle nil explicitly rather than assume a tenant is
|
|
173
|
+
# present. This schema offers no non-null guarantee to lean on.
|
|
174
|
+
#
|
|
175
|
+
# *_id is string-typed to accommodate bigint or uuid host primary keys,
|
|
176
|
+
# matching plutonium_wizard_sessions.
|
|
177
|
+
t.string :initiator_type, null: false
|
|
178
|
+
t.string :initiator_id, null: false
|
|
179
|
+
t.string :scoped_entity_type
|
|
180
|
+
t.string :scoped_entity_id
|
|
181
|
+
|
|
182
|
+
# Counts. Both nil for opaque (untargeted) work — the progress UI reads
|
|
183
|
+
# nil as "indeterminate" rather than 0%.
|
|
184
|
+
t.integer :progress_total
|
|
185
|
+
t.integer :progress_done, null: false, default: 0
|
|
186
|
+
|
|
187
|
+
t.public_send(:jsonb, :errors_log, null: false, default: [])
|
|
188
|
+
|
|
189
|
+
t.datetime :started_at
|
|
190
|
+
t.datetime :finished_at
|
|
191
|
+
|
|
192
|
+
t.timestamps
|
|
193
|
+
|
|
194
|
+
t.index [:target_type, :state], name: "idx_pu_runs_on_target_and_state"
|
|
195
|
+
t.index [:initiator_type, :initiator_id], name: "idx_pu_runs_on_initiator"
|
|
196
|
+
t.index [:scoped_entity_type, :scoped_entity_id], name: "idx_pu_runs_on_scoped_entity"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- [ ] **Step 7: Run the test to verify it passes**
|
|
203
|
+
|
|
204
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/configuration_test.rb`
|
|
205
|
+
Expected: PASS, 2 runs 0 failures
|
|
206
|
+
|
|
207
|
+
- [ ] **Step 8: Apply to the dummy app and commit**
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
cd test/dummy && bin/rails db:migrate && cd ../..
|
|
211
|
+
git add lib/plutonium/interaction/runs/configuration.rb db/migrate/interaction_runs lib/plutonium/railtie.rb lib/plutonium/configuration.rb test/plutonium/interaction/runs/configuration_test.rb
|
|
212
|
+
git commit -m "feat(runs): table, config and migration registration for interaction runs"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
### Task 2: The Run model
|
|
218
|
+
|
|
219
|
+
**Goal:** `Plutonium::Interaction::Run` — STI base with state transitions, progress accounting, and typed access to options.
|
|
220
|
+
|
|
221
|
+
**Files:**
|
|
222
|
+
- Create: `lib/plutonium/interaction/run.rb`
|
|
223
|
+
- Test: `test/plutonium/interaction/run_test.rb`
|
|
224
|
+
|
|
225
|
+
**Acceptance Criteria:**
|
|
226
|
+
- [ ] `start!` / `finish!` / `fail!` move `state` and stamp `started_at` / `finished_at`
|
|
227
|
+
- [ ] `progress_fraction` returns nil when `progress_total` is nil (opaque work)
|
|
228
|
+
- [ ] `options` round-trips through JSON with symbol access
|
|
229
|
+
- [ ] `record_target_failure!` appends to `errors_log` without losing prior entries
|
|
230
|
+
- [ ] `targeted?` is true only when the subclass responds to `perform_on`
|
|
231
|
+
|
|
232
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/run_test.rb` → 0 failures
|
|
233
|
+
|
|
234
|
+
**Steps:**
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 1: Write the failing test**
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
# test/plutonium/interaction/run_test.rb
|
|
240
|
+
require "test_helper"
|
|
241
|
+
|
|
242
|
+
class TestArchiveRun < Plutonium::Interaction::Run
|
|
243
|
+
def perform_on(record) = record
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
class TestOpaqueRun < Plutonium::Interaction::Run
|
|
247
|
+
def perform = :done
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
class Plutonium::Interaction::RunTest < ActiveSupport::TestCase
|
|
251
|
+
def build(klass = TestArchiveRun, **attrs)
|
|
252
|
+
klass.new(initiator_type: "User", initiator_id: "1", **attrs)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
test "lifecycle stamps state and timestamps" do
|
|
256
|
+
run = build
|
|
257
|
+
run.start!
|
|
258
|
+
assert_equal "running", run.state
|
|
259
|
+
refute_nil run.started_at
|
|
260
|
+
|
|
261
|
+
run.finish!
|
|
262
|
+
assert_equal "completed", run.state
|
|
263
|
+
refute_nil run.finished_at
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
test "progress is indeterminate for opaque work" do
|
|
267
|
+
assert_nil build(TestOpaqueRun).progress_fraction
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
test "progress is a fraction for targeted work" do
|
|
271
|
+
run = build(progress_total: 4, progress_done: 1)
|
|
272
|
+
assert_in_delta 0.25, run.progress_fraction, 0.001
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
test "target failures accumulate" do
|
|
276
|
+
run = build
|
|
277
|
+
run.record_target_failure!(id: 1, message: "boom")
|
|
278
|
+
run.record_target_failure!(id: 2, message: "bang")
|
|
279
|
+
assert_equal 2, run.errors_log.size
|
|
280
|
+
assert_equal "boom", run.errors_log.first["message"]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
test "targeted? reflects what the subclass implements" do
|
|
284
|
+
assert build(TestArchiveRun).targeted?
|
|
285
|
+
refute build(TestOpaqueRun).targeted?
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
291
|
+
|
|
292
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/run_test.rb`
|
|
293
|
+
Expected: FAIL — `uninitialized constant Plutonium::Interaction::Run`
|
|
294
|
+
|
|
295
|
+
- [ ] **Step 3: Implement the model**
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# lib/plutonium/interaction/run.rb
|
|
299
|
+
# frozen_string_literal: true
|
|
300
|
+
|
|
301
|
+
module Plutonium
|
|
302
|
+
module Interaction
|
|
303
|
+
# A persisted interaction run.
|
|
304
|
+
#
|
|
305
|
+
# STI base: authors subclass to define execution and failure policy, and the
|
|
306
|
+
# subclass name lands in `type`. Options are JSON, so an async action costs
|
|
307
|
+
# no migration.
|
|
308
|
+
#
|
|
309
|
+
# This class deliberately holds NO execution logic — see Runs::Executor. It
|
|
310
|
+
# is the record; the executor is the behaviour. Keeping them apart is what
|
|
311
|
+
# lets the job rebuild an authorization context around the work (Runs::Context)
|
|
312
|
+
# without the model knowing anything about policies.
|
|
313
|
+
class Run < ActiveRecord::Base
|
|
314
|
+
self.table_name = "plutonium_interaction_runs"
|
|
315
|
+
|
|
316
|
+
STATES = %w[pending running completed failed].freeze
|
|
317
|
+
|
|
318
|
+
belongs_to :initiator, polymorphic: true
|
|
319
|
+
belongs_to :scoped_entity, polymorphic: true, optional: true
|
|
320
|
+
|
|
321
|
+
validates :state, inclusion: {in: STATES}
|
|
322
|
+
# Required, not optional: a nullable initiator would drift into meaning
|
|
323
|
+
# "unscoped", which is exactly the fail-open case this design guards.
|
|
324
|
+
validates :initiator_type, :initiator_id, presence: true
|
|
325
|
+
|
|
326
|
+
scope :in_progress, -> { where(state: %w[pending running]) }
|
|
327
|
+
scope :for_target, ->(klass) { where(target_type: klass.to_s) }
|
|
328
|
+
|
|
329
|
+
def start!
|
|
330
|
+
update!(state: "running", started_at: Time.current)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def finish!
|
|
334
|
+
update!(state: "completed", finished_at: Time.current)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def fail!(message = nil)
|
|
338
|
+
record_target_failure!(id: nil, message: message) if message
|
|
339
|
+
update!(state: "failed", finished_at: Time.current)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def in_progress? = %w[pending running].include?(state)
|
|
343
|
+
|
|
344
|
+
# nil means INDETERMINATE, not zero: opaque work has no denominator, and
|
|
345
|
+
# the progress UI renders a spinner rather than a 0% bar.
|
|
346
|
+
def progress_fraction
|
|
347
|
+
return nil if progress_total.nil? || progress_total.zero?
|
|
348
|
+
|
|
349
|
+
progress_done.to_f / progress_total
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def record_target_failure!(id:, message:)
|
|
353
|
+
self.errors_log = errors_log + [{"target_id" => id, "message" => message}]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Which shape of work this is, decided by what the subclass implements
|
|
357
|
+
# rather than a mode flag — one less thing for an author to keep in sync.
|
|
358
|
+
def targeted? = respond_to?(:perform_on)
|
|
359
|
+
|
|
360
|
+
# Failure policy, overridden by subclasses via `on_failure`.
|
|
361
|
+
def self.on_failure(policy)
|
|
362
|
+
@failure_policy = policy
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.failure_policy = @failure_policy || :halt
|
|
366
|
+
|
|
367
|
+
def failure_policy = self.class.failure_policy
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
374
|
+
|
|
375
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/run_test.rb`
|
|
376
|
+
Expected: PASS, 5 runs 0 failures
|
|
377
|
+
|
|
378
|
+
- [ ] **Step 5: Commit**
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
git add lib/plutonium/interaction/run.rb test/plutonium/interaction/run_test.rb
|
|
382
|
+
git commit -m "feat(runs): Run model with state, progress and failure-policy hooks"
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
### Task 3: Authorization context and target resolution
|
|
388
|
+
|
|
389
|
+
**Goal:** Rebuild `(initiator, scoped_entity)` inside a job and resolve targets through the policy scope, so authorization survives leaving the request.
|
|
390
|
+
|
|
391
|
+
**This is the security core of the feature.** Both failure modes here fail *open*.
|
|
392
|
+
|
|
393
|
+
**Files:**
|
|
394
|
+
- Create: `lib/plutonium/interaction/runs/context.rb`
|
|
395
|
+
- Test: `test/plutonium/interaction/runs/context_test.rb`
|
|
396
|
+
|
|
397
|
+
**Acceptance Criteria:**
|
|
398
|
+
- [ ] Targets resolve through the target resource's policy scope for the stored initiator
|
|
399
|
+
- [ ] A target outside the stored `scoped_entity` is NOT returned
|
|
400
|
+
- [ ] A target whose id no longer exists is reported as missing, not silently dropped
|
|
401
|
+
- [ ] Resolution is a single query, not one per id
|
|
402
|
+
|
|
403
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/context_test.rb` → 0 failures
|
|
404
|
+
|
|
405
|
+
**Steps:**
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 1: Write the failing test**
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# test/plutonium/interaction/runs/context_test.rb
|
|
411
|
+
require "test_helper"
|
|
412
|
+
|
|
413
|
+
class Plutonium::Interaction::Runs::ContextTest < ActiveSupport::TestCase
|
|
414
|
+
include IntegrationTestHelper
|
|
415
|
+
|
|
416
|
+
setup do
|
|
417
|
+
@org = create_organization!
|
|
418
|
+
@other_org = create_organization!
|
|
419
|
+
@user = create_user!
|
|
420
|
+
create_membership!(organization: @org, user: @user)
|
|
421
|
+
|
|
422
|
+
@mine = create_post!(user: @user, organization: @org)
|
|
423
|
+
@theirs = create_post!(user: @user, organization: @other_org)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def run_for(ids)
|
|
427
|
+
TestPostRun.new(
|
|
428
|
+
initiator: @user,
|
|
429
|
+
scoped_entity: @org,
|
|
430
|
+
target_type: "Blogging::Post",
|
|
431
|
+
target_ids: ids
|
|
432
|
+
)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
test "resolves targets inside the stored entity scope" do
|
|
436
|
+
resolved = Plutonium::Interaction::Runs::Context.new(run_for([@mine.id])).targets
|
|
437
|
+
assert_equal [@mine.id], resolved.records.map(&:id)
|
|
438
|
+
assert_empty resolved.missing_ids
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
test "a target in another tenant is not returned" do
|
|
442
|
+
resolved = Plutonium::Interaction::Runs::Context.new(run_for([@theirs.id])).targets
|
|
443
|
+
assert_empty resolved.records,
|
|
444
|
+
"a run must never reach outside the entity it was dispatched in"
|
|
445
|
+
assert_equal [@theirs.id], resolved.missing_ids
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
test "a vanished target is reported, not silently dropped" do
|
|
449
|
+
resolved = Plutonium::Interaction::Runs::Context.new(run_for([@mine.id, 999_999])).targets
|
|
450
|
+
assert_equal [@mine.id], resolved.records.map(&:id)
|
|
451
|
+
assert_equal [999_999], resolved.missing_ids
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
test "resolution is one query regardless of target count" do
|
|
455
|
+
ids = [@mine.id] * 25
|
|
456
|
+
queries = 0
|
|
457
|
+
counter = ->(*, payload) { queries += 1 unless payload[:name] == "SCHEMA" }
|
|
458
|
+
ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
|
|
459
|
+
Plutonium::Interaction::Runs::Context.new(run_for(ids)).targets
|
|
460
|
+
end
|
|
461
|
+
assert_operator queries, :<=, 2, "targets must resolve in a single scoped query"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
- [ ] **Step 2: Add the dummy run subclass the test needs**
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
# test/dummy/app/runs/test_post_run.rb
|
|
470
|
+
class TestPostRun < Plutonium::Interaction::Run
|
|
471
|
+
def perform_on(post) = post.touch
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
- [ ] **Step 3: Run it and watch it fail**
|
|
476
|
+
|
|
477
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/context_test.rb`
|
|
478
|
+
Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs::Context`
|
|
479
|
+
|
|
480
|
+
- [ ] **Step 4: Implement the context**
|
|
481
|
+
|
|
482
|
+
```ruby
|
|
483
|
+
# lib/plutonium/interaction/runs/context.rb
|
|
484
|
+
# frozen_string_literal: true
|
|
485
|
+
|
|
486
|
+
module Plutonium
|
|
487
|
+
module Interaction
|
|
488
|
+
module Runs
|
|
489
|
+
# Rebuilds the authorization context a run was dispatched under, and
|
|
490
|
+
# resolves its targets through that context.
|
|
491
|
+
#
|
|
492
|
+
# ## Why this exists
|
|
493
|
+
#
|
|
494
|
+
# `perform` runs in a job: there is no controller, no `current_user`, no
|
|
495
|
+
# request. Plutonium authorizes on the PAIR (user, entity_scope) —
|
|
496
|
+
# Core::Controllers::Authorizable registers
|
|
497
|
+
# `authorize :entity_scope, through: :entity_scope_for_authorize`. Rebuilding
|
|
498
|
+
# only the user would resolve targets under the wrong tenant, and that
|
|
499
|
+
# fails OPEN: a broader scope silently includes records the initiator could
|
|
500
|
+
# not see when they dispatched.
|
|
501
|
+
#
|
|
502
|
+
# Authorization is therefore re-derived HERE, at perform time, and never
|
|
503
|
+
# trusted from dispatch. Access revoked between enqueue and run stops the
|
|
504
|
+
# work — otherwise a persisted run becomes a way to launder stale
|
|
505
|
+
# permissions.
|
|
506
|
+
class Context
|
|
507
|
+
# Resolved targets, plus the ids that did not come back.
|
|
508
|
+
#
|
|
509
|
+
# `missing_ids` is deliberately surfaced rather than swallowed: a target
|
|
510
|
+
# that vanished or fell out of scope is information the operator needs.
|
|
511
|
+
# Silence there is how bulk operations quietly under-apply.
|
|
512
|
+
Targets = Struct.new(:records, :missing_ids, keyword_init: true)
|
|
513
|
+
|
|
514
|
+
def initialize(run)
|
|
515
|
+
@run = run
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
attr_reader :run
|
|
519
|
+
|
|
520
|
+
def initiator = run.initiator
|
|
521
|
+
|
|
522
|
+
def scoped_entity = run.scoped_entity
|
|
523
|
+
|
|
524
|
+
# One scoped query, never N locates. `policy_scope` composes the
|
|
525
|
+
# resource's `relation_scope` with the rebuilt context, so authorization
|
|
526
|
+
# is enforced by construction rather than checked afterwards — which is
|
|
527
|
+
# also why ids beat GlobalIDs here: GlobalID::Locator bypasses the scope
|
|
528
|
+
# entirely.
|
|
529
|
+
def targets
|
|
530
|
+
return Targets.new(records: [], missing_ids: []) if run.target_type.blank?
|
|
531
|
+
|
|
532
|
+
ids = Array(run.target_ids)
|
|
533
|
+
records = authorized_scope.where(id: ids).to_a
|
|
534
|
+
found = records.map { |r| r.id.to_s }
|
|
535
|
+
|
|
536
|
+
Targets.new(records: records, missing_ids: ids.reject { |id| found.include?(id.to_s) })
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def target_class = run.target_type.constantize
|
|
540
|
+
|
|
541
|
+
private
|
|
542
|
+
|
|
543
|
+
def authorized_scope
|
|
544
|
+
policy_class = Plutonium::Resource::Policy.infer_policy_class(target_class)
|
|
545
|
+
policy_class.new(
|
|
546
|
+
target_class,
|
|
547
|
+
user: initiator,
|
|
548
|
+
entity_scope: scoped_entity
|
|
549
|
+
).relation_scope(target_class.all)
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
- [ ] **Step 5: Run the test to verify it passes**
|
|
558
|
+
|
|
559
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/context_test.rb`
|
|
560
|
+
Expected: PASS, 4 runs 0 failures
|
|
561
|
+
|
|
562
|
+
If `infer_policy_class` does not exist under that name, find the codebase's existing policy-inference entry point (`grep -rn "def.*policy_class" lib/plutonium/`) and use it — do not introduce a second inference path.
|
|
563
|
+
|
|
564
|
+
- [ ] **Step 6: Commit**
|
|
565
|
+
|
|
566
|
+
```bash
|
|
567
|
+
git add lib/plutonium/interaction/runs/context.rb test/plutonium/interaction/runs/context_test.rb test/dummy/app/runs/test_post_run.rb
|
|
568
|
+
git commit -m "feat(runs): rebuild the (initiator, scoped_entity) context and resolve targets through the policy scope"
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
### Task 4: Executor and failure policies
|
|
574
|
+
|
|
575
|
+
**Goal:** Run the work, honour `on_failure`, and keep progress current.
|
|
576
|
+
|
|
577
|
+
**Files:**
|
|
578
|
+
- Create: `lib/plutonium/interaction/runs/executor.rb`
|
|
579
|
+
- Create: `lib/plutonium/interaction/runs/job.rb`
|
|
580
|
+
- Test: `test/plutonium/interaction/runs/executor_test.rb`
|
|
581
|
+
|
|
582
|
+
**Acceptance Criteria:**
|
|
583
|
+
- [ ] `on_failure :continue` records the failure and processes remaining targets
|
|
584
|
+
- [ ] `on_failure :halt` stops at the first error and marks the run failed
|
|
585
|
+
- [ ] `on_failure :transactional` rolls every target back on any error
|
|
586
|
+
- [ ] Missing targets are recorded as per-target failures (not skipped)
|
|
587
|
+
- [ ] `progress_done` advances as targets complete
|
|
588
|
+
- [ ] An opaque run calls `perform` once and needs no targets
|
|
589
|
+
|
|
590
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/executor_test.rb` → 0 failures
|
|
591
|
+
|
|
592
|
+
**Steps:**
|
|
593
|
+
|
|
594
|
+
- [ ] **Step 1: Write the failing test**
|
|
595
|
+
|
|
596
|
+
```ruby
|
|
597
|
+
# test/plutonium/interaction/runs/executor_test.rb
|
|
598
|
+
require "test_helper"
|
|
599
|
+
|
|
600
|
+
class ContinueRun < Plutonium::Interaction::Run
|
|
601
|
+
on_failure :continue
|
|
602
|
+
def perform_on(post)
|
|
603
|
+
raise "boom" if post.title.include?("bad")
|
|
604
|
+
post.touch
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
class HaltRun < Plutonium::Interaction::Run
|
|
609
|
+
on_failure :halt
|
|
610
|
+
def perform_on(post) = raise("boom")
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
class OpaqueOkRun < Plutonium::Interaction::Run
|
|
614
|
+
cattr_accessor :ran
|
|
615
|
+
def perform = self.class.ran = true
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
class Plutonium::Interaction::Runs::ExecutorTest < ActiveSupport::TestCase
|
|
619
|
+
include IntegrationTestHelper
|
|
620
|
+
|
|
621
|
+
setup do
|
|
622
|
+
@org = create_organization!
|
|
623
|
+
@user = create_user!
|
|
624
|
+
create_membership!(organization: @org, user: @user)
|
|
625
|
+
@good = create_post!(user: @user, organization: @org, title: "good one")
|
|
626
|
+
@bad = create_post!(user: @user, organization: @org, title: "bad one")
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def run!(klass, ids)
|
|
630
|
+
run = klass.create!(
|
|
631
|
+
initiator: @user, scoped_entity: @org,
|
|
632
|
+
target_type: "Blogging::Post", target_ids: ids,
|
|
633
|
+
progress_total: ids.size
|
|
634
|
+
)
|
|
635
|
+
Plutonium::Interaction::Runs::Executor.new(run).call
|
|
636
|
+
run.reload
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
test "continue records the failure and keeps going" do
|
|
640
|
+
run = run!(ContinueRun, [@bad.id, @good.id])
|
|
641
|
+
assert_equal "completed", run.state
|
|
642
|
+
assert_equal 1, run.errors_log.size
|
|
643
|
+
assert_equal 2, run.progress_done
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
test "halt stops at the first error" do
|
|
647
|
+
run = run!(HaltRun, [@bad.id, @good.id])
|
|
648
|
+
assert_equal "failed", run.state
|
|
649
|
+
assert_equal 1, run.errors_log.size
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
test "a missing target is recorded, not skipped" do
|
|
653
|
+
run = run!(ContinueRun, [@good.id, 999_999])
|
|
654
|
+
assert_equal 1, run.errors_log.size
|
|
655
|
+
assert_match(/no longer available/i, run.errors_log.first["message"])
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
test "an opaque run performs once" do
|
|
659
|
+
OpaqueOkRun.ran = false
|
|
660
|
+
run = OpaqueOkRun.create!(initiator: @user, scoped_entity: @org)
|
|
661
|
+
Plutonium::Interaction::Runs::Executor.new(run).call
|
|
662
|
+
assert OpaqueOkRun.ran
|
|
663
|
+
assert_equal "completed", run.reload.state
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
669
|
+
|
|
670
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/executor_test.rb`
|
|
671
|
+
Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs::Executor`
|
|
672
|
+
|
|
673
|
+
- [ ] **Step 3: Implement the executor**
|
|
674
|
+
|
|
675
|
+
```ruby
|
|
676
|
+
# lib/plutonium/interaction/runs/executor.rb
|
|
677
|
+
# frozen_string_literal: true
|
|
678
|
+
|
|
679
|
+
module Plutonium
|
|
680
|
+
module Interaction
|
|
681
|
+
module Runs
|
|
682
|
+
# Runs a run. Separate from the model so the record stays a record, and
|
|
683
|
+
# separate from the job so it can be driven synchronously in tests.
|
|
684
|
+
class Executor
|
|
685
|
+
def initialize(run)
|
|
686
|
+
@run = run
|
|
687
|
+
@context = Context.new(run)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
attr_reader :run, :context
|
|
691
|
+
|
|
692
|
+
def call
|
|
693
|
+
run.start!
|
|
694
|
+
run.targeted? ? perform_targets : perform_opaque
|
|
695
|
+
rescue => e
|
|
696
|
+
run.fail!(e.message)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
private
|
|
700
|
+
|
|
701
|
+
def perform_opaque
|
|
702
|
+
run.perform
|
|
703
|
+
run.finish!
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def perform_targets
|
|
707
|
+
resolved = context.targets
|
|
708
|
+
|
|
709
|
+
# Recorded, never skipped: "3 targets were no longer available" is
|
|
710
|
+
# what tells an operator the run under-applied.
|
|
711
|
+
resolved.missing_ids.each do |id|
|
|
712
|
+
run.record_target_failure!(id: id, message: "Target #{id} was no longer available")
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
if run.failure_policy == :transactional
|
|
716
|
+
run.class.transaction { resolved.records.each { |record| perform_one(record, reraise: true) } }
|
|
717
|
+
else
|
|
718
|
+
resolved.records.each do |record|
|
|
719
|
+
break if perform_one(record) == :halt
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
run.errors_log.any? && run.failure_policy == :halt ? run.fail! : run.finish!
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# @return [Symbol, nil] :halt when the policy says to stop
|
|
727
|
+
def perform_one(record, reraise: false)
|
|
728
|
+
run.perform_on(record)
|
|
729
|
+
run.progress_done += 1
|
|
730
|
+
run.save!
|
|
731
|
+
nil
|
|
732
|
+
rescue => e
|
|
733
|
+
raise e if reraise
|
|
734
|
+
|
|
735
|
+
run.record_target_failure!(id: record.id, message: e.message)
|
|
736
|
+
run.progress_done += 1
|
|
737
|
+
run.save!
|
|
738
|
+
(run.failure_policy == :halt) ? :halt : nil
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
- [ ] **Step 4: Add the job**
|
|
747
|
+
|
|
748
|
+
```ruby
|
|
749
|
+
# lib/plutonium/interaction/runs/job.rb
|
|
750
|
+
# frozen_string_literal: true
|
|
751
|
+
|
|
752
|
+
module Plutonium
|
|
753
|
+
module Interaction
|
|
754
|
+
module Runs
|
|
755
|
+
# The only thing that crosses the process boundary is the run's id. Every
|
|
756
|
+
# scrap of context — who, which tenant, which targets — is re-read from the
|
|
757
|
+
# row, so nothing is inherited from the dispatching request.
|
|
758
|
+
class Job < ActiveJob::Base
|
|
759
|
+
def perform(run_id)
|
|
760
|
+
run = Plutonium::Interaction::Run.find_by(id: run_id)
|
|
761
|
+
return if run.nil? || !run.in_progress?
|
|
762
|
+
|
|
763
|
+
Executor.new(run).call
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
- [ ] **Step 5: Run the test to verify it passes**
|
|
772
|
+
|
|
773
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/executor_test.rb`
|
|
774
|
+
Expected: PASS, 4 runs 0 failures
|
|
775
|
+
|
|
776
|
+
- [ ] **Step 6: Commit**
|
|
777
|
+
|
|
778
|
+
```bash
|
|
779
|
+
git add lib/plutonium/interaction/runs/executor.rb lib/plutonium/interaction/runs/job.rb test/plutonium/interaction/runs/executor_test.rb
|
|
780
|
+
git commit -m "feat(runs): executor with continue/halt/transactional failure policies"
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
### Task 5: Interactions dispatch
|
|
786
|
+
|
|
787
|
+
**Goal:** `dispatches_to` turns an interaction into a dispatcher — it creates the run, enqueues it, and redirects.
|
|
788
|
+
|
|
789
|
+
**Files:**
|
|
790
|
+
- Create: `lib/plutonium/interaction/concerns/dispatchable.rb`
|
|
791
|
+
- Modify: `lib/plutonium/interaction/base.rb` (include the concern)
|
|
792
|
+
- Test: `test/plutonium/interaction/dispatchable_test.rb`
|
|
793
|
+
|
|
794
|
+
**Acceptance Criteria:**
|
|
795
|
+
- [ ] `dispatches_to SomeRun` makes `execute` create and enqueue a run
|
|
796
|
+
- [ ] The interaction's validated attributes land in `options`
|
|
797
|
+
- [ ] `initiator` and `scoped_entity` are taken from the interaction's context
|
|
798
|
+
- [ ] The outcome is a success whose response redirects to the run
|
|
799
|
+
- [ ] Validation failure enqueues nothing
|
|
800
|
+
|
|
801
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/dispatchable_test.rb` → 0 failures
|
|
802
|
+
|
|
803
|
+
**Steps:**
|
|
804
|
+
|
|
805
|
+
- [ ] **Step 1: Write the failing test**
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
# test/plutonium/interaction/dispatchable_test.rb
|
|
809
|
+
require "test_helper"
|
|
810
|
+
|
|
811
|
+
class DispatchDemoInteraction < Plutonium::Resource::Interaction
|
|
812
|
+
dispatches_to TestPostRun
|
|
813
|
+
attribute :notify_users, :boolean
|
|
814
|
+
validates :notify_users, inclusion: {in: [true, false]}
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
class Plutonium::Interaction::DispatchableTest < ActiveSupport::TestCase
|
|
818
|
+
include IntegrationTestHelper
|
|
819
|
+
|
|
820
|
+
setup do
|
|
821
|
+
@org = create_organization!
|
|
822
|
+
@user = create_user!
|
|
823
|
+
create_membership!(organization: @org, user: @user)
|
|
824
|
+
@post = create_post!(user: @user, organization: @org)
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
test "dispatch creates a run carrying the validated options" do
|
|
828
|
+
outcome = nil
|
|
829
|
+
assert_difference -> { Plutonium::Interaction::Run.count }, 1 do
|
|
830
|
+
outcome = DispatchDemoInteraction.call(
|
|
831
|
+
view_context: nil, notify_users: true,
|
|
832
|
+
initiator: @user, scoped_entity: @org,
|
|
833
|
+
target_type: "Blogging::Post", target_ids: [@post.id]
|
|
834
|
+
)
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
assert outcome.success?
|
|
838
|
+
run = Plutonium::Interaction::Run.last
|
|
839
|
+
assert_equal "TestPostRun", run.type
|
|
840
|
+
assert_equal true, run.options["notify_users"]
|
|
841
|
+
assert_equal @user, run.initiator
|
|
842
|
+
assert_equal @org, run.scoped_entity
|
|
843
|
+
assert_equal [@post.id], run.target_ids
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
test "an invalid interaction enqueues nothing" do
|
|
847
|
+
assert_no_difference -> { Plutonium::Interaction::Run.count } do
|
|
848
|
+
outcome = DispatchDemoInteraction.call(view_context: nil, notify_users: nil, initiator: @user)
|
|
849
|
+
assert outcome.failure?
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
856
|
+
|
|
857
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/dispatchable_test.rb`
|
|
858
|
+
Expected: FAIL — `undefined method 'dispatches_to'`
|
|
859
|
+
|
|
860
|
+
- [ ] **Step 3: Implement the concern**
|
|
861
|
+
|
|
862
|
+
```ruby
|
|
863
|
+
# lib/plutonium/interaction/concerns/dispatchable.rb
|
|
864
|
+
# frozen_string_literal: true
|
|
865
|
+
|
|
866
|
+
module Plutonium
|
|
867
|
+
module Interaction
|
|
868
|
+
module Concerns
|
|
869
|
+
# Turns an interaction into a dispatcher.
|
|
870
|
+
#
|
|
871
|
+
# The interaction keeps its whole existing job — declare inputs, validate,
|
|
872
|
+
# authorize, render a form — and gains one new behaviour: on execute it
|
|
873
|
+
# persists a run and enqueues it.
|
|
874
|
+
#
|
|
875
|
+
# Note what does NOT change: `call` still returns an Outcome synchronously,
|
|
876
|
+
# because dispatching is what succeeded. That is why Outcome needs no
|
|
877
|
+
# Pending state — the work's completion is the RUN's business, reported on
|
|
878
|
+
# its own page, not this request's.
|
|
879
|
+
module Dispatchable
|
|
880
|
+
extend ActiveSupport::Concern
|
|
881
|
+
|
|
882
|
+
class_methods do
|
|
883
|
+
def dispatches_to(run_class)
|
|
884
|
+
@run_class = run_class
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def run_class = @run_class
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
included do
|
|
891
|
+
# Dispatch context, supplied by the controller alongside the user's
|
|
892
|
+
# own inputs. Kept as attributes rather than read from a global so the
|
|
893
|
+
# interaction stays callable outside a request (and testable).
|
|
894
|
+
attribute :initiator
|
|
895
|
+
attribute :scoped_entity
|
|
896
|
+
attribute :target_type, :string
|
|
897
|
+
attribute :target_ids, default: -> { [] }
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
private
|
|
901
|
+
|
|
902
|
+
# Everything the user declared, minus the dispatch plumbing above.
|
|
903
|
+
def dispatch_options
|
|
904
|
+
except = %w[initiator scoped_entity target_type target_ids]
|
|
905
|
+
attributes.except(*except)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def dispatch!
|
|
909
|
+
run = self.class.run_class.create!(
|
|
910
|
+
initiator: initiator,
|
|
911
|
+
scoped_entity: scoped_entity,
|
|
912
|
+
target_type: target_type,
|
|
913
|
+
target_ids: Array(target_ids),
|
|
914
|
+
progress_total: Array(target_ids).presence&.size,
|
|
915
|
+
options: dispatch_options
|
|
916
|
+
)
|
|
917
|
+
Plutonium::Interaction::Runs::Job
|
|
918
|
+
.set(queue: Plutonium.configuration.interaction_runs.queue)
|
|
919
|
+
.perform_later(run.id)
|
|
920
|
+
run
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def execute
|
|
924
|
+
run = dispatch!
|
|
925
|
+
succeed(run).with_redirect_response(run)
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
- [ ] **Step 4: Include it**
|
|
934
|
+
|
|
935
|
+
In `lib/plutonium/interaction/base.rb`, alongside the other includes in the class body:
|
|
936
|
+
|
|
937
|
+
```ruby
|
|
938
|
+
include Plutonium::Interaction::Concerns::Dispatchable
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
- [ ] **Step 5: Run the test to verify it passes**
|
|
942
|
+
|
|
943
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/dispatchable_test.rb`
|
|
944
|
+
Expected: PASS, 2 runs 0 failures
|
|
945
|
+
|
|
946
|
+
If `with_redirect_response` is not the codebase's helper name, check `lib/plutonium/interaction/outcome.rb` for the actual redirect helper and use it — do not add a second one.
|
|
947
|
+
|
|
948
|
+
- [ ] **Step 6: Commit**
|
|
949
|
+
|
|
950
|
+
```bash
|
|
951
|
+
git add lib/plutonium/interaction/concerns/dispatchable.rb lib/plutonium/interaction/base.rb test/plutonium/interaction/dispatchable_test.rb
|
|
952
|
+
git commit -m "feat(runs): dispatches_to — interactions enqueue a run and redirect to it"
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
---
|
|
956
|
+
|
|
957
|
+
### Task 6: The run as a resource, and its progress page
|
|
958
|
+
|
|
959
|
+
> **AS BUILT — the steps below are the original draft and are wrong in five
|
|
960
|
+
> places.** Recorded here because the plan is a record, not a spec, once a task
|
|
961
|
+
> has landed. Implemented in `0615ef3d`; each delta was verified as forced by a
|
|
962
|
+
> framework constraint, not chosen:
|
|
963
|
+
>
|
|
964
|
+
> - **`RunDefinition` / `RunPolicy`, not `Runs::Definition` / `Runs::Policy`.**
|
|
965
|
+
> Definition lookup is hard-coded: `"#{resource_class}Definition".constantize`
|
|
966
|
+
> (`controllers/defineable.rb:14`, `resource/controller.rb:233`). A namespaced
|
|
967
|
+
> definition is never found, and `Definition::Base.model_class` infers the
|
|
968
|
+
> model back out of that same name.
|
|
969
|
+
> - **`Run` must `include Plutonium::Resource::Record`**, above the `belongs_to`s.
|
|
970
|
+
> `Resource::Register#register` raises otherwise (`register.rb:25`).
|
|
971
|
+
> - **`Run` needs a `model_name` override.** The default spells
|
|
972
|
+
> `/admin/plutonium/interaction/runs` into every URL. Pinned to the base class
|
|
973
|
+
> so an STI subclass resolves the one registered route.
|
|
974
|
+
> - **`associated_with` had to be written by hand.** The generic one matches
|
|
975
|
+
> `assoc.klass.name == record.class.name unless assoc.polymorphic?`
|
|
976
|
+
> (`record/associated_with.rb:49`), so it cannot see `scoped_entity` and raises
|
|
977
|
+
> for every host tenant model. Replaced with `where(scoped_entity: entity)`.
|
|
978
|
+
> - **Registration needs `controller:`** — `register_resource
|
|
979
|
+
> ::Plutonium::Interaction::Run, controller: "interaction_runs"`. Without it the
|
|
980
|
+
> path derives to `plutonium/interaction/runs`, so the host must define
|
|
981
|
+
> `MyPortal::Plutonium::Interaction::RunsController`, and `MyPortal::Plutonium`
|
|
982
|
+
> then shadows the gem's `::Plutonium` for every constant looked up lexically
|
|
983
|
+
> inside `module MyPortal` — breaking the portal's own
|
|
984
|
+
> `include Plutonium::Portal::Controller`. One additive line in
|
|
985
|
+
> `routing/route_set_extensions.rb` supports it.
|
|
986
|
+
>
|
|
987
|
+
> Also: **no `relation_scope` is declared at all.** Tenancy lives on the model's
|
|
988
|
+
> `associated_with`, which the base policy's macro already routes through
|
|
989
|
+
> `default_relation_scope` — so the rule is in one place rather than restated
|
|
990
|
+
> where it could drift. And the `turbo_poll_interval` data attribute below does
|
|
991
|
+
> not exist; polling is a Stimulus controller rendered *inside* the frame, which
|
|
992
|
+
> the server only emits while `run.in_progress?`, so the timer stops when the
|
|
993
|
+
> replacement markup arrives without it.
|
|
994
|
+
|
|
995
|
+
**Goal:** A portal can register runs; the show page is the progress page and refreshes itself.
|
|
996
|
+
|
|
997
|
+
**Files:**
|
|
998
|
+
- Create: `lib/plutonium/interaction/runs/definition.rb`
|
|
999
|
+
- Create: `lib/plutonium/interaction/runs/policy.rb`
|
|
1000
|
+
- Create: `lib/plutonium/ui/interaction/run_progress.rb`
|
|
1001
|
+
- Test: `test/plutonium/interaction/runs/policy_test.rb`
|
|
1002
|
+
- Test: `test/integration/admin_portal/interaction_run_progress_test.rb`
|
|
1003
|
+
|
|
1004
|
+
**Acceptance Criteria:**
|
|
1005
|
+
- [ ] `relation_scope` returns only runs in the current entity scope
|
|
1006
|
+
- [ ] A user cannot see another tenant's run
|
|
1007
|
+
- [ ] The show page renders state, progress and any recorded failures
|
|
1008
|
+
- [ ] While in progress the page carries a self-refreshing turbo-frame; when finished it does not
|
|
1009
|
+
- [ ] Progress renders as a spinner (not 0%) when `progress_total` is nil
|
|
1010
|
+
|
|
1011
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/policy_test.rb test/integration/admin_portal/interaction_run_progress_test.rb` → 0 failures
|
|
1012
|
+
|
|
1013
|
+
**Steps:**
|
|
1014
|
+
|
|
1015
|
+
- [ ] **Step 1: Write the failing policy test**
|
|
1016
|
+
|
|
1017
|
+
```ruby
|
|
1018
|
+
# test/plutonium/interaction/runs/policy_test.rb
|
|
1019
|
+
require "test_helper"
|
|
1020
|
+
|
|
1021
|
+
class Plutonium::Interaction::Runs::PolicyTest < ActiveSupport::TestCase
|
|
1022
|
+
include IntegrationTestHelper
|
|
1023
|
+
|
|
1024
|
+
setup do
|
|
1025
|
+
@org = create_organization!
|
|
1026
|
+
@other = create_organization!
|
|
1027
|
+
@user = create_user!
|
|
1028
|
+
create_membership!(organization: @org, user: @user)
|
|
1029
|
+
|
|
1030
|
+
@mine = TestPostRun.create!(initiator: @user, scoped_entity: @org)
|
|
1031
|
+
@theirs = TestPostRun.create!(initiator: @user, scoped_entity: @other)
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
test "a run is visible only inside its own entity scope" do
|
|
1035
|
+
scope = Plutonium::Interaction::Runs::Policy
|
|
1036
|
+
.new(Plutonium::Interaction::Run, user: @user, entity_scope: @org)
|
|
1037
|
+
.relation_scope(Plutonium::Interaction::Run.all)
|
|
1038
|
+
|
|
1039
|
+
assert_includes scope, @mine
|
|
1040
|
+
refute_includes scope, @theirs,
|
|
1041
|
+
"a run from another tenant must never be listed"
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
1047
|
+
|
|
1048
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/policy_test.rb`
|
|
1049
|
+
Expected: FAIL — `uninitialized constant Plutonium::Interaction::Runs::Policy`
|
|
1050
|
+
|
|
1051
|
+
- [ ] **Step 3: Implement the policy**
|
|
1052
|
+
|
|
1053
|
+
```ruby
|
|
1054
|
+
# lib/plutonium/interaction/runs/policy.rb
|
|
1055
|
+
# frozen_string_literal: true
|
|
1056
|
+
|
|
1057
|
+
module Plutonium
|
|
1058
|
+
module Interaction
|
|
1059
|
+
module Runs
|
|
1060
|
+
# Runs are read-only to users: they are a record of something that
|
|
1061
|
+
# happened, so there is no create/update/destroy surface. Visibility is
|
|
1062
|
+
# scoped by the entity the run was dispatched in — the same boundary the
|
|
1063
|
+
# work itself respected.
|
|
1064
|
+
class Policy < Plutonium::Resource::Policy
|
|
1065
|
+
def index? = true
|
|
1066
|
+
|
|
1067
|
+
def show? = true
|
|
1068
|
+
|
|
1069
|
+
def create? = false
|
|
1070
|
+
|
|
1071
|
+
def update? = false
|
|
1072
|
+
|
|
1073
|
+
def destroy? = false
|
|
1074
|
+
|
|
1075
|
+
# The MACRO form, not `def relation_scope`. An instance method of that
|
|
1076
|
+
# name overrides nothing — apply_scope dispatches to the macro-generated
|
|
1077
|
+
# __scoping__active_record_relation__default — so the narrowing would
|
|
1078
|
+
# silently never run. Since PR #96 that mistake raises at class load
|
|
1079
|
+
# rather than failing open.
|
|
1080
|
+
#
|
|
1081
|
+
# Consequences of being a block rather than a method: `super` is not
|
|
1082
|
+
# available (call default_relation_scope, which also satisfies the
|
|
1083
|
+
# multi-tenancy guard), and `return` would return from the enclosing
|
|
1084
|
+
# method, so use `next`.
|
|
1085
|
+
relation_scope do |relation|
|
|
1086
|
+
scope = default_relation_scope(relation)
|
|
1087
|
+
next scope unless entity_scope
|
|
1088
|
+
|
|
1089
|
+
scope.where(scoped_entity_type: entity_scope.class.name, scoped_entity_id: entity_scope.id.to_s)
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def permitted_attributes_for_read
|
|
1093
|
+
%i[type state progress_done progress_total started_at finished_at initiator]
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
- [ ] **Step 4: Implement the definition**
|
|
1102
|
+
|
|
1103
|
+
```ruby
|
|
1104
|
+
# lib/plutonium/interaction/runs/definition.rb
|
|
1105
|
+
# frozen_string_literal: true
|
|
1106
|
+
|
|
1107
|
+
module Plutonium
|
|
1108
|
+
module Interaction
|
|
1109
|
+
module Runs
|
|
1110
|
+
class Definition < Plutonium::Resource::Definition
|
|
1111
|
+
field :type, as: :string
|
|
1112
|
+
field :state, as: :string
|
|
1113
|
+
field :started_at, as: :datetime
|
|
1114
|
+
field :finished_at, as: :datetime
|
|
1115
|
+
|
|
1116
|
+
display :state, as: :badge
|
|
1117
|
+
|
|
1118
|
+
column :type
|
|
1119
|
+
column :state
|
|
1120
|
+
column :started_at
|
|
1121
|
+
end
|
|
1122
|
+
end
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
- [ ] **Step 5: Implement the progress panel**
|
|
1128
|
+
|
|
1129
|
+
```ruby
|
|
1130
|
+
# lib/plutonium/ui/interaction/run_progress.rb
|
|
1131
|
+
# frozen_string_literal: true
|
|
1132
|
+
|
|
1133
|
+
module Plutonium
|
|
1134
|
+
module UI
|
|
1135
|
+
module Interaction
|
|
1136
|
+
# The progress panel on a run's show page.
|
|
1137
|
+
#
|
|
1138
|
+
# Polls by default: a turbo-frame with a refresh interval needs no
|
|
1139
|
+
# ActionCable, so this works on any deployment. A run may opt into
|
|
1140
|
+
# realtime and reuse Kanban::Broadcaster, whose stream names are already
|
|
1141
|
+
# keyed on both resource_class AND scoped_entity so tenants can never
|
|
1142
|
+
# share a stream.
|
|
1143
|
+
#
|
|
1144
|
+
# The frame only refreshes WHILE the run is in progress — a finished run
|
|
1145
|
+
# is a static record, and leaving the poll running would be a background
|
|
1146
|
+
# request per viewer forever.
|
|
1147
|
+
class RunProgress < Plutonium::UI::Component::Base
|
|
1148
|
+
def initialize(run) = @run = run
|
|
1149
|
+
|
|
1150
|
+
attr_reader :run
|
|
1151
|
+
|
|
1152
|
+
def view_template
|
|
1153
|
+
turbo_frame_tag(dom_id(run), **frame_options) do
|
|
1154
|
+
div(class: "space-y-3") do
|
|
1155
|
+
render_state
|
|
1156
|
+
render_progress
|
|
1157
|
+
render_failures if run.errors_log.any?
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
private
|
|
1163
|
+
|
|
1164
|
+
def frame_options
|
|
1165
|
+
return {} unless run.in_progress?
|
|
1166
|
+
|
|
1167
|
+
{src: resource_url_for(run), loading: :lazy, data: {turbo_poll_interval: 2000}}
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
def render_state
|
|
1171
|
+
div(class: "text-sm font-medium") { plain run.state.humanize }
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
def render_progress
|
|
1175
|
+
fraction = run.progress_fraction
|
|
1176
|
+
if fraction.nil?
|
|
1177
|
+
# Indeterminate: opaque work has no denominator, and a 0% bar would
|
|
1178
|
+
# read as "nothing has happened" rather than "unknown".
|
|
1179
|
+
div(class: "text-xs text-[var(--pu-text-muted)]") { plain "Working…" }
|
|
1180
|
+
else
|
|
1181
|
+
div(class: "w-full h-2 rounded bg-[var(--pu-surface-alt)]") do
|
|
1182
|
+
div(class: "h-2 rounded bg-primary-600", style: "width: #{(fraction * 100).round}%")
|
|
1183
|
+
end
|
|
1184
|
+
div(class: "text-xs text-[var(--pu-text-muted)]") { plain "#{run.progress_done} of #{run.progress_total}" }
|
|
1185
|
+
end
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
def render_failures
|
|
1189
|
+
ul(class: "text-xs text-danger-600 space-y-1") do
|
|
1190
|
+
run.errors_log.each { |entry| li { plain entry["message"] } }
|
|
1191
|
+
end
|
|
1192
|
+
end
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
end
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
- [ ] **Step 6: Write the integration test**
|
|
1200
|
+
|
|
1201
|
+
```ruby
|
|
1202
|
+
# test/integration/admin_portal/interaction_run_progress_test.rb
|
|
1203
|
+
require "test_helper"
|
|
1204
|
+
|
|
1205
|
+
class AdminPortal::InteractionRunProgressTest < ActionDispatch::IntegrationTest
|
|
1206
|
+
include IntegrationTestHelper
|
|
1207
|
+
|
|
1208
|
+
setup do
|
|
1209
|
+
@admin = create_admin!
|
|
1210
|
+
login_as_admin(@admin)
|
|
1211
|
+
@org = create_organization!
|
|
1212
|
+
@user = create_user!
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
test "an in-progress run polls; a finished one does not" do
|
|
1216
|
+
run = TestPostRun.create!(initiator: @user, scoped_entity: @org, state: "running",
|
|
1217
|
+
progress_total: 4, progress_done: 1)
|
|
1218
|
+
|
|
1219
|
+
get "/admin/interaction_runs/#{run.id}"
|
|
1220
|
+
assert_response :success
|
|
1221
|
+
assert_match(/turbo-poll-interval/, response.body,
|
|
1222
|
+
"an in-progress run must refresh itself")
|
|
1223
|
+
|
|
1224
|
+
run.finish!
|
|
1225
|
+
get "/admin/interaction_runs/#{run.id}"
|
|
1226
|
+
refute_match(/turbo-poll-interval/, response.body,
|
|
1227
|
+
"a finished run is static; polling forever is a request per viewer")
|
|
1228
|
+
end
|
|
1229
|
+
end
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
- [ ] **Step 7: Register the resource in the dummy admin portal**
|
|
1233
|
+
|
|
1234
|
+
In `test/dummy/packages/admin_portal/config/routes.rb`, beside the other `register_resource` calls:
|
|
1235
|
+
|
|
1236
|
+
```ruby
|
|
1237
|
+
register_resource ::Plutonium::Interaction::Run
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
- [ ] **Step 8: Run both tests to verify they pass**
|
|
1241
|
+
|
|
1242
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/runs/policy_test.rb test/integration/admin_portal/interaction_run_progress_test.rb`
|
|
1243
|
+
Expected: PASS, 0 failures
|
|
1244
|
+
|
|
1245
|
+
- [ ] **Step 9: Commit**
|
|
1246
|
+
|
|
1247
|
+
```bash
|
|
1248
|
+
git add lib/plutonium/interaction/runs/definition.rb lib/plutonium/interaction/runs/policy.rb lib/plutonium/ui/interaction/run_progress.rb test/plutonium/interaction/runs/policy_test.rb test/integration/admin_portal/interaction_run_progress_test.rb test/dummy/packages/admin_portal/config/routes.rb
|
|
1249
|
+
git commit -m "feat(runs): run as a resource, with a self-refreshing progress page"
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
---
|
|
1253
|
+
|
|
1254
|
+
### Task 7: In-progress runs on the target index
|
|
1255
|
+
|
|
1256
|
+
**Goal:** A resource's index lists runs currently working on it, at the top.
|
|
1257
|
+
|
|
1258
|
+
**Files:**
|
|
1259
|
+
- Create: `lib/plutonium/ui/interaction/running_banner.rb`
|
|
1260
|
+
- Modify: `lib/plutonium/ui/page/index.rb` (render the banner above the collection)
|
|
1261
|
+
- Test: `test/integration/admin_portal/interaction_run_banner_test.rb`
|
|
1262
|
+
|
|
1263
|
+
**Acceptance Criteria:**
|
|
1264
|
+
- [ ] The index shows a banner listing in-progress runs whose `target_type` is this resource
|
|
1265
|
+
- [ ] Completed runs do not appear
|
|
1266
|
+
- [ ] Runs targeting a different resource do not appear
|
|
1267
|
+
- [ ] Another tenant's run does not appear
|
|
1268
|
+
- [ ] No banner renders when there are none (no empty chrome)
|
|
1269
|
+
|
|
1270
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/interaction_run_banner_test.rb` → 0 failures
|
|
1271
|
+
|
|
1272
|
+
**Steps:**
|
|
1273
|
+
|
|
1274
|
+
- [ ] **Step 1: Write the failing test**
|
|
1275
|
+
|
|
1276
|
+
```ruby
|
|
1277
|
+
# test/integration/admin_portal/interaction_run_banner_test.rb
|
|
1278
|
+
require "test_helper"
|
|
1279
|
+
|
|
1280
|
+
class AdminPortal::InteractionRunBannerTest < ActionDispatch::IntegrationTest
|
|
1281
|
+
include IntegrationTestHelper
|
|
1282
|
+
|
|
1283
|
+
setup do
|
|
1284
|
+
@admin = create_admin!
|
|
1285
|
+
login_as_admin(@admin)
|
|
1286
|
+
@user = create_user!
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
test "the index lists in-progress runs for this resource only" do
|
|
1290
|
+
running = TestPostRun.create!(initiator: @user, target_type: "Blogging::Post", state: "running")
|
|
1291
|
+
done = TestPostRun.create!(initiator: @user, target_type: "Blogging::Post", state: "completed")
|
|
1292
|
+
other = TestPostRun.create!(initiator: @user, target_type: "Catalog::Product", state: "running")
|
|
1293
|
+
|
|
1294
|
+
get "/admin/blogging/posts"
|
|
1295
|
+
assert_response :success
|
|
1296
|
+
|
|
1297
|
+
assert_match(/pu-running-banner/, response.body)
|
|
1298
|
+
assert_match(/data-run-id="#{running.id}"/, response.body)
|
|
1299
|
+
refute_match(/data-run-id="#{done.id}"/, response.body, "a finished run is not in progress")
|
|
1300
|
+
refute_match(/data-run-id="#{other.id}"/, response.body, "that run targets another resource")
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
test "no banner renders when nothing is running" do
|
|
1304
|
+
get "/admin/blogging/posts"
|
|
1305
|
+
assert_response :success
|
|
1306
|
+
refute_match(/pu-running-banner/, response.body, "an empty banner is chrome for nothing")
|
|
1307
|
+
end
|
|
1308
|
+
end
|
|
1309
|
+
```
|
|
1310
|
+
|
|
1311
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
1312
|
+
|
|
1313
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/interaction_run_banner_test.rb`
|
|
1314
|
+
Expected: FAIL — no `pu-running-banner` in the body
|
|
1315
|
+
|
|
1316
|
+
- [ ] **Step 3: Implement the banner**
|
|
1317
|
+
|
|
1318
|
+
```ruby
|
|
1319
|
+
# lib/plutonium/ui/interaction/running_banner.rb
|
|
1320
|
+
# frozen_string_literal: true
|
|
1321
|
+
|
|
1322
|
+
module Plutonium
|
|
1323
|
+
module UI
|
|
1324
|
+
module Interaction
|
|
1325
|
+
# Lists runs currently working on THIS resource, above its collection.
|
|
1326
|
+
#
|
|
1327
|
+
# Queried on target_type — which is why that is a real column rather than
|
|
1328
|
+
# a key inside the options JSON: this predicate has to be indexable.
|
|
1329
|
+
#
|
|
1330
|
+
# Renders nothing when there is nothing running. An always-present empty
|
|
1331
|
+
# banner is chrome that teaches the user to ignore the area.
|
|
1332
|
+
class RunningBanner < Plutonium::UI::Component::Base
|
|
1333
|
+
def initialize(resource_class:, runs:)
|
|
1334
|
+
@resource_class = resource_class
|
|
1335
|
+
@runs = runs
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
attr_reader :resource_class, :runs
|
|
1339
|
+
|
|
1340
|
+
def view_template
|
|
1341
|
+
return if runs.empty?
|
|
1342
|
+
|
|
1343
|
+
div(class: "pu-running-banner mb-3 rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] bg-[var(--pu-surface-alt)] p-3 space-y-2") do
|
|
1344
|
+
runs.each { |run| render_run(run) }
|
|
1345
|
+
end
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
private
|
|
1349
|
+
|
|
1350
|
+
def render_run(run)
|
|
1351
|
+
div(class: "flex items-center justify-between gap-3 text-sm", data: {run_id: run.id}) do
|
|
1352
|
+
span { plain "#{run.type.titleize} — #{run.state}" }
|
|
1353
|
+
a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary") { plain "View progress" }
|
|
1354
|
+
end
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
- [ ] **Step 4: Render it from the index page**
|
|
1363
|
+
|
|
1364
|
+
In `lib/plutonium/ui/page/index.rb`, immediately above where the collection renders:
|
|
1365
|
+
|
|
1366
|
+
```ruby
|
|
1367
|
+
if Plutonium.configuration.interaction_runs.enabled
|
|
1368
|
+
render Plutonium::UI::Interaction::RunningBanner.new(
|
|
1369
|
+
resource_class: resource_class,
|
|
1370
|
+
runs: current_authorized_scope_for(Plutonium::Interaction::Run)
|
|
1371
|
+
.for_target(resource_class).in_progress.to_a
|
|
1372
|
+
)
|
|
1373
|
+
end
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
Use the index page's existing authorized-scope helper — `grep -n "authorized_scope" lib/plutonium/ui/page/index.rb` — so the banner is scoped exactly as the collection is, and a run from another tenant can never appear.
|
|
1377
|
+
|
|
1378
|
+
- [ ] **Step 5: Run the test to verify it passes**
|
|
1379
|
+
|
|
1380
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/interaction_run_banner_test.rb`
|
|
1381
|
+
Expected: PASS, 2 runs 0 failures
|
|
1382
|
+
|
|
1383
|
+
- [ ] **Step 6: Run the full suite and commit**
|
|
1384
|
+
|
|
1385
|
+
```bash
|
|
1386
|
+
bundle exec appraisal rails-8.1 rake test
|
|
1387
|
+
bundle exec standardrb
|
|
1388
|
+
git add lib/plutonium/ui/interaction/running_banner.rb lib/plutonium/ui/page/index.rb test/integration/admin_portal/interaction_run_banner_test.rb
|
|
1389
|
+
git commit -m "feat(runs): list in-progress runs above a resource's collection"
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
---
|
|
1393
|
+
|
|
1394
|
+
## Deliberately deferred
|
|
1395
|
+
|
|
1396
|
+
**Opt-in realtime broadcast.** The spec offers it alongside polling; this plan
|
|
1397
|
+
ships polling only. Polling satisfies the requirement on every deployment,
|
|
1398
|
+
broadcast is an optimisation, and adding it now would put an ActionCable path
|
|
1399
|
+
into a subsystem whose authorization story is the risky part. When it is wanted,
|
|
1400
|
+
it is additive: `Kanban::Broadcaster` already builds tenant-safe stream names
|
|
1401
|
+
from `(resource_class, scoped_entity)`, and `RunProgress` already branches on
|
|
1402
|
+
`run.in_progress?` — the broadcast case slots into that branch.
|
|
1403
|
+
|
|
1404
|
+
The spec's own out-of-scope list (scheduling, approvals, retries, import/export
|
|
1405
|
+
run types, sweeping) stands unchanged.
|
|
1406
|
+
|
|
1407
|
+
## Final verification
|
|
1408
|
+
|
|
1409
|
+
```bash
|
|
1410
|
+
bundle exec appraisal rake test # all three Rails versions
|
|
1411
|
+
bundle exec standardrb
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
Expected: 0 failures across rails-7, rails-8.0 and rails-8.1; standardrb clean.
|