plutonium 0.60.5 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # ActiveRecord model backing the +plutonium_wizard_sessions+ table.
6
+ #
7
+ # Identity is the derived {InstanceKey} digest stored in +instance_key+; the
8
+ # polymorphic owner/anchor/scope refs exist for listing and rebuilding context,
9
+ # not for identity. At-rest encryption (the wizard's +encrypt_data+ opt-in) is
10
+ # applied by {Store::ActiveRecord} as a self-describing envelope in the +data+
11
+ # column, not statically here — so this model stays a plain schema mapping.
12
+ class Session < ActiveRecord::Base
13
+ self.table_name = "plutonium_wizard_sessions"
14
+
15
+ belongs_to :owner, polymorphic: true, optional: true
16
+ belongs_to :anchor, polymorphic: true, optional: true
17
+ belongs_to :scope, polymorphic: true, optional: true
18
+
19
+ enum :status,
20
+ {in_progress: "in_progress", completing: "completing", completed: "completed"},
21
+ prefix: true
22
+
23
+ # How long a row may sit in +completing+ before the sweep treats it as a
24
+ # CRASHED finalize and reaps it. A healthy finalize flips to +completing+,
25
+ # runs +execute+, and completes/clears the row within seconds — but +execute+
26
+ # runs OUTSIDE the completion lock and does not bump +expires_at+, so a sweep
27
+ # firing mid-finalize must NOT cancel it (that would destroy the run's
28
+ # tracked records out from under the in-flight +execute+, §6.2). The grace
29
+ # window distinguishes a finalize that is still running (recent +updated_at+)
30
+ # from one that crashed (stale +updated_at+). Generous on purpose.
31
+ COMPLETING_GRACE = 15.minutes
32
+
33
+ # Idle rows eligible for the abandonment sweep, by status:
34
+ #
35
+ # - +in_progress+ — abandoned: a concrete +expires_at+ (cleanup_after) that
36
+ # has passed. Rows with a null +expires_at+ (cleanup_after :never) are never
37
+ # swept.
38
+ # - +completing+ — a finalize that CRASHED mid-flight: it has been
39
+ # +completing+ longer than {COMPLETING_GRACE} (its +updated_at+, stamped
40
+ # when it entered +completing+, is older than now - grace). This keeps the
41
+ # sweep from racing an +execute+ that is still running (§6.2).
42
+ #
43
+ # +completed+ rows are never swept.
44
+ scope :sweepable, ->(now, completing_grace: COMPLETING_GRACE) {
45
+ in_progress =
46
+ status_in_progress.where.not(expires_at: nil).where(expires_at: ..now)
47
+ crashed_completing =
48
+ status_completing.where(updated_at: ..(now - completing_grace))
49
+ in_progress.or(crashed_completing)
50
+ }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # In-memory snapshot of one wizard instance's stored state, exchanged between
6
+ # the {Store} and the runner. Independent of any persistence backend.
7
+ #
8
+ # +data+ and +persisted+ default to an empty hash, +visited+ to an empty
9
+ # array, so callers never have to nil-check them.
10
+ State = Struct.new(
11
+ :wizard,
12
+ :instance_key,
13
+ :current_step,
14
+ :status,
15
+ :data,
16
+ :persisted,
17
+ :visited,
18
+ :owner,
19
+ :anchor,
20
+ :scope,
21
+ :token,
22
+ :engine,
23
+ # The optimistic-merge version the row was read at (nil for a state that was
24
+ # never read from a row, i.e. a fresh run). The store uses it to detect a
25
+ # concurrent write and merge instead of clobbering (§6.2).
26
+ :lock_version
27
+ ) do
28
+ def data = self[:data] || {}
29
+
30
+ def persisted = self[:persisted] || {}
31
+
32
+ def visited = self[:visited] || []
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Metadata for one wizard step: its key, label, branching condition, captured
6
+ # field surface, per-step hooks, and the `using:` import marker (resolved in
7
+ # Task 3). A value object — holds no runtime state.
8
+ class Step
9
+ attr_reader :key, :label, :description, :condition, :fields,
10
+ :on_submit, :on_rollback, :using_spec
11
+
12
+ def initialize(key:, fields:, label: nil, description: nil, condition: nil,
13
+ on_submit: nil, on_rollback: nil, using_spec: nil)
14
+ @key = key
15
+ @label = label || key.to_s.humanize
16
+ @description = description
17
+ @condition = condition
18
+ @fields = fields
19
+ @on_submit = on_submit
20
+ @on_rollback = on_rollback
21
+ @using_spec = using_spec
22
+ end
23
+
24
+ def review? = false
25
+
26
+ # The step's form sections (§7.1): inline `form_layout` wins, else the layout
27
+ # inherited from a `using:` source (filtered to imported fields), else nil.
28
+ # Resolved lazily so a `using:` import is only loaded when actually needed.
29
+ def form_layout = fields.form_layout_sections
30
+
31
+ # The effective attribute schema ({name => type}) contributed to the union
32
+ # `data` schema — a `using:` import composed with inline `attribute`
33
+ # declarations (inline wins on conflict, §2.4).
34
+ def attribute_schema = fields.attribute_schema
35
+
36
+ # The per-attribute options ({name => {default:, ...}}) contributed to the
37
+ # typed `data` snapshot, so e.g. `default:` applies (§2.6).
38
+ def attribute_options = fields.attribute_options
39
+
40
+ # The effective input config ({name => {options:, block:}}) — imported inputs
41
+ # composed with inline `input`/`field` declarations (inline wins).
42
+ def inputs = fields.inputs
43
+
44
+ # Inline `validates` declarations recorded for this step (raw [args, options]).
45
+ def validations = fields.validations
46
+
47
+ # Form-metadata validators contributed by a `using:` import ([args, options]
48
+ # pairs), replayed onto the typed data class alongside inline `validations`
49
+ # so imported fields surface required/length/etc. — without feeding the
50
+ # runner (which validates imports through the transient model).
51
+ def imported_form_validators = fields.imported_form_validators
52
+
53
+ # The imported validation runner ({attribute => [messages]} over a data
54
+ # slice), or nil when there's no `using:` import or `validate: false`.
55
+ def imported_validate_fn = fields.imported_validate_fn
56
+
57
+ # The structured inputs declared in this step ({name => {options:, block:}}).
58
+ def structured_inputs = fields.defined_structured_inputs
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Presents a wizard {Step} in the shape the existing resource-form pipeline
6
+ # (`Plutonium::UI::Form::Resource`) consumes from a definition (§7). The form
7
+ # renders a step exactly like a resource/interaction definition by reading:
8
+ #
9
+ # - `defined_fields` — empty; a step carries no separate field
10
+ # config, only `input`s.
11
+ # - `defined_inputs` — the step's merged inline + imported inputs
12
+ # (`{name => {options:, block:}}`).
13
+ # - `defined_structured_inputs` — the step's structured inputs.
14
+ # - `resolve_form_sections` — the step's resolved form layout (inline
15
+ # `form_layout` or one inherited from `using:`),
16
+ # normalized to ResolvedSections; nil → single
17
+ # grid.
18
+ #
19
+ # This is the seam that lets a wizard step ride the resource-form rendering
20
+ # path unchanged — seeded from the wizard's typed `data` (the form `object`),
21
+ # which is what makes resume/back rehydration (including repeater rows) work.
22
+ class StepAdapter
23
+ # Options on a file `input` that are consumed SERVER-SIDE by Wizard::Driving
24
+ # (from the raw step) to stage the upload — never form/HTML concerns. They must
25
+ # be stripped from what the form renders: Phlex rejects a Class-valued
26
+ # `uploader:` as an attribute, and `backend:` would otherwise leak as a stray
27
+ # attribute. Driving reads them off `step.inputs` directly, so removing them
28
+ # here is invisible to staging.
29
+ STAGING_ONLY_INPUT_OPTIONS = %i[backend uploader].freeze
30
+
31
+ def initialize(step)
32
+ @step = step
33
+ end
34
+
35
+ attr_reader :step
36
+
37
+ # The form's per-field config map. A step declares inputs, not fields, so
38
+ # there is no separate `field` config — return an empty map. The form merges
39
+ # `defined_fields[name]` (here {}) with `defined_inputs[name]`.
40
+ def defined_fields = {}
41
+
42
+ # `{name => {options:, block:}}` — inline + `using:`-imported inputs, with the
43
+ # server-side staging options stripped so they don't render as HTML attributes.
44
+ def defined_inputs
45
+ step.inputs.transform_values do |config|
46
+ options = config[:options]
47
+ next config if options.nil? || STAGING_ONLY_INPUT_OPTIONS.none? { |k| options.key?(k) }
48
+
49
+ config.merge(options: options.except(*STAGING_ONLY_INPUT_OPTIONS))
50
+ end
51
+ end
52
+
53
+ # `{name => {options:, block:}}` — structured (single/repeater) inputs.
54
+ def defined_structured_inputs = step.structured_inputs
55
+
56
+ # The resource form never imports nested-resource inputs from a wizard step.
57
+ def defined_nested_inputs = {}
58
+
59
+ # Auto-detect submit-and-continue is disabled for wizards (the wizard owns
60
+ # its own Back/Next/Finish navigation).
61
+ def submit_and_continue = false
62
+
63
+ # Resolve the step's form layout into ordered ResolvedSections (the shape the
64
+ # resource form's `resolve_form_layout` expects), or nil for a single grid.
65
+ #
66
+ # The step's `form_layout` is either:
67
+ # - inline → an Array<FormLayout::Section> (unresolved), or
68
+ # - imported → an Array<FormLayout::ResolvedSection> (already resolved by
69
+ # the FieldImporter, filtered to imported fields).
70
+ # Normalize both to ResolvedSections claiming only currently-permitted fields.
71
+ def resolve_form_sections(resource_fields)
72
+ layout = step.form_layout
73
+ return nil if layout.blank?
74
+
75
+ resource_fields = resource_fields.map(&:to_sym)
76
+ return resolve_resolved_sections(layout, resource_fields) if layout.first.is_a?(Plutonium::Definition::FormLayout::ResolvedSection)
77
+
78
+ resolve_raw_sections(layout, resource_fields)
79
+ end
80
+
81
+ private
82
+
83
+ # An imported layout is already ResolvedSections; just restrict each section's
84
+ # fields to the currently-permitted set (and drop emptied non-ungrouped ones).
85
+ def resolve_resolved_sections(layout, resource_fields)
86
+ known = resource_fields.to_set
87
+ layout.filter_map do |resolved|
88
+ fields = resolved.fields.map(&:to_sym).select { |f| known.include?(f) }
89
+ next if fields.empty? && !resolved.section.ungrouped?
90
+ Plutonium::Definition::FormLayout::ResolvedSection.new(resolved.section, fields)
91
+ end
92
+ end
93
+
94
+ # Raw inline Sections resolve exactly like a resource definition's layout
95
+ # (first-section-wins ownership; unlisted permitted fields fall into a
96
+ # trailing ungrouped bucket) — so delegate to the canonical resolver rather
97
+ # than re-implement it.
98
+ def resolve_raw_sections(layout, resource_fields)
99
+ Plutonium::Definition::FormLayout.resolve_sections(layout, resource_fields)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ module Store
6
+ # Shipped store, backed by the +plutonium_wizard_sessions+ table via {Session}.
7
+ class ActiveRecord < Base
8
+ # A +data+ blob encrypted at rest (§8.1, the wizard's +encrypt_data+ opt-in)
9
+ # is stored as a SELF-DESCRIBING envelope INSIDE the jsonb column:
10
+ # +{ "_enc" => "<ciphertext>" }+. The row therefore decrypts based on its own
11
+ # shape, independent of the wizard's CURRENT +encrypt_data?+ (which may have
12
+ # been toggled after the row was written). Only +data+ (the step field values)
13
+ # is encrypted; +tracked_records+ holds record GIDs and stays in clear.
14
+ ENCRYPTED_ENVELOPE_KEY = "_enc"
15
+
16
+ def read(instance_key)
17
+ row = Session.find_by(instance_key: instance_key)
18
+ row && to_state(row)
19
+ end
20
+
21
+ # Upsert the run's state. Concurrency-safe (§6.2): two requests can write the
22
+ # same run (double-submit, two tabs, the first-step create race). A blind
23
+ # overwrite would let the later writer clobber the earlier one's staged data
24
+ # (last-writer-wins). Instead:
25
+ #
26
+ # - **Create** (no row yet): insert. If a concurrent create won the unique
27
+ # +instance_key+ index (RecordNotUnique), fall through to the merge path.
28
+ # - **Update** (row exists): take a row lock, then compare the version the
29
+ # caller's +state+ was read at against the row's CURRENT version. Equal →
30
+ # no concurrent writer, write +state+ verbatim (honors this request's
31
+ # prunes/deletions). Differs (or the caller never read a version — it lost
32
+ # a create race) → a concurrent advance committed; call the +merge+ block
33
+ # with the latest committed {State} so the two sides are combined, and
34
+ # write the result. The lock serializes writers; the version check keeps
35
+ # the merge off the normal single-writer path.
36
+ #
37
+ # +merge+ is optional — a blockless caller keeps last-writer-wins (the
38
+ # store-contract callers that don't model concurrency). The runner always
39
+ # passes one.
40
+ def write(instance_key, state, cleanup_after:, &merge)
41
+ row = Session.find_or_initialize_by(instance_key: instance_key)
42
+
43
+ if row.new_record?
44
+ assign_row(row, state, cleanup_after)
45
+ begin
46
+ row.save!
47
+ return to_state(row)
48
+ rescue ::ActiveRecord::RecordNotUnique
49
+ # Lost the create race — the row now exists. Re-fetch and merge.
50
+ row = Session.find_by!(instance_key: instance_key)
51
+ end
52
+ end
53
+
54
+ write_locked(row, state, cleanup_after, merge)
55
+ end
56
+
57
+ def complete(instance_key)
58
+ row = Session.find_by!(instance_key: instance_key)
59
+ row.update!(status: "completed", completed_at: Time.current, data: {}, tracked_records: {}, visited: [])
60
+ end
61
+
62
+ def clear(instance_key)
63
+ Session.where(instance_key: instance_key).delete_all
64
+ end
65
+
66
+ def completed?(instance_key:)
67
+ Session.status_completed.where(instance_key: instance_key).exists?
68
+ end
69
+
70
+ private
71
+
72
+ # Update an existing row under a row lock, merging when a concurrent writer
73
+ # moved it past the version the caller read. Returns the written {State}
74
+ # (carrying the bumped +lock_version+ so the caller's next write is current).
75
+ def write_locked(row, state, cleanup_after, merge)
76
+ row.with_lock do
77
+ final = (merge && stale_for_merge?(row, state)) ? merge.call(to_state(row)) : state
78
+ assign_row(row, final, cleanup_after)
79
+ row.save!
80
+ end
81
+ to_state(row)
82
+ end
83
+
84
+ # Whether the row has moved since the caller read it (so a verbatim write
85
+ # would clobber a concurrent advance): the caller never read a version (nil
86
+ # — it thought it was creating, i.e. lost a create race), or its version is
87
+ # behind the row's current one.
88
+ def stale_for_merge?(row, state)
89
+ state.lock_version.nil? || state.lock_version != row.lock_version
90
+ end
91
+
92
+ # Copy a {State} onto the row's columns. Never touches +lock_version+ —
93
+ # ActiveRecord manages it (the WHERE-clause check + increment on save).
94
+ def assign_row(row, state, cleanup_after)
95
+ row.wizard = state.wizard
96
+ row.current_step = state.current_step
97
+ row.status ||= "in_progress"
98
+ row.data = encode_data(state.data, state.wizard)
99
+ row.tracked_records = state.persisted
100
+ row.visited = state.visited
101
+ row.owner = state.owner
102
+ row.anchor = state.anchor
103
+ row.scope = state.scope
104
+ row.token = state.token
105
+ row.engine = state.engine
106
+ row.expires_at = cleanup_after ? Time.current + cleanup_after : nil
107
+ end
108
+
109
+ def to_state(row)
110
+ State.new(
111
+ wizard: row.wizard,
112
+ instance_key: row.instance_key,
113
+ current_step: row.current_step,
114
+ status: row.status,
115
+ data: decode_data(row.data, row.wizard),
116
+ persisted: row.tracked_records,
117
+ visited: row.visited,
118
+ owner: row.owner,
119
+ anchor: row.anchor,
120
+ scope: row.scope,
121
+ token: row.token,
122
+ engine: row.engine,
123
+ lock_version: row.lock_version
124
+ )
125
+ end
126
+
127
+ # Encrypt the run's field data at rest when the wizard opts in via
128
+ # `encrypt_data`; a non-encrypting wizard stores the plain hash. Wrapped in
129
+ # a self-describing {ENCRYPTED_ENVELOPE_KEY} envelope (see the class note).
130
+ def encode_data(data, wizard_name)
131
+ return data unless encrypting?(wizard_name)
132
+
133
+ cipher = with_encryption_context(wizard_name) do
134
+ ::ActiveRecord::Encryption.encryptor.encrypt(JSON.generate(data))
135
+ end
136
+ {ENCRYPTED_ENVELOPE_KEY => cipher}
137
+ end
138
+
139
+ # Reverse of {#encode_data}: an envelope is decrypted from the row's SHAPE
140
+ # (not the wizard's current flag); any other value is a plain, never-
141
+ # encrypted blob and is returned as-is.
142
+ def decode_data(stored, wizard_name)
143
+ return stored unless stored.is_a?(Hash) && stored.key?(ENCRYPTED_ENVELOPE_KEY)
144
+
145
+ clear = with_encryption_context(wizard_name) do
146
+ ::ActiveRecord::Encryption.encryptor.decrypt(stored[ENCRYPTED_ENVELOPE_KEY])
147
+ end
148
+ JSON.parse(clear)
149
+ end
150
+
151
+ # Whether the wizard opted into at-rest encryption. A name that doesn't
152
+ # resolve to a loaded class (e.g. a renamed/removed wizard, or a synthetic
153
+ # store-test name) can carry no `encrypt_data` declaration to honour, so it
154
+ # is treated as clear — existing rows decode by SHAPE regardless.
155
+ def encrypting?(wizard_name)
156
+ wizard_name.to_s.safe_constantize&.encrypt_data? || false
157
+ end
158
+
159
+ # Run an encrypt/decrypt, translating ActiveRecord's lazy, context-free
160
+ # configuration error into one that names the wizard and points at the fix —
161
+ # `encrypt_data` is opt-in, so a missing key set is an author error, not a
162
+ # runtime surprise to swallow.
163
+ def with_encryption_context(wizard_name)
164
+ yield
165
+ rescue ::ActiveRecord::Encryption::Errors::Configuration => e
166
+ raise ::ActiveRecord::Encryption::Errors::Configuration,
167
+ "#{wizard_name} declares `encrypt_data` but ActiveRecord encryption is not " \
168
+ "configured (set active_record.encryption.{primary_key,deterministic_key," \
169
+ "key_derivation_salt}). Original error: #{e.message}"
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ module Store
6
+ # Storage port for wizard sessions. Adapters exchange {State} value objects
7
+ # and are keyed by the derived {InstanceKey} digest.
8
+ class Base
9
+ # @param instance_key [String]
10
+ # @return [State, nil]
11
+ def read(instance_key) = raise NotImplementedError
12
+
13
+ # Upsert by +instance_key+. Stamps +expires_at = now + cleanup_after+
14
+ # (nil cleanup_after → null expiry).
15
+ #
16
+ # @param instance_key [String]
17
+ # @param state [State]
18
+ # @param cleanup_after [ActiveSupport::Duration, nil]
19
+ # @return [State]
20
+ def write(instance_key, state, cleanup_after:) = raise NotImplementedError
21
+
22
+ # Mark completed: status "completed", stamp completed_at, null data/persisted.
23
+ #
24
+ # @param instance_key [String]
25
+ def complete(instance_key) = raise NotImplementedError
26
+
27
+ # Delete the row.
28
+ #
29
+ # @param instance_key [String]
30
+ def clear(instance_key) = raise NotImplementedError
31
+
32
+ # One-time completion check (§4.3 / §9): does a `completed` row exist at
33
+ # this instance_key? Identity is the digest, so the caller recomputes the
34
+ # wizard's instance_key (concurrency_key + folded tenant) and asks here.
35
+ #
36
+ # @param instance_key [String]
37
+ # @return [Boolean]
38
+ def completed?(instance_key:) = raise NotImplementedError
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ module Store
6
+ # In-memory store for fast, DB-free unit tests (and a template for future
7
+ # adapters). Mirrors {ActiveRecord}'s observable behavior.
8
+ class Memory < Base
9
+ def initialize
10
+ @rows = {}
11
+ end
12
+
13
+ def read(instance_key)
14
+ @rows[instance_key]&.dup
15
+ end
16
+
17
+ def write(instance_key, state, cleanup_after:)
18
+ state = state.dup
19
+ state.instance_key = instance_key
20
+ state.status ||= "in_progress"
21
+ @rows[instance_key] = state
22
+ end
23
+
24
+ def complete(instance_key)
25
+ state = @rows.fetch(instance_key)
26
+ state.status = "completed"
27
+ state.data = {}
28
+ state.persisted = {}
29
+ state.visited = []
30
+ state
31
+ end
32
+
33
+ def clear(instance_key)
34
+ @rows.delete(instance_key)
35
+ end
36
+
37
+ def completed?(instance_key:)
38
+ row = @rows[instance_key]
39
+ !!row && row.status == "completed"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # The abandonment sweep (§8.1). Reaps idle wizard sessions whose +expires_at+
6
+ # has passed (status +in_progress+ or +completing+ — the latter catches a
7
+ # finalize that crashed mid-flight, §6.2). For each row it builds a {Runner}
8
+ # and calls +cancel+, which runs the wizard's cleanup — each step's per-step
9
+ # +on_rollback+ (additive side-effect cleanup, if any) then the engine's
10
+ # always-on destroy of every tracked record, in reverse order — and then
11
+ # deletes the row.
12
+ #
13
+ # This is **load-bearing for save-as-you-go wizards**: for +execute+-only
14
+ # wizards an unscheduled sweep merely leaves stale session rows (harmless), but
15
+ # for +on_submit+ wizards the sweep is the only thing that cleans up abandoned
16
+ # partial domain records. Hosts must schedule it (a periodic job / rake task).
17
+ #
18
+ # The job is idempotent and safe to re-run: a row already cleared is skipped,
19
+ # and an unconstantizable wizard class is skipped while the row is still reaped.
20
+ # +completed+ rows are never touched (the +sweepable+ scope excludes them).
21
+ class SweepJob < ActiveJob::Base
22
+ def perform(now: Time.current)
23
+ store = Store::ActiveRecord.new
24
+
25
+ Session.sweepable(now).find_each do |row|
26
+ sweep_row(row, store, now)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def sweep_row(row, store, now)
33
+ # Re-check the row is STILL sweepable under a fresh read. The finalize we may
34
+ # be racing (a `completing` row within, or just past, the grace window) could
35
+ # have completed — and a one-time wizard RETAINS its `completed` row as the
36
+ # gate marker. Without this re-check, the sweep could cancel+delete a row the
37
+ # finalize just completed, destroying that marker (un-gating the user) or
38
+ # racing the cleanup of its tracked records. A still-sweepable row is safe to
39
+ # reap; anything else (completed, cleared, or refreshed) is skipped.
40
+ return unless Session.sweepable(now).exists?(id: row.id)
41
+
42
+ wizard_class = row.wizard.safe_constantize
43
+
44
+ if wizard_class
45
+ # `cancel` runs the wizard's cleanup (each step's on_rollback, then the
46
+ # engine always destroys its tracked records) and then clears the row.
47
+ #
48
+ # Reconstruct the run's context from the row the sweep already trusts:
49
+ #
50
+ # - `current_user: row.owner` — a non-`anonymous` wizard's state is
51
+ # owner-scoped (§4.5), so with no owner the runner would mismatch, drop
52
+ # the loaded state, and cancel an EMPTY run, orphaning every `persist`'d
53
+ # record (the exact case the sweep exists for).
54
+ # - `current_scoped_entity: row.scope` — the wizard's `current_scoped_entity`
55
+ # is set from this argument (not the loaded state), so a tenant-aware
56
+ # `on_rollback` would otherwise run with a nil tenant.
57
+ #
58
+ # (The `anchor` needs no argument — the runner restores it from the loaded
59
+ # state.)
60
+ Runner.new(
61
+ wizard_class: wizard_class,
62
+ store: store,
63
+ instance_key: row.instance_key,
64
+ current_user: row.owner,
65
+ current_scoped_entity: row.scope
66
+ ).cancel
67
+ end
68
+
69
+ # Safety net: an unconstantizable wizard never ran `cancel` (so the row is
70
+ # still present), and a `cancel` failure shouldn't leave the row behind.
71
+ # `clear` is a no-op when the row is already gone.
72
+ store.clear(row.instance_key)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wizard/errors"
4
+ require_relative "wizard/configuration"
5
+
6
+ module Plutonium
7
+ # The Plutonium wizard subsystem: multi-step, DB-backed, data-capture wizards.
8
+ module Wizard
9
+ # The union `data` schema (§2.6) and the runner's inline validator both build
10
+ # anonymous ActiveModel classes from a step's `attribute_schema`. A `using:`
11
+ # import contributes the model's column types (e.g. `:text`), which
12
+ # ActiveModel's type registry doesn't know. Fall back to `:string` for any type
13
+ # the registry can't resolve so the snapshot/validator still builds — the
14
+ # staged value is stored/displayed as-is.
15
+ def self.safe_attribute_type(type)
16
+ ActiveModel::Type.lookup(type)
17
+ type
18
+ rescue ArgumentError
19
+ :string
20
+ end
21
+
22
+ # Compute a wizard run's identity digest (§4.1), shared by the runner/driving
23
+ # layer (which creates rows) and the gate (which recomputes the key) so the
24
+ # two are byte-identical — if they diverge, one-time gating silently breaks.
25
+ #
26
+ # A wizard with a `concurrency_key` is hashed over its resolved key value(s)
27
+ # (the tenant is folded in by {Base#concurrency_key_value}); otherwise it's
28
+ # hashed over the per-launch `wizard_token`.
29
+ #
30
+ # The wizard's `concurrency_key` resolver and tenancy fold run in a transient
31
+ # wizard instance seeded with the identity context. A resolver that references
32
+ # a missing context method raises a clear error.
33
+ #
34
+ # @return [String] the hex SHA256 instance_key
35
+ def self.compute_instance_key(wizard_class:, current_user:, current_scoped_entity:, anchor:, wizard_token:)
36
+ unless wizard_class.concurrency_key?
37
+ return InstanceKey.tokened(wizard_class.name, wizard_token)
38
+ end
39
+
40
+ probe = wizard_class.new
41
+ probe.current_user = current_user
42
+ probe.current_scoped_entity = current_scoped_entity
43
+ probe.wizard_token = wizard_token
44
+ probe.anchor = anchor if wizard_class.anchored?
45
+ key_value = probe.concurrency_key_value
46
+ InstanceKey.concurrency(wizard_class.name, key_value)
47
+ rescue NameError => e
48
+ raise ArgumentError,
49
+ "#{wizard_class.name}'s concurrency_key referenced a method that isn't " \
50
+ "available in this context (#{e.message}). Available: current_user, " \
51
+ "current_scoped_entity, anchor, wizard_token, or a host method."
52
+ end
53
+
54
+ # The "continue where you left off" listing (§4.5): in-progress wizard runs
55
+ # for the current user, narrowed to the current tenant scope when the portal is
56
+ # entity-scoped, each enriched with the wizard's label/icon, current step (+
57
+ # label), updated_at, and a resolved resume_url (nil with a reason when a mount
58
+ # can't be resolved generically).
59
+ #
60
+ # This is the public, ergonomic API: like interactions, it takes the
61
+ # +view_context+ and derives the run owner and tenant scope from the controller
62
+ # it carries — `current_user` (the run owner) and `current_scoped_entity` (the
63
+ # tenant, when `scoped_to_entity?`; nil for a non-scoped portal). It delegates to
64
+ # {Resume.entries_for}, which does that derivation and builds the resume URLs in
65
+ # this portal.
66
+ #
67
+ # Plutonium::Wizard.in_progress_for(view_context)
68
+ # Plutonium::Wizard.in_progress_for(view_context, wizard: ConfigureCompanyWizard)
69
+ # Plutonium::Wizard.in_progress_for(view_context, anchor: company)
70
+ #
71
+ # +anchor:+ and +wizard:+ are OPTIONAL narrowing filters applied IN THE QUERY,
72
+ # before each row is enriched (resume-URL built, anchor loaded) — so filtering
73
+ # here is cheaper than `select`-ing the returned array, which enriches every row
74
+ # first. Use them for the per-record / per-wizard resume widgets (e.g. "does this
75
+ # company have an unfinished `configure` draft?"). They compose. Omit both for
76
+ # the full "continue where you left off" dashboard list.
77
+ #
78
+ # @param view_context [ActionView::Base] the current view context (as interactions take)
79
+ # @param anchor [ActiveRecord::Base, nil] narrow to runs anchored against this record
80
+ # @param wizard [Class, nil] narrow to runs of this wizard class
81
+ # @return [Array<Plutonium::Wizard::Resume::Entry>]
82
+ def self.in_progress_for(view_context, anchor: nil, wizard: nil)
83
+ Resume.entries_for(view_context, anchor: anchor, wizard: wizard)
84
+ end
85
+ end
86
+ end