Declarative workflow orchestration for Ash Framework.

Define multi-step workflows that combine human actions, background jobs, and time-based deadlines as a single DSL. Generates ash_state_machine states/transitions, ash_oban triggers, and Ash actions.

workflow

Define a workflow by declaring steps, transitions, and timeouts.

Nested DSLs

Options

NameTypeDefaultDocs
state_attributeatomThe attribute the workflow's current step is stored in. Defaults to state. AshWorkflow passes this down to ash_state_machine, so set it here rather than in the state_machine section. Use it when the resource already has a lifecycle column of its own, such as status.
scheduleranyThe module that decides when this workflow's automatic steps and timeouts run, optionally with its options: scheduler AshWorkflow.Scheduler.Oban scheduler {AshWorkflow.Scheduler.Oban, check_interval: "0 "} Defaults to the :scheduler application environment for :ash_workflow, and to AshWorkflow.Scheduler.Oban when that is unset. AshWorkflow.Scheduler.Oban requires the resource to also have the AshOban extension. AshWorkflow does not add it, so that a workflow using a scheduler unrelated to Oban does not carry the ash_oban DSL. See AshWorkflow.Scheduler for the behaviour an implementation satisfies.
queueatom:workflowThe Oban queue to use for all generated triggers. Defaults to :workflow.
check_intervalString.t"* * * * *"Oban cron expression controlling how often every generated trigger on this resource polls — both automatic steps and timeouts. Defaults to every minute. Each automatic step and each timeout gets its own scheduler, and every scheduler runs a query on every tick, so this multiplies: a resource with four automatic steps and four timeouts polls eight times a minute at the default. Raise it for workflows measured in days, and override individual timeouts with check_interval on the timeout itself.
generate_indexes?booleantrueWhether to add the composite indexes the generated triggers rely on. Only applies to resources using AshPostgres.DataLayer; other data layers ignore it. Every trigger filters on the state attribute, and every timeout also filters on its field, so without (state, field) indexes each poll is a sequential scan. Set to false if you manage these indexes yourself — a custom_indexes entry on the same fields already takes precedence. See AshWorkflow.Info.recommended_indexes/1.

workflow.step

step name

Declares a step in the workflow. Each step becomes a state in the generated state machine.

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatomThe name of the step. Becomes a state in the generated state machine.

Options

NameTypeDefaultDocs
actionatomThe action to run for automatic steps. Must reference a user-defined update action on the resource.
initialbooleanfalseIf true, this step is the initial state. At most one step can be marked initial. If none are, the first non-terminal step by declaration order is used.
terminalbooleanfalseAsserts that this is an end state. A step that declares no action, no transitions, no timeouts, no on_success and no on_error is terminal whether or not this is set, so it is only needed to state the intent — the verifier then rejects the step if it grows an outgoing declaration.
on_erroratomThe step to transition to on failure. Optional, for automatic steps.
policyanyAn Ash policy check to apply to all transitions in this step. Accepts any {module, opts} tuple implementing Ash.Policy.Check.

workflow.step.transition

transition name

Declares a named transition from this manual step to another step.

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatomThe name of the transition. Becomes an Ash update action.

Options

NameTypeDefaultDocs
toatomThe step to transition to. Omit when using conditional routes.
acceptlist(atom)[]List of resource attributes the generated transition action should accept as input.
undoable?booleanfalseIf true, this transition may be rewound by the generated undo action. Requires an undo block on the workflow. Opt-in per transition rather than per workflow, because undoing a decision that has already had effects outside the workflow — an offer sent, a payment taken — cannot be made safe by the extension. The default is that nothing is undoable. Note that undo restores state, not attributes: a transition with accept does not have its accepted values rolled back.

workflow.step.transition.route

route to

A conditional target for a transition. Evaluated at runtime.

Arguments

NameTypeDefaultDocs
toatomThe step to transition to if the condition matches.

Options

NameTypeDefaultDocs
whenanyAn Ash expression evaluated against the record with the action's changes applied, so it can read an attribute the same call accepted. Use expr(attribute == value).

Introspection

Target: AshWorkflow.Entities.Route

Introspection

Target: AshWorkflow.Entities.Transition

workflow.step.timeout

timeout name

Declares a time-based action or forced transition if the workflow stays in this step too long.

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatomA unique name for this timeout.

