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,684 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # The pure navigation/commit engine (§6). Given a wizard class, a {Store}, and
6
+ # an instance key, it loads (or builds) the {State}, hydrates a single wizard
7
+ # instance, and drives the flow: compute the visible path, validate + stage a
8
+ # step, run per-step `on_submit`/`persist`, navigate back, cancel (cleanup),
9
+ # and finalize via `execute` (with the completeness check, branch-hidden
10
+ # pruning, and the locked `in_progress → completing` transition).
11
+ #
12
+ # No HTTP/controller/UI here — the controller (Task 5) drives this directly.
13
+ class Runner
14
+ # The outcome of a runner operation.
15
+ #
16
+ # - +ok+ — the operation succeeded (validation passed / navigation moved).
17
+ # - +errors+ — {attribute => [messages]} when it didn't.
18
+ # - +completed+ — finalize ran `execute` to completion.
19
+ # - +redirect_step+ — finalize found a completeness gap; the step to bounce to.
20
+ # - +value+ — the successful `execute` outcome's value.
21
+ Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value) do
22
+ def ok? = !!ok
23
+
24
+ def completed? = !!completed
25
+
26
+ def errors = self[:errors] || {}
27
+ end
28
+
29
+ attr_reader :wizard, :state
30
+
31
+ def initialize(wizard_class:, store:, instance_key:, view_context: nil,
32
+ owner: nil, anchor: nil, scope: nil, token: nil, engine: nil,
33
+ current_user: nil, current_scoped_entity: nil)
34
+ @engine = engine
35
+ @wizard_class = wizard_class
36
+ @store = store
37
+ @instance_key = instance_key
38
+ # The keyed row IS the lock (§4.2): an existing in_progress row at this
39
+ # instance_key is RESUMED, never forked. `read` returns it (or any prior
40
+ # row, incl. a completed one-time marker) for the digest; a fresh launch
41
+ # with no row builds new state.
42
+ existing = store.read(instance_key)
43
+
44
+ # Owner-scoped resume (§4.5): for a non-`anonymous` wizard, a row may only
45
+ # be resumed by its owner. A run id leaked in a URL can't be picked up by a
46
+ # different logged-in user — a mismatch reads as "no such run for you".
47
+ # `@forbidden` lets the driving layer 404 rather than silently fork.
48
+ if existing && owner_mismatch?(wizard_class, existing, current_user)
49
+ existing = nil
50
+ @forbidden = true
51
+ end
52
+
53
+ @resumed = !existing.nil?
54
+ @state = existing || new_state(owner:, anchor:, scope:, token:)
55
+ @wizard = wizard_class.new(view_context:)
56
+ @wizard.data_attributes = @state.data
57
+ @wizard.anchor = (@state.anchor || anchor) if wizard_class.anchored?
58
+ @wizard.current_user = current_user
59
+ @wizard.current_scoped_entity = current_scoped_entity
60
+ @wizard.wizard_token = token
61
+ # `persisted` is rehydrated LAZILY (§4.5): inject the stored GID source so
62
+ # `wizard.persisted[:key]` locates that key's GIDs on first read (memoized)
63
+ # — a request that never reads `persisted` issues zero locate queries. The
64
+ # anchor (the authz/scoping gate) is still resolved eagerly above.
65
+ @wizard.persisted_gid_source = @state.persisted
66
+ end
67
+
68
+ # Whether a row already existed at this key when the runner was built — i.e.
69
+ # this launch RESUMED rather than started fresh (§4.2).
70
+ def resumed? = @resumed
71
+
72
+ # Whether an existing row at this key belongs to a DIFFERENT user (§4.5
73
+ # owner-scoping). The driving layer turns this into a 404 so a leaked run id
74
+ # can't be resumed by another logged-in user.
75
+ def forbidden? = !!@forbidden
76
+
77
+ # Whether this run's key already has a retained `completed` one-time marker
78
+ # (§4.3 / §9) — re-entering a finished one-time wizard. The driving layer
79
+ # redirects such a request out rather than re-running it.
80
+ def completed_one_time?
81
+ @wizard_class.one_time? && @state.status.to_s == "completed"
82
+ end
83
+
84
+ # The currently-visible step path (§6.2 subtractive branching). Each step's
85
+ # `condition:` is evaluated against the latest staged `data`; the review step
86
+ # is always last by construction.
87
+ #
88
+ # Called many times per request (current_step, step_complete?, prune_*, the
89
+ # page render), and each call instance_execs every step's `condition:` — so
90
+ # memoize the result, keyed on the IDENTITY of `@state.data`. Every data
91
+ # mutation reassigns `@state.data` to a NEW hash (`merge`/`except`/`deep_merge`
92
+ # or a fresh `@state`), so an identity change is a reliable "data moved"
93
+ # signal; conditions only depend on `data` (and the request-stable `anchor`).
94
+ # `sync_data` still runs each call to preserve the wizard's live `data` view.
95
+ def visible_path
96
+ sync_data
97
+ return @visible_path if defined?(@visible_path) && @visible_path_data.equal?(@state.data)
98
+
99
+ @visible_path = @wizard_class.steps.select do |step|
100
+ step.condition.nil? || @wizard.instance_exec(&step.condition)
101
+ end
102
+ @visible_path_data = @state.data
103
+ @visible_path
104
+ end
105
+
106
+ # The visible step matching the stored cursor, or the first visible step.
107
+ def current_step
108
+ path = visible_path
109
+ path.find { |s| s.key.to_s == @state.current_step.to_s } || path.first
110
+ end
111
+
112
+ # The keys of steps the user has REACHED (the high-water mark — every step the
113
+ # cursor has landed on, including the one advanced from and the one advanced
114
+ # to). Drives stepper clickability / `go_to` reachability (§7); it does NOT
115
+ # gate completeness — that's `submitted?`. Reaching a step lets you navigate
116
+ # back to it without forcing it to count as done.
117
+ def visited_keys
118
+ @state.visited.map(&:to_s)
119
+ end
120
+
121
+ # Whether a visible non-review step is complete: SUBMITTED AND its staged data
122
+ # currently validates (§6.3). Drives the review step's per-step jump links and
123
+ # the gated Finish button (§2.5). A review step is "complete" iff every other
124
+ # visible step is. "Submitted" (advanced THROUGH, not merely reached) is the
125
+ # gating notion — distinct from `visited`/reached, which is for navigation —
126
+ # so a user can't skip a zero-validation step just by landing on it.
127
+ def step_complete?(step)
128
+ return incomplete_visible_steps.empty? if step.review?
129
+
130
+ submitted?(step) && validate(step, {}).empty?
131
+ end
132
+
133
+ # Whether a step has been SUBMITTED (advanced through) — its staged `data`
134
+ # slice exists. Distinct from `visited`/reached: advancing TO a step (landing
135
+ # on it) doesn't stage its data, so it isn't "submitted" until the user Nexts
136
+ # through it. Drives the forward button label (revisiting a submitted step →
137
+ # "Save & continue") and gates completeness (§6.3).
138
+ def submitted?(step)
139
+ @state.data.key?(step.key.to_s)
140
+ end
141
+
142
+ # The ordered visible non-review steps that aren't yet complete (§6.3). The
143
+ # review step lists these as "fix this" jump links and gates Finish.
144
+ def incomplete_visible_steps
145
+ visible_path.reject(&:review?).select do |step|
146
+ !submitted?(step) || validate(step, {}).any?
147
+ end
148
+ end
149
+
150
+ # Validate + stage a step, run its `on_submit` (in a transaction), then move
151
+ # the cursor to the next visible step. On validation/`on_submit` failure the
152
+ # cursor does not move and the errors are returned.
153
+ #
154
+ # `goto:` overrides the post-advance cursor target (the "Save & review"
155
+ # shortcut, §7): after staging this step it points the cursor at the named
156
+ # visible step (typically the review step) instead of the next one, so a user
157
+ # editing one step after completing the wizard returns straight to review. The
158
+ # override is ignored if it doesn't name a currently-visible step; review's
159
+ # own finalize re-checks completeness, so a forged jump can't skip the gate.
160
+ def advance(step_key, params, goto: nil)
161
+ step = step_for(step_key)
162
+ errors = validate(step, params)
163
+ if errors.any?
164
+ # Reflect the rejected submission back into the IN-MEMORY step data (via
165
+ # `stage`, which never persists — only `persist_state` does) so the
166
+ # re-rendered form shows exactly what the user just typed, with the errors
167
+ # attached. Without this, validation failure reverts every field on the
168
+ # step to its last STAGED value — so a sibling field the user filled in
169
+ # correctly silently empties out on the error re-render. Render-only: the
170
+ # cursor and stored row are untouched, so an abandoned invalid submit
171
+ # stages nothing.
172
+ stage(step.key, params)
173
+ return Result.new(ok: false, errors:)
174
+ end
175
+
176
+ # Re-submitting a step whose on_submit ALREADY ran (you went back to it and
177
+ # Nexted again): undo the prior attempt — its on_rollback then destroy its
178
+ # tracked records — BEFORE re-running, so records/side effects aren't
179
+ # duplicated and the old records aren't orphaned. `persisted` carries the
180
+ # step's key once on_submit has run (a side-effect-only step records an empty
181
+ # list), so it's the "already ran" signal. An UNCHANGED re-submit keeps the
182
+ # prior result untouched — no needless rollback + re-charge.
183
+ ran_before = step.on_submit && @state.persisted.key?(step.key.to_s)
184
+ changed = step_input_changed?(step, params)
185
+ stage(step.key, params)
186
+ if step.on_submit && (!ran_before || changed)
187
+ rollback_prior_submit(step) if ran_before
188
+ run_on_submit(step)
189
+ end
190
+ @state.visited |= [step.key.to_s]
191
+ # Staging this step's params may have flipped a branch `condition:`, hiding
192
+ # an earlier step that already persisted records (save-as-you-go). Prune it
193
+ # NOW — roll its records back and clear its state — so nothing is orphaned
194
+ # for the rest of the flow (§6.3). A rollback failure here surfaces as a
195
+ # step failure (same as `on_submit`), it is not swallowed; the cursor does
196
+ # not move and the advance's data is not lost (the prune persists state).
197
+ prune_departed_steps
198
+ @state.current_step = advance_target(step, goto)&.key&.to_s
199
+ # Mark the step we ARRIVE at visited too, not just the one we left. `visited`
200
+ # is the set of steps the user has *reached* (the high-water mark) — what the
201
+ # stepper uses to decide which headers link. Without this, landing on a step
202
+ # and navigating away before completing it would leave it unreachable (you
203
+ # couldn't click back to it). Completeness gating is unaffected: it also
204
+ # checks validity, so a required step reached-but-empty still reads incomplete.
205
+ @state.visited |= [@state.current_step].compact
206
+ persist_state
207
+ Result.new(ok: true)
208
+ rescue ActiveRecord::RecordInvalid => e
209
+ Result.new(ok: false, errors: message_errors(e.record))
210
+ rescue StepError => e
211
+ Result.new(ok: false, errors: {e.attribute => [e.message]})
212
+ end
213
+
214
+ # Point the cursor at a specific visible step on a GET (stepper jump / resume
215
+ # via direct URL). Only honored when the target is a currently-visible step
216
+ # the user has already visited — forward jumps to unvisited steps are not
217
+ # allowed (§7). The review step is reachable once it's the visible terminal.
218
+ # No persistence: a GET must not mutate stored state; the cursor move lives
219
+ # for this request so the right step renders seeded from staged data.
220
+ #
221
+ # Returns true when the requested step is the legitimate current step for this
222
+ # request — already current, or reachable and now aligned — and false when it
223
+ # is not reachable (blank, branch-hidden, or a forward jump to an unvisited
224
+ # step). The driving layer uses this confirmation to abort a POST that targets
225
+ # an unreachable step BEFORE it validates/stages/runs the step's on_submit, so
226
+ # a forged or stale submission can't drive a step the user can't see.
227
+ def go_to(step_key)
228
+ return false if step_key.blank?
229
+
230
+ target = visible_path.find { |s| s.key.to_s == step_key.to_s }
231
+ return false unless target
232
+ return true if target.key.to_s == @state.current_step.to_s
233
+
234
+ # The review step is reachable once the user has started the flow (visited
235
+ # at least one step): it shows the auto-summary, the outstanding "fix this"
236
+ # links, and a Finish that stays disabled until every step is complete —
237
+ # the actual finalize POST re-checks completeness regardless. Other steps
238
+ # are reachable only once visited (no forward jumps to unvisited steps).
239
+ reachable = target.review? ? visited_keys.any? : visited_keys.include?(target.key.to_s)
240
+ return false unless reachable
241
+
242
+ @state.current_step = target.key.to_s
243
+ true
244
+ end
245
+
246
+ # Move the cursor to the previous visible step. No validation; never discards
247
+ # staged data (§6 — back is navigation, not submission).
248
+ def back
249
+ @state.current_step = previous_visible&.key&.to_s
250
+ persist_state
251
+ Result.new(ok: true)
252
+ end
253
+
254
+ # Abandon the flow: run cleanup (each step's `on_rollback`, then always
255
+ # destroy its tracked records, in reverse step order) BEFORE clearing the
256
+ # row — `clear` is a `delete_all` with no callbacks, so compensation must
257
+ # happen first (§2.3).
258
+ def cancel
259
+ run_cleanup
260
+ @store.clear(@instance_key)
261
+ Result.new(ok: true)
262
+ end
263
+
264
+ # Finish the flow (§6.3): assert every visible non-review step is visited and
265
+ # valid (else bounce to the first gap); prune branch-hidden data on a working
266
+ # copy; perform the locked `in_progress → completing` transition; run
267
+ # `execute` in a transaction; complete the row on success, revert on failure.
268
+ def finalize
269
+ gap = first_incomplete_visible
270
+ return Result.new(ok: false, redirect_step: gap.key) if gap
271
+
272
+ # Safety net (§6.3): roll back + forget any branch-hidden step that still
273
+ # holds persisted records or staged data, so nothing orphaned survives into
274
+ # `execute`. `advance` prunes promptly, but a step can be hidden via paths
275
+ # that don't pass through `advance` (e.g. seeded/resumed state).
276
+ prune_departed_steps
277
+ pruned = prune_hidden(@state.data)
278
+
279
+ # Lost a concurrent finalize (the row is already `completing` or `completed`,
280
+ # §6.2): another request/tab is running — or already ran — `execute`. Don't
281
+ # render a blank-error 422; PRG back to the terminal step so the follow-up
282
+ # GET resolves to the right place (the "already completed" page for a
283
+ # one-time wizard, or a fresh re-render once the winner finishes).
284
+ unless lock_for_completion!
285
+ return Result.new(ok: false, redirect_step: visible_path.last&.key)
286
+ end
287
+
288
+ outcome = nil
289
+ ActiveRecord::Base.transaction do
290
+ @wizard.data_attributes = pruned
291
+ outcome = @wizard.execute
292
+ raise ActiveRecord::Rollback if outcome.failure?
293
+ end
294
+
295
+ if outcome.success?
296
+ # Repeatability (§4.3): a one-time wizard RETAINS its completed row at
297
+ # the key (blocks restart, the gate checks it); every other wizard
298
+ # DELETES the row on completion (repeatable — tokened runs always are).
299
+ if @wizard_class.one_time?
300
+ @store.complete(@instance_key)
301
+ else
302
+ @store.clear(@instance_key)
303
+ end
304
+ Result.new(ok: true, completed: true, value: outcome.value)
305
+ else
306
+ revert_completing!
307
+ Result.new(ok: false, errors: wizard_errors)
308
+ end
309
+ rescue ActiveRecord::RecordInvalid => e
310
+ revert_completing!
311
+ Result.new(ok: false, errors: message_errors(e.record))
312
+ rescue StepError => e
313
+ revert_completing!
314
+ Result.new(ok: false, errors: {e.attribute => [e.message]})
315
+ rescue
316
+ # `lock_for_completion!` committed `completing` in its own transaction
317
+ # before `execute` ran (§6.2). Any hard failure here must revert that row
318
+ # to `in_progress` so the user can retry, then propagate.
319
+ revert_completing!
320
+ raise
321
+ end
322
+
323
+ private
324
+
325
+ def sync_data
326
+ @wizard.data_attributes = @state.data
327
+ end
328
+
329
+ # Stage a step's submitted (flat) fields under its step key — `data` is keyed
330
+ # by step ({step_key => {field => value}}), so a step's writes never touch
331
+ # another step's slice.
332
+ def stage(step_key, params)
333
+ key = step_key.to_s
334
+ current = @state.data[key] || {}
335
+ @state.data = @state.data.merge(key => current.merge(params))
336
+ sync_data
337
+ end
338
+
339
+ # Persist the staged state. The store writes verbatim on the normal
340
+ # single-writer path; when a CONCURRENT advance committed since we read the
341
+ # row (double-submit, two tabs, or the first-step unique-index create race),
342
+ # it calls {#merge_concurrent_state} under a row lock so neither side's data
343
+ # is lost (§6.2). The returned state carries the bumped `lock_version`, so a
344
+ # later write in this same request is recognised as current (not a conflict).
345
+ def persist_state
346
+ @state = @store.write(@instance_key, @state, cleanup_after: @wizard_class.cleanup_after) do |latest|
347
+ merge_concurrent_state(latest)
348
+ end
349
+ end
350
+
351
+ # Merge this request's staged changes onto the LATEST committed state (read
352
+ # under the store's row lock), returning the state to persist. `data` is
353
+ # nested ({step_key => {field => value}}) → deep-merge so a concurrent step's
354
+ # fields aren't clobbered. `persisted` is {step_key => [gids]} → UNION the
355
+ # lists per step; a shallow merge would replace the other writer's GID list
356
+ # for a shared step key (both raced first-step on_submits), orphaning its
357
+ # records (no longer tracked for rollback/sweep). Identity/context fields are
358
+ # taken from whichever side has them (the row may have been a bare create).
359
+ def merge_concurrent_state(latest)
360
+ latest.data = latest.data.deep_merge(@state.data)
361
+ latest.persisted = latest.persisted.merge(@state.persisted) { |_key, a, b| a | b }
362
+ latest.visited |= @state.visited
363
+ latest.current_step = @state.current_step
364
+ latest.owner ||= @state.owner
365
+ latest.anchor ||= @state.anchor
366
+ latest.scope ||= @state.scope
367
+ latest.token ||= @state.token
368
+ latest.engine ||= @state.engine
369
+ latest
370
+ end
371
+
372
+ def step_for(key)
373
+ @wizard_class.steps.find { |s| s.key.to_s == key.to_s }
374
+ end
375
+
376
+ def next_visible_after(step)
377
+ path = visible_path
378
+ idx = path.index { |s| s.key == step.key }
379
+ idx ? path[idx + 1] : path.first
380
+ end
381
+
382
+ # The cursor's post-advance target: the `goto` step when it names a currently-
383
+ # visible step (the "Save & review" shortcut), else the next visible step.
384
+ def advance_target(step, goto)
385
+ if goto.present?
386
+ target = visible_path.find { |s| s.key.to_s == goto.to_s }
387
+ return target if target
388
+ end
389
+ next_visible_after(step)
390
+ end
391
+
392
+ def previous_visible
393
+ path = visible_path
394
+ idx = path.index { |s| s.key.to_s == @state.current_step.to_s } || 0
395
+ path[[idx - 1, 0].max]
396
+ end
397
+
398
+ # Validate a step's params: imported (model) validation merged with inline
399
+ # `validates`. `imported_validate_fn` MAY be nil (validate: false) — nil-guard.
400
+ def validate(step, params)
401
+ return {} if step.review?
402
+
403
+ merged = (@state.data[step.key.to_s] || {}).merge(params)
404
+ errors = {}
405
+ imported = step.imported_validate_fn&.call(merged)
406
+ errors.merge!(stringify_messages(imported)) if imported
407
+ errors.merge!(inline_errors(step, merged)) { |_k, a, b| Array(a) + Array(b) }
408
+ errors.merge!(attachment_errors(step, merged)) { |_k, a, b| Array(a) + Array(b) }
409
+ errors.reject { |_attr, msgs| Array(msgs).blank? }
410
+ end
411
+
412
+ # Stage-phase attachment validation: run each Shrine-backed file field's
413
+ # effective uploader (`uploader:` or base Shrine) validations against the
414
+ # staged token, so a file that violates them is rejected on THIS step — the
415
+ # same field-error/re-render path as `validates` — instead of only at
416
+ # `execute`. A no-op for ActiveStorage fields and for uploaders with no rules.
417
+ def attachment_errors(step, merged)
418
+ step.inputs.each_with_object({}) do |(name, config), acc|
419
+ next unless Plutonium::Wizard::Attachments.field?(config)
420
+
421
+ messages = Plutonium::Wizard::Attachments.validation_errors(
422
+ merged[name.to_s],
423
+ backend: config.dig(:options, :backend),
424
+ uploader: config.dig(:options, :uploader)
425
+ )
426
+ acc[name.to_sym] = messages if messages.any?
427
+ end
428
+ end
429
+
430
+ # Run the step's inline `validates` against a transient instance of the SAME
431
+ # typed class the form/data layer uses ({Data.class_for}), built from THIS
432
+ # step's schema (not a cross-step union). Passes ONLY the inline `validations`
433
+ # — NOT the import's `form_validators` — because imported fields are validated
434
+ # separately through the transient model (`imported_validate_fn`); folding the
435
+ # imported validators in here too would double-report them. Returns
436
+ # {attribute => [String messages]} keyed by symbol.
437
+ def inline_errors(step, merged)
438
+ # Reuse the wizard class's memoized inline-validation class (built once)
439
+ # instead of recompiling an anonymous class on every call. nil → the step
440
+ # has no inline validations.
441
+ klass = @wizard_class.inline_validation_classes[step.key.to_sym]
442
+ return {} unless klass
443
+
444
+ obj = klass.new(merged)
445
+ obj.valid?
446
+ message_errors(obj)
447
+ end
448
+
449
+ # Run the step's `on_submit` in a transaction, with the `persist` macro bound
450
+ # to the wizard for the duration. Tracked records' GIDs land in
451
+ # `state.persisted[step_key]`; the live records land in `wizard.persisted`.
452
+ def run_on_submit(step)
453
+ tracker = PersistTracker.new
454
+ ActiveRecord::Base.transaction do
455
+ sync_data
456
+ @wizard.define_singleton_method(:persist) { |*records| tracker.add(records.flatten) }
457
+ begin
458
+ @wizard.instance_exec(&step.on_submit)
459
+ ensure
460
+ @wizard.singleton_class.send(:remove_method, :persist) if @wizard.singleton_class.method_defined?(:persist)
461
+ end
462
+ end
463
+ @state.persisted = @state.persisted.merge(step.key.to_s => tracker.gids)
464
+ @wizard.persisted[step.key.to_sym] = tracker.records
465
+ end
466
+
467
+ # Undo a step's PRIOR on_submit before it is re-run (a re-submit with changed
468
+ # input). Runs the same compensation as cancel/branch-prune — the step's
469
+ # on_rollback then `destroy!` of its tracked records — in its own transaction
470
+ # (consistent with `run_cleanup`/`prune_departed_steps`), then forgets the
471
+ # step's persisted entry so the fresh on_submit starts clean.
472
+ def rollback_prior_submit(step)
473
+ ActiveRecord::Base.transaction { rollback_step(step) }
474
+ @state.persisted = @state.persisted.except(step.key.to_s)
475
+ @wizard.persisted[step.key.to_sym] = []
476
+ end
477
+
478
+ # Whether the incoming params would change the step's already-staged slice —
479
+ # i.e. the user edited something. An unchanged re-submit (back, then Next with
480
+ # no edits) must NOT re-run a side-effecting on_submit.
481
+ def step_input_changed?(step, params)
482
+ current = @state.data[step.key.to_s] || {}
483
+ current.merge(params) != current
484
+ end
485
+
486
+ # Reverse-order cleanup of every step's tracked records (§2.3): run each
487
+ # step's `on_rollback` (if any) then always destroy its tracked records.
488
+ def run_cleanup
489
+ ActiveRecord::Base.transaction do
490
+ @wizard_class.steps.reverse_each { |step| rollback_step(step) }
491
+ end
492
+ end
493
+
494
+ # Per-step rollback (§2.3), shared by `run_cleanup` (cancel/sweep) and the
495
+ # branch-hidden prune path (§6.3). `persist`'d records are ALWAYS destroyed
496
+ # by the engine; `on_rollback` is an OPTIONAL hook for ADDITIONAL cleanup of
497
+ # untracked side effects (refund a charge, call an external API), run while
498
+ # the records are still alive so it can read `persisted[step]`.
499
+ #
500
+ # Order: locate the step's tracked records and populate `wizard.persisted`,
501
+ # run the user's `on_rollback` FIRST (records still alive — e.g. to read a
502
+ # `charge_id` to refund), THEN destroy the records in reverse order via
503
+ # `destroy!` (which respects a model's own soft-delete/paranoia override).
504
+ #
505
+ # A no-op only when the step tracked nothing AND has no `on_rollback` (so a
506
+ # step never persisted to and with no compensator issues no locate beyond
507
+ # the single `located_records` probe). A side-effect-only step (an
508
+ # `on_rollback` but no persisted records) still runs its `on_rollback`.
509
+ # Callers wrap this in a transaction so the compensating writes are atomic.
510
+ def rollback_step(step)
511
+ records = located_records(step)
512
+ return if records.empty? && step.on_rollback.nil?
513
+
514
+ @wizard.persisted[step.key.to_sym] = records
515
+ @wizard.instance_exec(&step.on_rollback) if step.on_rollback
516
+ records.reverse_each(&:destroy!)
517
+ end
518
+
519
+ def located_records(step)
520
+ gids = Array(@state.persisted[step.key.to_s])
521
+ return [] if gids.empty?
522
+
523
+ # One query per model class (vs one locate per GID) for a multi-record step;
524
+ # `ignore_missing` drops already-destroyed records, order is preserved.
525
+ GlobalID::Locator.locate_many(gids, ignore_missing: true)
526
+ end
527
+
528
+ # The first visible non-review step that hasn't been submitted+validated
529
+ # (§6.3): a step is incomplete if it was never SUBMITTED (advanced through) OR
530
+ # its staged data is invalid. A zero-validation step is therefore NOT complete
531
+ # until submitted, so a user can't skip it and still finalize. Branch-hidden
532
+ # steps fall out of `visible_path` and are excluded naturally.
533
+ def first_incomplete_visible
534
+ visible_path.reject(&:review?).find do |step|
535
+ !submitted?(step) || validate(step, {}).any?
536
+ end
537
+ end
538
+
539
+ # Drop the staged slice of any step not currently visible (§6.3 pruning) —
540
+ # `data` is keyed by step, so this is a slice of the visible step keys.
541
+ # Returns a working copy; the stored data is untouched.
542
+ def prune_hidden(data)
543
+ data.slice(*visible_path.reject(&:review?).map { |s| s.key.to_s })
544
+ end
545
+
546
+ # Fully prune every step that has left the visible path but still has
547
+ # persisted records or staged `data` (§6.3). Save-as-you-go means a step's
548
+ # `on_submit` may have persisted records; when a later answer hides that step
549
+ # those records would otherwise be orphaned (`prune_hidden` only slices the
550
+ # working-copy `data`, it never rolls records back). For each departed step we
551
+ # roll its records back (`rollback_step` — the step's `on_rollback` then the
552
+ # engine's destroy), clear its persisted/data/visited state, then persist so
553
+ # the cleared state is durable.
554
+ #
555
+ # Only departed steps that actually hold something are touched — a step never
556
+ # persisted to and with no staged data issues no locate (we don't probe the
557
+ # whole step list), so the lazy-persisted contract is preserved.
558
+ def prune_departed_steps
559
+ visible = visible_path
560
+ departed = @wizard_class.steps.reject do |step|
561
+ visible.any? { |v| v.key == step.key } || !step_has_state?(step)
562
+ end
563
+ return if departed.empty?
564
+
565
+ # Compensating writes are atomic, consistent with `run_cleanup`/`on_submit`.
566
+ # Reverse order so later steps unwind before the earlier ones they built on.
567
+ ActiveRecord::Base.transaction do
568
+ departed.reverse_each { |step| rollback_step(step) }
569
+ end
570
+
571
+ departed.each { |step| forget_step(step) }
572
+ persist_state
573
+ end
574
+
575
+ # Whether a step holds anything worth pruning: persisted records (its key is
576
+ # present in stored `persisted`) or a non-empty staged `data` slice for the
577
+ # step. Pure hash/key inspection — never locates.
578
+ def step_has_state?(step)
579
+ return true if @state.persisted.key?(step.key.to_s)
580
+
581
+ @state.data[step.key.to_s].present?
582
+ end
583
+
584
+ # Erase all trace of a step that left the visible path: its persisted GIDs
585
+ # (state + the live wizard view), its staged `data`, and its visited mark — so
586
+ # if the branch is re-entered the step is treated as unvisited and its
587
+ # `on_submit` re-runs cleanly (§6.3).
588
+ def forget_step(step)
589
+ key = step.key.to_s
590
+ @state.persisted = @state.persisted.except(key)
591
+ @wizard.persisted[step.key.to_sym] = []
592
+
593
+ @state.data = @state.data.except(key)
594
+ @state.visited = @state.visited - [key]
595
+ sync_data
596
+ end
597
+
598
+ # The locked `in_progress → completing` transition (§6.2). With the AR store a
599
+ # row exists and we lock it; the Memory store has no row, so there's nothing
600
+ # to lock and we proceed (the in-process test can't race). Returns false for
601
+ # the loser of a concurrent finalize.
602
+ def lock_for_completion!
603
+ row = Session.find_by(instance_key: @instance_key)
604
+ return true unless row
605
+
606
+ row.with_lock do
607
+ return false unless row.status_in_progress?
608
+ row.update!(status: "completing")
609
+ end
610
+ true
611
+ end
612
+
613
+ def revert_completing!
614
+ Session.where(instance_key: @instance_key, status: "completing")
615
+ .update_all(status: "in_progress")
616
+ end
617
+
618
+ # Owner-scoping check (§4.5): a non-`anonymous` wizard's row may only be
619
+ # resumed by the user that owns it. An `anonymous` wizard is guarded by its
620
+ # unguessable run id instead (no owner), so it never mismatches here.
621
+ def owner_mismatch?(wizard_class, state, current_user)
622
+ return false if wizard_class.anonymous?
623
+ return false if state.owner.nil?
624
+
625
+ gid(state.owner) != gid(current_user)
626
+ end
627
+
628
+ def gid(record)
629
+ record&.to_global_id&.to_s
630
+ end
631
+
632
+ def new_state(owner:, anchor:, scope:, token:)
633
+ State.new(
634
+ wizard: @wizard_class.name,
635
+ instance_key: @instance_key,
636
+ current_step: @wizard_class.steps.first&.key&.to_s,
637
+ status: "in_progress",
638
+ data: {},
639
+ persisted: {},
640
+ visited: [],
641
+ owner:,
642
+ anchor:,
643
+ scope:,
644
+ token:,
645
+ engine: @engine
646
+ )
647
+ end
648
+
649
+ def wizard_errors
650
+ message_errors(@wizard)
651
+ end
652
+
653
+ # Normalize a model's errors to {attribute_sym => [String messages]} (§6.1).
654
+ def message_errors(obj)
655
+ obj.errors.group_by_attribute.transform_values { |errs| errs.map(&:message) }
656
+ end
657
+
658
+ # Normalize a {attribute => [ActiveModel::Error | String]} hash (e.g. from
659
+ # `imported_validate_fn`) to {attribute => [String messages]} (§6.1).
660
+ def stringify_messages(errors)
661
+ errors.transform_values do |msgs|
662
+ Array(msgs).map { |m| m.respond_to?(:message) ? m.message : m }
663
+ end
664
+ end
665
+
666
+ # Accumulates the records passed to the `persist` macro inside `on_submit`.
667
+ class PersistTracker
668
+ def initialize
669
+ @records = []
670
+ end
671
+
672
+ def add(records)
673
+ @records.concat(Array(records))
674
+ end
675
+
676
+ attr_reader :records
677
+
678
+ def gids
679
+ @records.map { |r| r.to_global_id.to_s }
680
+ end
681
+ end
682
+ end
683
+ end
684
+ end