Class: Plutonium::Wizard::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/plutonium/wizard/runner.rb

Overview

The pure navigation/commit engine (§6). Given a wizard class, a Store, and an instance key, it loads (or builds) the State, hydrates a single wizard instance, and drives the flow: compute the visible path, validate + stage a step, run per-step on_submit/persist, navigate back, cancel (cleanup), and finalize via execute (with the completeness check, branch-hidden pruning, and the locked in_progress → completing transition).

No HTTP/controller/UI here — the controller (Task 5) drives this directly.

Defined Under Namespace

Classes: PersistTracker, Result

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(wizard_class:, store:, instance_key:, view_context: nil, owner: nil, anchor: nil, scope: nil, token: nil, engine: nil, current_user: nil, current_scoped_entity: nil) ⇒ Runner

Returns a new instance of Runner.



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/plutonium/wizard/runner.rb', line 31

def initialize(wizard_class:, store:, instance_key:, view_context: nil,
  owner: nil, anchor: nil, scope: nil, token: nil, engine: nil,
  current_user: nil, current_scoped_entity: nil)
  @engine = engine
  @wizard_class = wizard_class
  @store = store
  @instance_key = instance_key
  # The keyed row IS the lock (§4.2): an existing in_progress row at this
  # instance_key is RESUMED, never forked. `read` returns it (or any prior
  # row, incl. a completed one-time marker) for the digest; a fresh launch
  # with no row builds new state.
  existing = store.read(instance_key)

  # Owner-scoped resume (§4.5): for a non-`anonymous` wizard, a row may only
  # be resumed by its owner. A run id leaked in a URL can't be picked up by a
  # different logged-in user — a mismatch reads as "no such run for you".
  # `@forbidden` lets the driving layer 404 rather than silently fork.
  if existing && owner_mismatch?(wizard_class, existing, current_user)
    existing = nil
    @forbidden = true
  end

  @resumed = !existing.nil?
  @state = existing || new_state(owner:, anchor:, scope:, token:)
  @wizard = wizard_class.new(view_context:)
  @wizard.data_attributes = @state.data
  @wizard.anchor = (@state.anchor || anchor) if wizard_class.anchored?
  @wizard.current_user = current_user
  @wizard.current_scoped_entity = current_scoped_entity
  @wizard.wizard_token = token
  # `persisted` is rehydrated LAZILY (§4.5): inject the stored GID source so
  # `wizard.persisted[:key]` locates that key's GIDs on first read (memoized)
  # — a request that never reads `persisted` issues zero locate queries. The
  # anchor (the authz/scoping gate) is still resolved eagerly above.
  @wizard.persisted_gid_source = @state.persisted
end

Instance Attribute Details

#stateObject (readonly)

Returns the value of attribute state.



29
30
31
# File 'lib/plutonium/wizard/runner.rb', line 29

def state
  @state
end

#wizardObject (readonly)

Returns the value of attribute wizard.



29
30
31
# File 'lib/plutonium/wizard/runner.rb', line 29

def wizard
  @wizard
end

Instance Method Details

#advance(step_key, params, goto: nil) ⇒ Object

Validate + stage a step, run its on_submit (in a transaction), then move the cursor to the next visible step. On validation/on_submit failure the cursor does not move and the errors are returned.

goto: overrides the post-advance cursor target (the "Save & review" shortcut, §7): after staging this step it points the cursor at the named visible step (typically the review step) instead of the next one, so a user editing one step after completing the wizard returns straight to review. The override is ignored if it doesn't name a currently-visible step; review's own finalize re-checks completeness, so a forged jump can't skip the gate.



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/plutonium/wizard/runner.rb', line 160

def advance(step_key, params, goto: nil)
  step = step_for(step_key)
  errors = validate(step, params)
  if errors.any?
    # Reflect the rejected submission back into the IN-MEMORY step data (via
    # `stage`, which never persists — only `persist_state` does) so the
    # re-rendered form shows exactly what the user just typed, with the errors
    # attached. Without this, validation failure reverts every field on the
    # step to its last STAGED value — so a sibling field the user filled in
    # correctly silently empties out on the error re-render. Render-only: the
    # cursor and stored row are untouched, so an abandoned invalid submit
    # stages nothing.
    stage(step.key, params)
    return Result.new(ok: false, errors:)
  end

  # Re-submitting a step whose on_submit ALREADY ran (you went back to it and
  # Nexted again): undo the prior attempt — its on_rollback then destroy its
  # tracked records — BEFORE re-running, so records/side effects aren't
  # duplicated and the old records aren't orphaned. `persisted` carries the
  # step's key once on_submit has run (a side-effect-only step records an empty
  # list), so it's the "already ran" signal. An UNCHANGED re-submit keeps the
  # prior result untouched — no needless rollback + re-charge.
  ran_before = step.on_submit && @state.persisted.key?(step.key.to_s)
  changed = step_input_changed?(step, params)
  stage(step.key, params)
  if step.on_submit && (!ran_before || changed)
    rollback_prior_submit(step) if ran_before
    run_on_submit(step)
  end
  @state.visited |= [step.key.to_s]
  # Staging this step's params may have flipped a branch `condition:`, hiding
  # an earlier step that already persisted records (save-as-you-go). Prune it
  # NOW — roll its records back and clear its state — so nothing is orphaned
  # for the rest of the flow (§6.3). A rollback failure here surfaces as a
  # step failure (same as `on_submit`), it is not swallowed; the cursor does
  # not move and the advance's data is not lost (the prune persists state).
  prune_departed_steps
  @state.current_step = advance_target(step, goto)&.key&.to_s
  # Mark the step we ARRIVE at visited too, not just the one we left. `visited`
  # is the set of steps the user has *reached* (the high-water mark) — what the
  # stepper uses to decide which headers link. Without this, landing on a step
  # and navigating away before completing it would leave it unreachable (you
  # couldn't click back to it). Completeness gating is unaffected: it also
  # checks validity, so a required step reached-but-empty still reads incomplete.
  @state.visited |= [@state.current_step].compact
  persist_state
  Result.new(ok: true)
