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
- step
- transition
- route
- timeout
- retry
- on_success
- retry
- transition
- transition_log
- belongs_to_actor
- undo
Options
| Name | Type | Default | Docs |
|---|---|---|---|
state_attribute | atom | The 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. | |
scheduler | any | The 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. | |
queue | atom | :workflow | The Oban queue to use for all generated triggers. Defaults to :workflow. |
check_interval | String.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? | boolean | true | Whether 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 nameDeclares a step in the workflow. Each step becomes a state in the generated state machine.
Nested DSLs
- transition
- route
- timeout
- retry
- on_success
- retry
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
name | atom | The name of the step. Becomes a state in the generated state machine. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
action | atom | The action to run for automatic steps. Must reference a user-defined update action on the resource. | |
initial | boolean | false | If 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. |
terminal | boolean | false | Asserts 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_error | atom | The step to transition to on failure. Optional, for automatic steps. | |
policy | any | An Ash policy check to apply to all transitions in this step. Accepts any {module, opts} tuple implementing Ash.Policy.Check. |
workflow.step.transition
transition nameDeclares a named transition from this manual step to another step.
Nested DSLs
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
name | atom | The name of the transition. Becomes an Ash update action. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
to | atom | The step to transition to. Omit when using conditional routes. | |
accept | list(atom) | [] | List of resource attributes the generated transition action should accept as input. |
undoable? | boolean | false | If 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 toA conditional target for a transition. Evaluated at runtime.
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
to | atom | The step to transition to if the condition matches. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
when | any | An 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 nameDeclares a time-based action or forced transition if the workflow stays in this step too long.
Nested DSLs
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
name | atom | A unique name for this timeout. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
fire_after | any | Duration tuple, e.g. {3, :days} or {2, :hours}. | |
field | atom | :state_entered_at | The datetime attribute or calculation to measure fire_after against. Defaults to :state_entered_at. |
action | atom | Action to run when the timeout fires. Does not change state. | |
transition_to | atom | Step to force-transition to when the timeout fires. | |
repeat | boolean | false | If true, re-fire the timeout on the same interval. |
self_scheduled? | boolean | false | Declares 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_interval | String.t | Oban 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
| Name | Type | Default | Docs |
|---|---|---|---|
max_attempts | pos_integer | 1 | How 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. |
backoff | any | :exponential | :exponential | How 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.on_success
on_success toDeclares 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.
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
to | atom | The step to transition to. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
when | any | An 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
| Name | Type | Default | Docs |
|---|---|---|---|
max_attempts | pos_integer | 1 | How 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. |
backoff | any | :exponential | :exponential | How 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 resourceDeclares an opt-in transition log resource that records one row per
workflow event. See AshWorkflow.Entities.TransitionLog.
Nested DSLs
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
resource | atom | The 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, destinationConfigures actor capture on the transition log.
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
name | atom | The attribute on the transition log resource that stores the actor, e.g. :user. | |
destination | atom | The actor resource module, e.g. MyApp.Accounts.User. |
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
| Name | Type | Default | Docs |
|---|---|---|---|
within | any | How 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? | boolean | false | If 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. |
policy | any | An 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