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
- every
- 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
- every
- 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}, measured from field. Exactly one of fire_after or fire_at is required. | |
fire_at | atom | A 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. | |
field | atom | The 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. | |
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. | |
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.every
every name, interval \\ nilDeclares a recurring action that runs on an interval for as long as the workflow stays in this step.
Nested DSLs
Arguments
| Name | Type | Default | Docs |
|---|---|---|---|
name | atom | A unique name for this recurring action. | |
interval | any | Duration tuple, e.g. {1, :day} or {2, :hours}. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
action | atom | Action to run each time the interval elapses. Does not change state. | |
until | any | Stop 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? | boolean | false | Declares 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_interval | String.t | Oban 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_field | atom | The 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
| 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.Every
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. 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
| 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. |
Options
| Name | Type | Default | Docs |
|---|---|---|---|
skip_other_actors? | boolean | false | Whether 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
| 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