Options

NameTypeDefaultDocs
fire_afteranyDuration tuple, e.g. {3, :days} or {2, :hours}, measured from field. Exactly one of fire_after or fire_at is required.
fire_atatomA datetime attribute or expression calculation holding the deadline instant itself. The timeout fires once that instant has passed, at the resolution the scheduler polls with. Cannot be combined with fire_after or field.
fieldatomThe datetime attribute or calculation to measure fire_after against. Defaults to :state_entered_at. Has no meaning with fire_at, which names the deadline itself.
actionatomAction to run when the timeout fires. Does not change state.
transition_toatomStep to force-transition to when the timeout fires.
self_scheduled?booleanfalseDeclares that you run this timeout's trigger yourself, more often than a cron expression can ask for. Cron cannot poll more often than once a minute, so a sub-minute fire_after is normally a compile error — the deadline would fire up to 60 seconds late. Setting this asserts that something else drives the trigger at the resolution the deadline needs, and permits the shorter duration. This changes nothing about what is generated: the scheduler module and its cron are still created, so AshOban.schedule/2 and AshOban.schedule_and_run_triggers/1 keep working. It only records the claim, and silences the check that would otherwise reject the duration.
check_intervalString.tOban cron expression for how often to check this timeout, overriding the workflow-level check_interval. Defaults to the workflow's setting, which itself defaults to every minute.

workflow.step.timeout.retry

Declares the failure policy for the step's or timeout's generated work: how many attempts and how long between them. Scheduler-neutral: AshWorkflow.Scheduler.Oban turns it into the trigger's max_attempts and backoff, and AshWorkflow.Scheduler.Precise re-arms its timer. Either way, on_error runs only after the final attempt fails.

Options

NameTypeDefaultDocs
max_attemptspos_integer1How many times the scheduler runs the step's action before it gives up. The default of 1 means one attempt and no retry, which is why a step declaring on_error moves to its error state on the first failure.
backoffany | :exponential:exponentialHow long to wait between attempts. A duration tuple such as {10, :seconds} is a fixed delay between attempts. :exponential grows the delay with the attempt number. Has no effect while max_attempts is 1.

Introspection

Target: AshWorkflow.Entities.Retry

Introspection

Target: AshWorkflow.Entities.Timeout

workflow.step.every

every name, interval \\ nil

Declares a recurring action that runs on an interval for as long as the workflow stays in this step.

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatomA unique name for this recurring action.
intervalanyDuration tuple, e.g. {1, :day} or {2, :hours}.

Options

NameTypeDefaultDocs
actionatomAction to run each time the interval elapses. Does not change state.
untilanyStop firing once this much wall-clock time has passed since the record entered the step. Measured against state_entered_at directly, which an every's own firing no longer touches. Must be strictly longer than interval, since equal to it leaves no room to fire even once.
self_scheduled?booleanfalseDeclares that you run this action's trigger yourself, more often than a cron expression can ask for. Cron cannot poll more often than once a minute, so a sub-minute interval is normally a compile error — the action would fire up to 60 seconds late. Setting this asserts that something else drives the trigger at the resolution the interval needs, and permits the shorter duration. This changes nothing about what is generated: the scheduler module and its cron are still created, so AshOban.schedule/2 and AshOban.schedule_and_run_triggers/1 keep working. It only records the claim, and silences the check that would otherwise reject the duration.
check_intervalString.tOban cron expression for how often to check this action, overriding the workflow-level check_interval. Defaults to the workflow's setting, which itself defaults to every minute.
last_fired_fieldatomThe datetime attribute this every writes its last-fired instant to, and measures interval against. Defaults to <step>_<every>_last_fired_at. AshWorkflow adds this attribute and owns every write to it — see AshWorkflow.Transformers.AddAttributes.

workflow.step.every.retry

Declares the failure policy for the step's or timeout's generated work: how many attempts and how long between them. Scheduler-neutral: AshWorkflow.Scheduler.Oban turns it into the trigger's max_attempts and backoff, and AshWorkflow.Scheduler.Precise re-arms its timer. Either way, on_error runs only after the final attempt fails.

Options

