Records a workflow event: writes through state_entered_at and, if a
transition_log is configured, appends one row describing what happened.
Replaces the five copy-pasted set_attribute(:state_entered_at, ...) call
sites AshWorkflow.Transformers.AddActions used to inject — one per kind of
workflow event — plus the resource-global :initial row on create. The
change is always injected regardless of whether logging is configured; the
log append is a single conditional at the leaf, not a fork in the
transformer.
Options
:triggered_by(required) — one of:initial,:manual,:automatic,:timeout,:error_path,:undo.:touch_state_entered_at(optional, defaults totrue) — whether the change writesstate_entered_at. A repeating timeout relies on that write: resetting the anchor is how its trigger re-arms. A non-repeating action timeout must not reset it, because every other deadline on the step measuresafterfrom the same attribute, so moving it would push a pendingtransition_totimeout out of reach.:transition_name(optional) — the name recorded on the log row. Defaults tochangeset.action.name, which is enough for most sites, but several of the actionsAddActionsgenerates use an internal hidden name (e.g.__on_error_process) that would be confusing in history, so those sites pass a human-facing name explicitly. An undo takes the name of the row it reverses, so the pair reads as one decision and its reversal rather than as two unrelated events.
Undo
AshWorkflow.Changes.UndoTransition puts the row being reversed into the
changeset context, and this change writes its primary key to the log's
undoes foreign key. That pointer is what makes an undo additive: the
reversed row is left exactly as written, and both readings of history stay
derivable from the same rows — see AshWorkflow.TransitionLog.effective/1.
Telemetry
This change is also where the [:ash_workflow, :transition] span is emitted,
for the same reason it is where the log row is written: it runs on every
action AshWorkflow generates, so one place covers a manual transition, an
automatic step, a timeout, an error path, an undo and the initial create. See
AshWorkflow.Telemetry for the events and their metadata.
Telling the scheduler
Entering a step is when the deadlines ahead of a record become known, so this
change also calls AshWorkflow.Scheduler.notify_state_change/1. A scheduler
that arms timers needs that call to be precise; a scheduler that polls
implements neither runtime callback, so the call does nothing for it.
It runs in the same after_action hook as the log append and the span's stop
event, which means it runs after the data layer has committed the new state.
A timer armed before the commit could fire against a record still holding the
old state.
Atomicity
This change implements atomic/3 rather than falling back to
require_atomic? false. state_entered_at is set with the now() Ash
expression so it stays part of the atomic update, and the log append is
attached as an after_action hook either way — hooks run after the data
layer action completes whether or not the attribute update itself was
atomic, so there is nothing about appending the log row that requires
opting out of atomicity. This is the "try atomic first" route the design
doc calls out as the likeliest source of bugs; see
test/ash_workflow/changes/record_event_test.exs for the direct atomicity
test it asks for.