rescue ActiveRecord::RecordInvalid => e
  Result.new(ok: false, errors: message_errors(e.record))
rescue StepError => e
  Result.new(ok: false, errors: {e.attribute => [e.message]})
end

#backObject

Move the cursor to the previous visible step. No validation; never discards staged data (§6 — back is navigation, not submission).



248
249
250
251
252
# File 'lib/plutonium/wizard/runner.rb', line 248

def back
  @state.current_step = previous_visible&.key&.to_s
  persist_state
  Result.new(ok: true)
end

#cancelObject

Abandon the flow: run cleanup (each step's on_rollback, then always destroy its tracked records, in reverse step order) BEFORE clearing the row — clear is a delete_all with no callbacks, so compensation must happen first (§2.3).



258
259
260
261
262
# File 'lib/plutonium/wizard/runner.rb', line 258

def cancel
  run_cleanup
  @store.clear(@instance_key)
  Result.new(ok: true)
end

#completed_one_time?Boolean

Whether this run's key already has a retained completed one-time marker (§4.3 / §9) — re-entering a finished one-time wizard. The driving layer redirects such a request out rather than re-running it.

Returns:

  • (Boolean)


80
81
82
# File 'lib/plutonium/wizard/runner.rb', line 80

def completed_one_time?
  @wizard_class.one_time? && @state.status.to_s == "completed"
end

#current_stepObject

The visible step matching the stored cursor, or the first visible step.



107
108
109
110
# File 'lib/plutonium/wizard/runner.rb', line 107

def current_step
  path = visible_path
  path.find { |s| s.key.to_s == @state.current_step.to_s } || path.first
end

#finalizeObject

Finish the flow (§6.3): assert every visible non-review step is visited and valid (else bounce to the first gap); prune branch-hidden data on a working copy; perform the locked in_progress → completing transition; run execute in a transaction; complete the row on success, revert on failure.



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/plutonium/wizard/runner.rb', line 268

def finalize
  gap = first_incomplete_visible
  return Result.new(ok: false, redirect_step: gap.key) if gap

  # Safety net (§6.3): roll back + forget any branch-hidden step that still
  # holds persisted records or staged data, so nothing orphaned survives into
  # `execute`. `advance` prunes promptly, but a step can be hidden via paths
  # that don't pass through `advance` (e.g. seeded/resumed state).
  prune_departed_steps
  pruned = prune_hidden(@state.data)

  # Lost a concurrent finalize (the row is already `completing` or `completed`,
  # §6.2): another request/tab is running — or already ran — `execute`. Don't
  # render a blank-error 422; PRG back to the terminal step so the follow-up
  # GET resolves to the right place (the "already completed" page for a
  # one-time wizard, or a fresh re-render once the winner finishes).
  unless lock_for_completion!
    return Result.new(ok: false, redirect_step: visible_path.last&.key)
  end

  outcome = nil
  ActiveRecord::Base.transaction do
    @wizard.data_attributes = pruned
    outcome = @wizard.execute
    raise ActiveRecord::Rollback if outcome.failure?
  end

  if outcome.success?
    # Repeatability (§4.3): a one-time wizard RETAINS its completed row at
    # the key (blocks restart, the gate checks it); every other wizard
    # DELETES the row on completion (repeatable — tokened runs always are).
    if @wizard_class.one_time?
      @store.complete(@instance_key)
    else
      @store.clear(@instance_key)
    end
    Result.new(ok: true, completed: true, value: outcome.value)
  else
    revert_completing!
    Result.new(ok: false, errors: wizard_errors)
  end
rescue ActiveRecord::RecordInvalid => e
  revert_completing!
  Result.new(ok: false, errors: message_errors(e.record))
rescue StepError => e
  revert_completing!
  Result.new(ok: false, errors: {e.attribute => [e.message]})
rescue
  # `lock_for_completion!` committed `completing` in its own transaction
  # before `execute` ran (§6.2). Any hard failure here must revert that row
  # to `in_progress` so the user can retry, then propagate.
  revert_completing!
  raise
end

#forbidden?Boolean

Whether an existing row at this key belongs to a DIFFERENT user (§4.5 owner-scoping). The driving layer turns this into a 404 so a leaked run id can't be resumed by another logged-in user.

Returns:

  • (Boolean)


75
# File 'lib/plutonium/wizard/runner.rb', line 75

def forbidden? = !!@forbidden

#go_to(step_key) ⇒ Object

Point the cursor at a specific visible step on a GET (stepper jump / resume via direct URL). Only honored when the target is a currently-visible step the user has already visited — forward jumps to unvisited steps are not allowed (§7). The review step is reachable once it's the visible terminal. No persistence: a GET must not mutate stored state; the cursor move lives for this request so the right step renders seeded from staged data.

Returns true when the requested step is the legitimate current step for this request — already current, or reachable and now aligned — and false when it is not reachable (blank, branch-hidden, or a forward jump to an unvisited step). The driving layer uses this confirmation to abort a POST that targets an unreachable step BEFORE it validates/stages/runs the step's on_submit, so a forged or stale submission can't drive a step the user can't see.



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/plutonium/wizard/runner.rb', line 227

def go_to(step_key)
  return false if step_key.blank?

  target = visible_path.find { |s| s.key.to_s == step_key.to_s }
  return false unless target
  return true if target.key.to_s == @state.current_step.to_s

  # The review step is reachable once the user has started the flow (visited
  # at least one step): it shows the auto-summary, the outstanding "fix this"
  # links, and a Finish that stays disabled until every step is complete —
  # the actual finalize POST re-checks completeness regardless. Other steps
  # are reachable only once visited (no forward jumps to unvisited steps).
  reachable = target.review? ? visited_keys.any? : visited_keys.include?(target.key.to_s)
  return false unless reachable

  @state.current_step = target.key.to_s
  true
end

#incomplete_visible_stepsObject

The ordered visible non-review steps that aren't yet complete (§6.3). The review step lists these as "fix this" jump links and gates Finish.



144
145
146
147
148
# File 'lib/plutonium/wizard/runner.rb', line 144

def incomplete_visible_steps
  visible_path.reject(&:review?).select do |step|
    !(step) || validate(step, {}).any?
  end
end

#resumed?Boolean

Whether a row already existed at this key when the runner was built — i.e. this launch RESUMED rather than started fresh (§4.2).

Returns:

  • (Boolean)


70
# File 'lib/plutonium/wizard/runner.rb', line 70

def resumed? = @resumed

#step_complete?(step) ⇒ Boolean

Whether a visible non-review step is complete: SUBMITTED AND its staged data currently validates (§6.3). Drives the review step's per-step jump links and the gated Finish button (§2.5). A review step is "complete" iff every other visible step is. "Submitted" (advanced THROUGH, not merely reached) is the gating notion — distinct from visited/reached, which is for navigation — so a user can't skip a zero-validation step just by landing on it.

Returns:

  • (Boolean)


127
128
129
130
131
# File 'lib/plutonium/wizard/runner.rb', line 127

def step_complete?(step)
  return incomplete_visible_steps.empty? if step.review?

  (step) && validate(step, {}).empty?
end

#submitted?(step) ⇒ Boolean

Whether a step has been SUBMITTED (advanced through) — its staged data slice exists. Distinct from visited/reached: advancing TO a step (landing on it) doesn't stage its data, so it isn't "submitted" until the user Nexts through it. Drives the forward button label (revisiting a submitted step → "Save & continue") and gates completeness (§6.3).

Returns:

  • (Boolean)


138
139
140
# File 'lib/plutonium/wizard/runner.rb', line 138

def (step)
  @state.data.key?(step.key.to_s)
end

#visible_pathObject

The currently-visible step path (§6.2 subtractive branching). Each step's condition: is evaluated against the latest staged data; the review step is always last by construction.

Called many times per request (current_step, step_complete?, prune_*, the page render), and each call instance_execs every step's condition: — so memoize the result, keyed on the IDENTITY of @state.data. Every data mutation reassigns @state.data to a NEW hash (merge/except/deep_merge or a fresh @state), so an identity change is a reliable "data moved" signal; conditions only depend on data (and the request-stable anchor). sync_data still runs each call to preserve the wizard's live data view.



95
96
97
98
99
100
101
102
103
104
# File 'lib/plutonium/wizard/runner.rb', line 95

def visible_path
  sync_data
  return @visible_path if defined?(@visible_path) && @visible_path_data.equal?(@state.data)

  @visible_path = @wizard_class.steps.select do |step|
    step.condition.nil? || @wizard.instance_exec(&step.condition)
  end
  @visible_path_data = @state.data
  @visible_path
end

#visited_keysObject

The keys of steps the user has REACHED (the high-water mark — every step the cursor has landed on, including the one advanced from and the one advanced to). Drives stepper clickability / go_to reachability (§7); it does NOT gate completeness — that's submitted?. Reaching a step lets you navigate back to it without forcing it to count as done.



117
118
119
# File 'lib/plutonium/wizard/runner.rb', line 117

def visited_keys
  @state.visited.map(&:to_s)
end