NameTypeDefaultDocs
max_attemptspos_integer1How many times the scheduler runs the step's action before it gives up. The default of 1 means one attempt and no retry, which is why a step declaring on_error moves to its error state on the first failure.
backoffany | :exponential:exponentialHow long to wait between attempts. A duration tuple such as {10, :seconds} is a fixed delay between attempts. :exponential grows the delay with the attempt number. Has no effect while max_attempts is 1.

Introspection

Target: AshWorkflow.Entities.Retry

Introspection

Target: AshWorkflow.Entities.Every

workflow.step.on_success

on_success to

Declares a step to transition to when this step's action succeeds, optionally guarded by a when condition. Repeatable: declare it more than once to fan out to different states depending on what the action computed. Entries are evaluated in declaration order, first match wins, against the record after the step's action has run. An entry with no when is unconditional and must be declared last, since it always matches and would otherwise shadow any entries after it. If no entry is unconditional, the step must declare another way out — on_error, or a timeout with transition_to — since a record whose action succeeds but matches none of the conditions fails with AshWorkflow.Errors.NoMatchingRoute and stays in the step until one of those moves it.

Arguments

NameTypeDefaultDocs
toatomThe step to transition to.

Options

NameTypeDefaultDocs
whenanyAn Ash expression evaluated against the record after the action has run. Use expr(attribute == value). Omit for an unconditional route.

Introspection

Target: AshWorkflow.Entities.Route

workflow.step.retry

Declares the failure policy for the step's or timeout's generated work: how many attempts and how long between them. Scheduler-neutral: AshWorkflow.Scheduler.Oban turns it into the trigger's max_attempts and backoff, and AshWorkflow.Scheduler.Precise re-arms its timer. Either way, on_error runs only after the final attempt fails.

Options

NameTypeDefaultDocs
max_attemptspos_integer1How many times the scheduler runs the step's action before it gives up. The default of 1 means one attempt and no retry, which is why a step declaring on_error moves to its error state on the first failure.
backoffany | :exponential:exponentialHow long to wait between attempts. A duration tuple such as {10, :seconds} is a fixed delay between attempts. :exponential grows the delay with the attempt number. Has no effect while max_attempts is 1.

Introspection

Target: AshWorkflow.Entities.Retry

Introspection

Target: AshWorkflow.Entities.Step

workflow.transition_log

transition_log resource

Declares an opt-in transition log resource that records one row per workflow event. See AshWorkflow.Entities.TransitionLog.

Nested DSLs

Arguments

NameTypeDefaultDocs
resourceatomThe transition log resource module. Scaffolded with mix ash_workflow.gen.transition_log and validated at compile time by AshWorkflow.Verifiers.ValidateTransitionLog.

workflow.transition_log.belongs_to_actor

belongs_to_actor name, destination

Configures actor capture on the transition log.

Arguments

NameTypeDefaultDocs
nameatomThe attribute on the transition log resource that stores the actor, e.g. :user.
destinationatomThe actor resource module, e.g. MyApp.Accounts.User.

Options

NameTypeDefaultDocs
skip_other_actors?booleanfalseWhether an actor that is not a destination struct is recorded as nil. Set it when actions also run as another actor, such as a plain-struct system actor, which has no primary key to record. When false, such an actor raises when its log row is written, and an actor of another resource has its primary key written to the foreign key. With same_actor? undo, a skipped actor is refused with :no_actor.

Introspection

Target: AshWorkflow.Entities.BelongsToActor

Introspection

Target: AshWorkflow.Entities.TransitionLog

workflow.undo

Enables undo for this workflow. Requires a transition_log, and at least one transition marked undoable?: true. See AshWorkflow.Entities.Undo.

Options

NameTypeDefaultDocs
withinanyHow long after a transition it may still be undone, e.g. {30, :minutes}. Measured against the undone row's occurred_at. Defaults to nil, which places no time limit on undo.
same_actor?booleanfalseIf true, only the actor recorded on a transition may undo it. Requires belongs_to_actor on the transition_log — without a recorded actor there is nothing to compare against, so this is rejected at compile time.
policyanyAn Ash policy check applied to the generated undo action. Accepts any {module, opts} tuple implementing Ash.Policy.Check. Step policies do not apply to undo: an undo spans two states, and which step it rewinds into is only known at runtime. Without this option the undo action falls under the extension's default-allow policy, like every other generated action.

Introspection

Target: AshWorkflow.Entities.Undo