plutonium 0.62.2 → 0.63.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/CHANGELOG.md +67 -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 +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- 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 +29 -11
- 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 +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Interaction
|
|
5
|
+
module Async
|
|
6
|
+
# A persisted interaction run.
|
|
7
|
+
#
|
|
8
|
+
# STI base: authors subclass to define execution and failure policy, and the
|
|
9
|
+
# subclass name lands in +type+. Options are JSON, so an async action costs
|
|
10
|
+
# no migration.
|
|
11
|
+
#
|
|
12
|
+
# This class deliberately holds NO execution logic — that lives in the
|
|
13
|
+
# runs executor. It is the record; the executor is the behaviour. Keeping
|
|
14
|
+
# them apart is what lets the job rebuild an authorization context around
|
|
15
|
+
# the work without the model knowing anything about policies.
|
|
16
|
+
class Run < ActiveRecord::Base
|
|
17
|
+
# Before the belongs_to declarations below: Record::Associations decorates
|
|
18
|
+
# belongs_to/has_many to generate the sgid accessors, so an association
|
|
19
|
+
# declared above the include gets none. It is also what makes a run
|
|
20
|
+
# registerable as a resource — Resource::Register refuses anything that
|
|
21
|
+
# does not include this.
|
|
22
|
+
include Plutonium::Resource::Record
|
|
23
|
+
|
|
24
|
+
self.table_name = "plutonium_async_runs"
|
|
25
|
+
|
|
26
|
+
STATES = %w[pending running completed failed].freeze
|
|
27
|
+
IN_PROGRESS_STATES = %w[pending running].freeze
|
|
28
|
+
FAILURE_POLICIES = %i[halt continue transactional].freeze
|
|
29
|
+
|
|
30
|
+
# Routes, paths and helpers are all derived from model_name, and the
|
|
31
|
+
# default would spell the gem's own namespace out in every URL
|
|
32
|
+
# (/admin/plutonium/interaction/runs) and every helper
|
|
33
|
+
# (plutonium_async_run_path). Pinned to the BASE class rather than
|
|
34
|
+
# +self+ so every STI subclass routes to the one registered resource:
|
|
35
|
+
# resource_url_for(a TestPostRun) has to find the Run's route config.
|
|
36
|
+
#
|
|
37
|
+
# "Run" rather than the shorter "Run" on purpose: a bare "Run"
|
|
38
|
+
# would squat the most generic name available and collide silently with a
|
|
39
|
+
# host app's own Run model (CI runs, ML training runs, delivery runs) on
|
|
40
|
+
# every axis that matters -- route path, controller name, param_key and
|
|
41
|
+
# i18n key are all identical, and nothing in Resource::Register detects it.
|
|
42
|
+
MODEL_NAME = ActiveModel::Name.new(self, nil, "AsyncRun")
|
|
43
|
+
|
|
44
|
+
def self.model_name = MODEL_NAME
|
|
45
|
+
|
|
46
|
+
# Failure policy, declared by subclasses via +on_failure+ and consumed by
|
|
47
|
+
# the executor. A class_attribute rather than a plain class ivar so a
|
|
48
|
+
# deep STI hierarchy (B < A < Run) inherits what A declared.
|
|
49
|
+
class_attribute :failure_policy, instance_writer: false, default: :halt
|
|
50
|
+
|
|
51
|
+
belongs_to :initiator, polymorphic: true
|
|
52
|
+
# The nested-route parent, present only for a dispatch from a nested
|
|
53
|
+
# route. Paired with +parent_association+; see the migration.
|
|
54
|
+
belongs_to :parent, polymorphic: true, optional: true
|
|
55
|
+
# Nullable by design: only entity-scoped portals have a tenant. nil means
|
|
56
|
+
# "no tenant", NOT "tenant unknown".
|
|
57
|
+
belongs_to :scoped_entity, polymorphic: true, optional: true
|
|
58
|
+
|
|
59
|
+
validates :state, inclusion: {in: STATES}
|
|
60
|
+
|
|
61
|
+
# Guarantees its own job gets enqueued once this row is durably visible
|
|
62
|
+
# (same shape as InviteToken#send_invitation_email) — not done by the
|
|
63
|
+
# caller, which would race a fast/inline job adapter against the commit.
|
|
64
|
+
after_commit :enqueue_job, on: :create
|
|
65
|
+
|
|
66
|
+
scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }
|
|
67
|
+
scope :for_target, ->(klass) { where(target_type: klass.to_s) }
|
|
68
|
+
|
|
69
|
+
# In progress with no recorded activity since +before+ — see
|
|
70
|
+
# Async::ReapJob. +last_activity_at+ is nil until a job actually claims
|
|
71
|
+
# the run (see Async::Executor#claim!), so a run never picked up at all
|
|
72
|
+
# falls back to +created_at+ — otherwise it would never match and a
|
|
73
|
+
# dropped enqueue would sit "pending" forever, unreapable.
|
|
74
|
+
scope :stalled, ->(before:) {
|
|
75
|
+
in_progress.where("COALESCE(last_activity_at, created_at) < ?", before)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Overrides the generic scope Record::AssociatedWith installs, which
|
|
79
|
+
# resolves a tenant by walking reflections. Both of this table's links to
|
|
80
|
+
# a tenant are POLYMORPHIC, and that walk skips polymorphic associations
|
|
81
|
+
# (it cannot know the class) — so the generic version raises
|
|
82
|
+
# "could not resolve the association" for every host tenant model.
|
|
83
|
+
#
|
|
84
|
+
# The tenant a run belongs to is not a fact about the object graph anyway:
|
|
85
|
+
# it is the scope the run was DISPATCHED in, recorded on the row. Reading
|
|
86
|
+
# it from the row is what makes the tenant filter mean the same thing here
|
|
87
|
+
# as it did at dispatch, for any host tenant model, with no host-side
|
|
88
|
+
# scope to define.
|
|
89
|
+
#
|
|
90
|
+
# A nil scoped_entity is therefore excluded from every tenant, which is
|
|
91
|
+
# the correct direction: nil means "dispatched outside any tenant", so it
|
|
92
|
+
# belongs to none of them.
|
|
93
|
+
scope :associated_with, ->(entity) { where(scoped_entity: entity) }
|
|
94
|
+
|
|
95
|
+
# Declares how the executor reacts to a target failure. Rejects an unknown
|
|
96
|
+
# policy at class-definition time rather than letting a typo surface as
|
|
97
|
+
# surprise behaviour deep inside a background job.
|
|
98
|
+
#
|
|
99
|
+
# @param policy [Symbol] one of {FAILURE_POLICIES}
|
|
100
|
+
# @return [void]
|
|
101
|
+
def self.on_failure(policy)
|
|
102
|
+
unless FAILURE_POLICIES.include?(policy)
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"unknown failure policy: #{policy.inspect} (expected one of #{FAILURE_POLICIES.map(&:inspect).join(", ")})"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
self.failure_policy = policy
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def start! = update!(state: "running", started_at: Time.current, last_activity_at: Time.current)
|
|
111
|
+
|
|
112
|
+
def finish! = update!(state: "completed", finished_at: Time.current, last_activity_at: Time.current)
|
|
113
|
+
|
|
114
|
+
def fail!(message = nil)
|
|
115
|
+
record_target_failure!(id: nil, message: message) if message
|
|
116
|
+
update!(state: "failed", finished_at: Time.current, last_activity_at: Time.current)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def in_progress? = IN_PROGRESS_STATES.include?(state)
|
|
120
|
+
|
|
121
|
+
# What actually happened, as opposed to how the executor exited.
|
|
122
|
+
#
|
|
123
|
+
# A :continue run that could not apply some of its targets ends as
|
|
124
|
+
# +completed+ (see Async::Executor#perform_targets) — the author declared
|
|
125
|
+
# partial application acceptable and the loop ran to the end. But
|
|
126
|
+
# "completed" on its own is what a clean success looks like, so rendering
|
|
127
|
+
# it alone would make a run that under-applied indistinguishable from one
|
|
128
|
+
# that did everything asked of it. Every reader — badge, table column,
|
|
129
|
+
# progress panel — goes through this instead of through +state+.
|
|
130
|
+
#
|
|
131
|
+
# @return [String]
|
|
132
|
+
def outcome
|
|
133
|
+
(state == "completed" && errors_log.any?) ? "completed_with_errors" : state
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Integer] recorded target failures, run-level entries included
|
|
137
|
+
def error_count = errors_log.size
|
|
138
|
+
|
|
139
|
+
# Human, I18n-aware name of the target resource class — "Post", not
|
|
140
|
+
# "Blogging::Post" — for display (see Async::RunDefinition). Falls back to the
|
|
141
|
+
# raw string for a target_type renamed/removed since this run was
|
|
142
|
+
# dispatched, rather than raising on an old row. nil for opaque
|
|
143
|
+
# (untargeted) work.
|
|
144
|
+
#
|
|
145
|
+
# @return [String, nil]
|
|
146
|
+
def target_label
|
|
147
|
+
return nil if target_type.nil?
|
|
148
|
+
|
|
149
|
+
target_type.constantize.model_name.human
|
|
150
|
+
rescue NameError
|
|
151
|
+
target_type
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Runs have no name or title, so Labeling would fall back to
|
|
155
|
+
# "Async run #12" — true but silent about the only thing that
|
|
156
|
+
# distinguishes one row from the next.
|
|
157
|
+
#
|
|
158
|
+
# Demodulized: a host's run class is as likely to be
|
|
159
|
+
# Billing::ReissueInvoicesRun as a top-level one, and the namespace adds
|
|
160
|
+
# nothing to a label that already sits under the run's own breadcrumb.
|
|
161
|
+
#
|
|
162
|
+
# Except when demodulizing is what throws the name away. A run declared
|
|
163
|
+
# by Dispatchable#async is Blogging::ArchivePosts::Run, and every
|
|
164
|
+
# one of them would render "Run #12" — the failure mode this method
|
|
165
|
+
# exists to avoid, reintroduced for the shape most authors will write.
|
|
166
|
+
# For those, the enclosing segment IS the name, so it is folded back in.
|
|
167
|
+
#
|
|
168
|
+
# @return [String]
|
|
169
|
+
def to_label
|
|
170
|
+
parts = self.class.name.split("::")
|
|
171
|
+
name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
|
|
172
|
+
"#{name.titleize} ##{to_param}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# nil means INDETERMINATE, not zero: opaque work has no denominator, and
|
|
176
|
+
# the progress UI renders a spinner rather than a 0% bar.
|
|
177
|
+
#
|
|
178
|
+
# @return [Float, nil]
|
|
179
|
+
def progress_fraction
|
|
180
|
+
return nil if progress_total.nil? || progress_total.zero?
|
|
181
|
+
|
|
182
|
+
progress_done.to_f / progress_total
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Appends a failure to +errors_log+ and persists immediately, preserving
|
|
186
|
+
# prior entries. It writes rather than staging because the information it
|
|
187
|
+
# carries — which targets the run could not act on — must survive an early
|
|
188
|
+
# return, a rescue that never reaches {#fail!}, or a caller that simply
|
|
189
|
+
# forgets to save. A silently-dropped entry is indistinguishable from a
|
|
190
|
+
# clean run, which is the one thing an operator must never be told.
|
|
191
|
+
#
|
|
192
|
+
# A nil +id+ is the RUN-LEVEL sentinel: the failure is the whole run's
|
|
193
|
+
# (see {#fail!}), not one target's — it advances neither progress_done
|
|
194
|
+
# nor handled_target_ids. Readers grouping the log by target must treat
|
|
195
|
+
# nil as its own bucket rather than a target id.
|
|
196
|
+
def record_target_failure!(id:, message:)
|
|
197
|
+
record_target_failures!([{id: id, message: message}])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Appends several failures in ONE write — folding in the progress bump
|
|
201
|
+
# and handled_target_ids for every entry that names a real target, so a
|
|
202
|
+
# crash between separate writes can't leave them out of sync with each
|
|
203
|
+
# other.
|
|
204
|
+
#
|
|
205
|
+
# The singular form persists on every call, so a loop over M ids is M
|
|
206
|
+
# writes, each rewriting the whole errors_log JSON — O(M²) bytes. The
|
|
207
|
+
# executor resolves every unavailable target in one pass before it
|
|
208
|
+
# performs anything (see Async::Executor#record_unresolved), and that
|
|
209
|
+
# batch is what this exists for.
|
|
210
|
+
#
|
|
211
|
+
# @param entries [Array<Hash>] +{id:, message:}+ pairs
|
|
212
|
+
def record_target_failures!(entries)
|
|
213
|
+
return if entries.empty?
|
|
214
|
+
|
|
215
|
+
target_entries = entries.reject { |entry| entry[:id].nil? }
|
|
216
|
+
appended = entries.map { |entry| {"target_id" => entry[:id], "message" => entry[:message]} }
|
|
217
|
+
update!(
|
|
218
|
+
errors_log: errors_log + appended,
|
|
219
|
+
progress_done: progress_done + target_entries.size,
|
|
220
|
+
handled_target_ids: handled_target_ids + target_entries.map { |entry| entry[:id].to_s },
|
|
221
|
+
last_activity_at: Time.current
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Target ids not yet dispositioned — what a resumed run still has to
|
|
226
|
+
# do. See {#record_target_failures!} and Async::Executor#advance!, which
|
|
227
|
+
# are what populate handled_target_ids.
|
|
228
|
+
#
|
|
229
|
+
# @return [Array]
|
|
230
|
+
def unhandled_target_ids
|
|
231
|
+
handled = handled_target_ids.map(&:to_s).to_set
|
|
232
|
+
target_ids.reject { |id| handled.include?(id.to_s) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The interaction's validated inputs, with the types dispatch put in.
|
|
236
|
+
#
|
|
237
|
+
# The column is JSON, so Dispatchable#dispatch_options writes it through
|
|
238
|
+
# ActiveJob::Arguments; this is the other half. Without it a Date comes
|
|
239
|
+
# back a String and a BigDecimal comes back a String, which is the kind of
|
|
240
|
+
# bug that survives every test written against a run whose options happen
|
|
241
|
+
# to be strings anyway.
|
|
242
|
+
#
|
|
243
|
+
# Falls back to the raw hash for a row written before this existed, and
|
|
244
|
+
# for one an operator edited by hand — neither carries the envelope keys
|
|
245
|
+
# deserialize expects, and refusing to read them would take out the
|
|
246
|
+
# progress page as well as the work.
|
|
247
|
+
#
|
|
248
|
+
# @return [Hash]
|
|
249
|
+
def options
|
|
250
|
+
raw = super
|
|
251
|
+
return raw unless raw.is_a?(Hash)
|
|
252
|
+
|
|
253
|
+
::ActiveJob::Arguments.deserialize([raw]).first
|
|
254
|
+
rescue ::ActiveJob::DeserializationError
|
|
255
|
+
raw
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# An attachment attribute, revived from the token dispatch staged.
|
|
259
|
+
#
|
|
260
|
+
# A file cannot ride the options column, so dispatch uploads it to the
|
|
261
|
+
# backend's cache and stores the token (see
|
|
262
|
+
# Dispatchable#stage_dispatch_attachments). +options["file"]+ is therefore
|
|
263
|
+
# that token — a String — and this is what turns it back into something
|
|
264
|
+
# with a filename and bytes.
|
|
265
|
+
#
|
|
266
|
+
# async do
|
|
267
|
+
# def perform
|
|
268
|
+
# CSV.foreach(attachment(:import_file).url) { |row| ... }
|
|
269
|
+
# end
|
|
270
|
+
# end
|
|
271
|
+
#
|
|
272
|
+
# Deliberately not folded into +options+. The token is what is actually
|
|
273
|
+
# stored, and reviving it reaches storage — which the progress page reads
|
|
274
|
+
# options without wanting to do.
|
|
275
|
+
#
|
|
276
|
+
# @param key [Symbol, String] the attribute name
|
|
277
|
+
# @return [Plutonium::Attachments::Resolved, nil] nil if nothing was
|
|
278
|
+
# staged, or the token no longer resolves
|
|
279
|
+
def attachment(key) = attachments(key).first
|
|
280
|
+
|
|
281
|
+
# Every attachment staged under +key+, for a multiple-file attribute.
|
|
282
|
+
#
|
|
283
|
+
# @param key [Symbol, String]
|
|
284
|
+
# @return [Array<Plutonium::Attachments::Resolved>]
|
|
285
|
+
def attachments(key) = Plutonium::Attachments.resolve(options[key.to_s])
|
|
286
|
+
|
|
287
|
+
# Says "still working" — for work the executor cannot see inside.
|
|
288
|
+
#
|
|
289
|
+
# Async::ReapJob treats a run silent past +stall_after+ as dead and resumes
|
|
290
|
+
# it. Every write the executor makes refreshes that clock, so a targeted
|
|
291
|
+
# run whose targets are quick heartbeats once per target for free. Two
|
|
292
|
+
# shapes of work get nothing:
|
|
293
|
+
#
|
|
294
|
+
# * OPAQUE work. Between the claim and {#finish!} the executor writes
|
|
295
|
+
# nothing at all, because there is nothing to count. An opaque +perform+
|
|
296
|
+
# that outlives stall_after is reaped mid-flight, and — having no
|
|
297
|
+
# handled_target_ids to resume from — is re-run FROM SCRATCH.
|
|
298
|
+
# * A single +perform_on+ that outlives stall_after on its own.
|
|
299
|
+
#
|
|
300
|
+
# So a run that does either must say so itself. This is deliberately not
|
|
301
|
+
# automatic: a background thread would have to guess how often to write,
|
|
302
|
+
# and would keep reporting a wedged worker as healthy. Only the work knows
|
|
303
|
+
# it is making progress.
|
|
304
|
+
#
|
|
305
|
+
# def perform
|
|
306
|
+
# invoices.each_slice(500) do |slice|
|
|
307
|
+
# reissue(slice)
|
|
308
|
+
# heartbeat!
|
|
309
|
+
# end
|
|
310
|
+
# end
|
|
311
|
+
#
|
|
312
|
+
# It also ANSWERS: the write is conditional on this instance still holding
|
|
313
|
+
# the row's lock_version, so a worker superseded while inside a long
|
|
314
|
+
# +perform+ learns at its next beat rather than at the end — see
|
|
315
|
+
# Async::Executor#superseded?, which turns that into "no longer mine"
|
|
316
|
+
# instead of a failure. That is the only place a long opaque run can find
|
|
317
|
+
# out at all.
|
|
318
|
+
#
|
|
319
|
+
# The beat must NOT bump lock_version — that is the executor's own record
|
|
320
|
+
# it would be invalidating, making its next save! raise against a row
|
|
321
|
+
# nobody else touched. Which rules out more than it looks like:
|
|
322
|
+
#
|
|
323
|
+
# * +touch+ and +update!+ both bump it (Locking::Optimistic hooks
|
|
324
|
+
# _touch_row as well as _update_record).
|
|
325
|
+
# * so does +update_all+ GIVEN A HASH. Rails silently adds the increment
|
|
326
|
+
# when the model locks and the hash does not mention the column — see
|
|
327
|
+
# ActiveRecord::Relation#update_all. Only the string/array form escapes
|
|
328
|
+
# it, going through sanitize_sql_for_assignment untouched, which is why
|
|
329
|
+
# this and Async::Executor#claim! both spell their SQL out.
|
|
330
|
+
#
|
|
331
|
+
# The in-memory attribute is left alone for a related reason: nothing
|
|
332
|
+
# reads it, and assigning it would put a change on the record that the
|
|
333
|
+
# caller never asked to save.
|
|
334
|
+
#
|
|
335
|
+
# Call it on SELF, which is what +perform+/+perform_on+ already hand the
|
|
336
|
+
# author. The conditional reads this instance's lock_version, and the
|
|
337
|
+
# executor's own writes keep that current; a separately loaded copy
|
|
338
|
+
# (Run.find(id)) goes stale at the first advance! and would raise here
|
|
339
|
+
# having been superseded by nobody.
|
|
340
|
+
#
|
|
341
|
+
# Under the +:transactional+ policy the beat is inside the batch's
|
|
342
|
+
# transaction like everything else, so it is invisible to the reaper until
|
|
343
|
+
# the batch commits. An all-or-nothing batch longer than stall_after is
|
|
344
|
+
# reapable no matter what this does.
|
|
345
|
+
#
|
|
346
|
+
# @raise [ActiveRecord::StaleObjectError] if another executor owns the run
|
|
347
|
+
# @return [Time] the recorded activity time
|
|
348
|
+
def heartbeat!
|
|
349
|
+
now = Time.current
|
|
350
|
+
written = self.class.where(id: id, lock_version: lock_version)
|
|
351
|
+
.update_all(["last_activity_at = ?", now])
|
|
352
|
+
raise ActiveRecord::StaleObjectError.new(self, "heartbeat") if written.zero?
|
|
353
|
+
|
|
354
|
+
now
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Which shape of work this is, decided by what the subclass implements
|
|
358
|
+
# rather than a mode flag — one less thing for an author to keep in sync.
|
|
359
|
+
#
|
|
360
|
+
# Non-public methods count. `private def perform_on(record)` is a natural
|
|
361
|
+
# idiom for work only the executor is meant to invoke, and Ruby's
|
|
362
|
+
# public-only respond_to? default would read that as opaque work — routing
|
|
363
|
+
# it to #perform, which the base class raises NotImplementedError for. The
|
|
364
|
+
# author would see every dispatch fail on a run whose perform_on is right
|
|
365
|
+
# there. Async::Executor invokes both through send to match.
|
|
366
|
+
def targeted? = respond_to?(:perform_on, true)
|
|
367
|
+
|
|
368
|
+
# Opaque (untargeted) work. Subclasses override this, or +perform_on+ for
|
|
369
|
+
# per-target work.
|
|
370
|
+
#
|
|
371
|
+
# Defined here so a subclass that implements NEITHER is diagnosed by name
|
|
372
|
+
# rather than surfacing as a NoMethodError from inside the executor's loop,
|
|
373
|
+
# where nothing in the message says which class is at fault.
|
|
374
|
+
def perform
|
|
375
|
+
raise NotImplementedError,
|
|
376
|
+
"#{self.class} implements neither #perform nor #perform_on: define " \
|
|
377
|
+
"#perform_on(record) for work over targets, or #perform for opaque work"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
# Not swallowed: after_commit runs synchronously, so re-raising here
|
|
383
|
+
# still surfaces to the dispatching request rather than leaving a
|
|
384
|
+
# permanently invisible, un-enqueued ghost.
|
|
385
|
+
def enqueue_job
|
|
386
|
+
Plutonium::Interaction::Async::Job.perform_later(id)
|
|
387
|
+
rescue
|
|
388
|
+
fail!("could not be enqueued for execution")
|
|
389
|
+
raise
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Interaction
|
|
5
|
+
module Async
|
|
6
|
+
# The UI configuration for run records.
|
|
7
|
+
#
|
|
8
|
+
# Named +RunDefinition+ rather than +Async::Definition+ because definition
|
|
9
|
+
# lookup is by convention and nothing else: both
|
|
10
|
+
# Plutonium::Resource::Controller#resource_definition and
|
|
11
|
+
# Plutonium::Resource::Controllers::Defineable#resource_definition
|
|
12
|
+
# constantize "#{resource_class}Definition", and
|
|
13
|
+
# Plutonium::Definition::Base.model_class infers the model back out of that
|
|
14
|
+
# same name. A differently-named class would simply never be found.
|
|
15
|
+
#
|
|
16
|
+
# Host apps override per portal the usual way, with
|
|
17
|
+
# +MyPortal::Plutonium::Interaction::Async::RunDefinition+.
|
|
18
|
+
class RunDefinition < Plutonium::Resource::Definition
|
|
19
|
+
# `outcome` rather than `state`: a :continue run that could not apply some
|
|
20
|
+
# of its targets ends as "completed", and a green Completed badge on a run
|
|
21
|
+
# that under-applied is exactly the report an operator must never be given.
|
|
22
|
+
# See Plutonium::Interaction::Async::Run#outcome.
|
|
23
|
+
# Only `outcome` is declared. Everything else the policy makes readable is
|
|
24
|
+
# a real column and infers its own type; `outcome` is a method, so it has
|
|
25
|
+
# neither a type to infer nor a badge to infer one from (`state` is a
|
|
26
|
+
# plain string column with an inclusion validation, not a Rails enum).
|
|
27
|
+
#
|
|
28
|
+
# `running` and `completed_with_errors` are coloured explicitly: neither
|
|
29
|
+
# is in the badge component's semantic table, and its fallback is a
|
|
30
|
+
# stable-but-meaningless colour.
|
|
31
|
+
field :outcome, as: :string
|
|
32
|
+
display :outcome, as: :badge,
|
|
33
|
+
colors: {running: :info, completed_with_errors: :warning}
|
|
34
|
+
# Declared for the table too: the index is where an operator scans for the
|
|
35
|
+
# run that did not do what it was asked, and a bare
|
|
36
|
+
# "completed_with_errors" string is the one place that reads worse than
|
|
37
|
+
# the badge.
|
|
38
|
+
column :outcome, as: :badge,
|
|
39
|
+
colors: {running: :info, completed_with_errors: :warning}
|
|
40
|
+
|
|
41
|
+
field :target_label, label: "Target type"
|
|
42
|
+
|
|
43
|
+
# The details tab, with the live panel above the fields.
|
|
44
|
+
#
|
|
45
|
+
# On the Display rather than the ShowPage: page-level
|
|
46
|
+
# render_before_content sits outside the tablist and outside the modal
|
|
47
|
+
# chrome, so a run opened in a modal would render its progress above the
|
|
48
|
+
# dialog instead of inside it.
|
|
49
|
+
class Display < Display
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def render_before_fields
|
|
53
|
+
render Plutonium::UI::Interaction::Async::RunProgress.new(resource_record!)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class ShowPage < ShowPage
|
|
58
|
+
# Answers the progress frame's poll with the panel ALONE.
|
|
59
|
+
#
|
|
60
|
+
# Turbo sends +Turbo-Frame: <id>+ when the frame re-fetches its src, and
|
|
61
|
+
# DynaFrameContent wraps every response in a frame of that name. Falling
|
|
62
|
+
# through to the normal page would therefore return the whole show page
|
|
63
|
+
# wrapped in a frame carrying the progress frame's id — with the panel's
|
|
64
|
+
# own frame nested inside it, under the same id. Turbo would swap the
|
|
65
|
+
# outer one in, and the next poll would nest again.
|
|
66
|
+
def view_template(&)
|
|
67
|
+
return super unless progress_poll?
|
|
68
|
+
|
|
69
|
+
DynaFrameContent do
|
|
70
|
+
render Plutonium::UI::Interaction::Async::RunProgress.new(resource_record!)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def progress_poll?
|
|
77
|
+
current_turbo_frame == Plutonium::UI::Interaction::Async::RunProgress.frame_id(resource_record!)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The index refreshes itself while any run is still working.
|
|
82
|
+
#
|
|
83
|
+
# ONE frame around the collection, not one per row. A +turbo-frame+ is
|
|
84
|
+
# not in the content model of +tr+, so a frame wrapping a row is hoisted
|
|
85
|
+
# out of the table by the HTML parser before Turbo ever sees it. A frame
|
|
86
|
+
# per CELL parses, but buys one poller per row for a single page.
|
|
87
|
+
#
|
|
88
|
+
# The Stimulus controller sits INSIDE the frame, exactly as
|
|
89
|
+
# {Plutonium::UI::Interaction::Async::RunProgress} places it: a frame
|
|
90
|
+
# navigation replaces the frame's CONTENTS, not the frame element, so a
|
|
91
|
+
# controller on the tag itself could never be removed and the timer
|
|
92
|
+
# would never stop.
|
|
93
|
+
class IndexPage < IndexPage
|
|
94
|
+
include Phlex::Rails::Helpers::TurboFrameTag
|
|
95
|
+
|
|
96
|
+
FRAME_ID = "pu_async_runs_index"
|
|
97
|
+
|
|
98
|
+
# Answers the frame's poll with the collection ALONE, for the reason
|
|
99
|
+
# spelled out on {ShowPage}: DynaFrameContent already wraps every
|
|
100
|
+
# response in a frame named by the inbound header, so emitting the
|
|
101
|
+
# page would nest the whole thing inside its own frame, and again on
|
|
102
|
+
# the next poll.
|
|
103
|
+
def view_template(&)
|
|
104
|
+
return super unless answering_own_frame?
|
|
105
|
+
|
|
106
|
+
DynaFrameContent() do
|
|
107
|
+
render_default_content
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def answering_own_frame? = current_turbo_frame == FRAME_ID
|
|
114
|
+
|
|
115
|
+
def render_default_content
|
|
116
|
+
body = proc { div(**poll_attributes) { super() } }
|
|
117
|
+
return body.call if answering_own_frame?
|
|
118
|
+
|
|
119
|
+
# `turbo-frame` defaults to `display: inline`.
|
|
120
|
+
turbo_frame_tag(FRAME_ID, class: "block", &body)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Absent once nothing is working: the refreshed markup no longer
|
|
124
|
+
# carries the controller, and the timer stops of its own accord. The
|
|
125
|
+
# index needs no equivalent of RunProgress's +finished+ reload — the
|
|
126
|
+
# whole collection is inside this frame, so there are no stale fields
|
|
127
|
+
# beside it to catch up.
|
|
128
|
+
#
|
|
129
|
+
# The URL is the CURRENT one, filters, sort and page included. Polling
|
|
130
|
+
# the bare index would answer page 1 unfiltered and swap that into a
|
|
131
|
+
# frame the operator had scoped to something else.
|
|
132
|
+
def poll_attributes
|
|
133
|
+
return {} unless any_run_in_progress?
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
data: {
|
|
137
|
+
controller: "run-progress",
|
|
138
|
+
run_progress_url_value: current_page_url,
|
|
139
|
+
run_progress_interval_value: Plutonium::UI::Interaction::Async::RunProgress::POLL_INTERVAL_MS
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Asked of the SCOPE rather than the rendered page, through the same
|
|
145
|
+
# door render_running_banner uses. It can only over-poll (an operator
|
|
146
|
+
# reading last month's finished runs while something works elsewhere),
|
|
147
|
+
# never under-poll: a run visible and in progress makes this true.
|
|
148
|
+
def any_run_in_progress?
|
|
149
|
+
authorized_resource_scope(Run, relation: Run.in_progress).exists?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Interaction
|
|
5
|
+
module Async
|
|
6
|
+
# Authorization for run records themselves.
|
|
7
|
+
#
|
|
8
|
+
# Named +RunPolicy+ rather than +Policy+ so ActionPolicy's ordinary
|
|
9
|
+
# inference finds it: +INFER_FROM_CLASS+ looks for "#{record_class}Policy",
|
|
10
|
+
# and Plutonium's STI fallback looks for "#{base_class}Policy" — which is
|
|
11
|
+
# what routes a +TestPostRun+ (or any host subclass) to this one policy
|
|
12
|
+
# without every subclass declaring anything.
|
|
13
|
+
#
|
|
14
|
+
# == Read-only
|
|
15
|
+
#
|
|
16
|
+
# A run is the record of something that already happened. There is nothing
|
|
17
|
+
# to create through a form (a run is born from a dispatching interaction,
|
|
18
|
+
# which has its own policy), nothing to edit that would not be rewriting the
|
|
19
|
+
# audit trail, and deleting one destroys the only report of what a bulk
|
|
20
|
+
# action actually did. The four write predicates are therefore hard +false+
|
|
21
|
+
# rather than inherited defaults — subclasses can still open them, but they
|
|
22
|
+
# have to say so.
|
|
23
|
+
#
|
|
24
|
+
# == Scope
|
|
25
|
+
#
|
|
26
|
+
# Nothing is declared here. The base policy's +relation_scope+ already calls
|
|
27
|
+
# +default_relation_scope+, which applies +Run.associated_with(entity_scope)+
|
|
28
|
+
# — and that scope filters on the tenant the run RECORDED at dispatch (see
|
|
29
|
+
# Run). Re-declaring the macro to repeat that filter by hand would add a
|
|
30
|
+
# second, drifting definition of the same rule; declaring it as a
|
|
31
|
+
# +def relation_scope+ instance method would be worse still, since
|
|
32
|
+
# +apply_scope+ never calls one (Plutonium::Resource::Policy.method_added
|
|
33
|
+
# raises on that mistake at class load).
|
|
34
|
+
class RunPolicy < Plutonium::Resource::Policy
|
|
35
|
+
def read? = true
|
|
36
|
+
|
|
37
|
+
def create? = false
|
|
38
|
+
|
|
39
|
+
def update? = false
|
|
40
|
+
|
|
41
|
+
def destroy? = false
|
|
42
|
+
|
|
43
|
+
# Deliberately NOT the autodetected column list.
|
|
44
|
+
#
|
|
45
|
+
# +options+ is arbitrary JSON copied from the dispatching interaction's
|
|
46
|
+
# validated inputs — reasons, notes, recipient lists, whatever the author
|
|
47
|
+
# declared. The policy that governed who could SUBMIT those values was the
|
|
48
|
+
# interaction's; the set of people who can READ this run is different and
|
|
49
|
+
# wider (in a tenant portal, everyone in the tenant). Autodetection would
|
|
50
|
+
# quietly hand the second set the first set's input, so the readable
|
|
51
|
+
# attributes are enumerated instead, and +options+ is not among them.
|
|
52
|
+
#
|
|
53
|
+
# +target_ids+ is out for the same reason at one remove: it is a list of
|
|
54
|
+
# primary keys that were never filtered through the target resource's own
|
|
55
|
+
# policy for THIS reader.
|
|
56
|
+
# +outcome+ stands in for +state+, and +state+ is deliberately absent: the
|
|
57
|
+
# two differ only for a run that completed having failed some of its
|
|
58
|
+
# targets, and that is precisely the case where the raw column reads
|
|
59
|
+
# "completed" and tells the reader the opposite of what happened.
|
|
60
|
+
#
|
|
61
|
+
# +errors_log+ is absent because the progress panel renders it as a list —
|
|
62
|
+
# the raw JSON column beside it would be the same report twice, once
|
|
63
|
+
# unreadably.
|
|
64
|
+
def permitted_attributes_for_read
|
|
65
|
+
%i[
|
|
66
|
+
type outcome progress_done progress_total
|
|
67
|
+
target_label initiator started_at finished_at created_at
|
|
68
|
+
]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The show page drops the progress COUNTERS, for the reason errors_log
|
|
72
|
+
# is absent above: the panel already renders them, as "5 of 5 targets
|
|
73
|
+
# (100%)" over a bar. Two renderings of one number is the same report
|
|
74
|
+
# twice — and they disagree, because only the panel is inside the polled
|
|
75
|
+
# frame. A page that says "Completed" and "0 done" at once is worse than
|
|
76
|
+
# either alone.
|
|
77
|
+
#
|
|
78
|
+
# The INDEX keeps them. There is no progress bar in a table row, so the
|
|
79
|
+
# counters are the only progress it can show.
|
|
80
|
+
def permitted_attributes_for_show
|
|
81
|
+
permitted_attributes_for_read - %i[progress_done progress_total]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|