AshWorkflow.Charts draws a workflow from its DSL. The chart shows which steps are automatic, manual, wait states or terminal. It labels each timeout with its deadline, gives each conditional route its condition, and draws only the undo moves the generated undo action can make.

AshStateMachine.Charts draws the state machine that AshWorkflow generates, so it works on a workflow resource too. AshWorkflow.Charts reads the workflow DSL in its place, so it can also show what only the DSL knows: step kinds, deadlines, route conditions, step notes, and the moves that undo can rewind.

Drawing a workflow

AshWorkflow.Charts.mermaid_state_diagram(MyApp.Candidate)
AshWorkflow.Charts.render(MyApp.Candidate, :json, pretty: true)

From the command line:

mix ash_workflow.diagram MyApp.Candidate
mix ash_workflow.diagram --format json --output priv/diagrams

With no resource names, the task draws every resource that uses AshWorkflow in the domains listed under :ash_domains. Without --output, it prints the diagrams. With --output, it writes one file per resource.

What a chart shows

This is BasicWorkflow.DocumentApproval, from the repository's examples/basic directory. An automatic check runs first and falls to check_failed on an error. Then a reviewer approves or rejects, with a reminder after 3 days:

workflow do
  step :auto_check do
    action :run_document_checks
    on_success :review
    on_error :check_failed
  end

  step :review do
    transition :approve, to: :approved
    transition :reject, to: :rejected

    timeout :reminder, fire_after: {3, :days}, action: :send_review_reminder
  end

  step :approved, terminal: true
  step :rejected, terminal: true
  step :check_failed, terminal: true
end

Its chart:

stateDiagram-v2
    state "auto_check" as step_auto_check
    step_auto_check : ⚙️ automatic
    state "review" as step_review
    step_review : ✋ manual
    state "approved" as step_approved
    state "rejected" as step_rejected
    state "check_failed" as step_check_failed
    [*] --> step_auto_check
    step_auto_check --> step_review: ⚙ run_document_checks
    step_auto_check --> step_check_failed: ✖ on_error
    step_review --> step_approved: approve
    step_review --> step_rejected: reject
    note right of step_review
        ⏱ after 3 days: send_review_reminder
    end note
    step_approved --> [*]
    step_rejected --> [*]
    step_check_failed --> [*]
In the workflowIn the chart
Automatic step⚙️ automatic under the step name
Manual step✋ manual under the step name
Wait state, where a timeout is the only exit⏳ wait state under the step name
Initial stepAn edge from the start marker
Terminal stepAn edge to the end marker
on_success⚙ and the step's action
on_error✖ on_error
Timeout with transition_to⏱, the timeout's name and its deadline: auto_escalate after 4 hours, grace_expired 14 days after invoice_due_at or go_live at publish_at
Conditional routeThe transition or action, when, and the route's condition. The last on_success route without a when reads otherwise
Undo↶ undo, from the step undo leaves to the step it restores, with the window when within is set: ↶ undo within 1 hour
Step policypolicy: in the step's note, from the check's own describe/1
retry with more than one attemptretry: in the step's note, or after the action in a timeout's or every's note
Timeout that runs an action⏱, its deadline and its action in the step's note
every↻, its schedule and its action in the step's note

More examples

Each example is in the repository's examples/basic directory. The definition and the chart are both generated from that code, so they always agree.

Undo and a recurring action

BasicWorkflow.Incident marks escalate and resolve as undoable?: true. The undo edges come from AshWorkflow.Info.undoable_edges/1, so the chart shows the same moves that the undo action permits. The every is a note on investigating, because it runs an action and does not move the record.

workflow do
  transition_log BasicWorkflow.IncidentTransition

  undo do
    within {1, :hours}
  end

  step :triaging do
    action :classify_severity
    on_success :investigating
    on_error :triage_failed
  end

  step :investigating do
    transition :escalate, to: :escalated, undoable?: true
    transition :resolve, to: :resolved, undoable?: true

    every :status_update do
      interval {1, :hours}
      action :send_status_update
    end

    timeout :auto_escalate, fire_after: {4, :hours}, transition_to: :escalated
  end

  step :escalated do
    transition :resolve, to: :resolved, undoable?: true
  end

  step :resolved, terminal: true
  step :triage_failed, terminal: true
end
stateDiagram-v2
    state "triaging" as step_triaging
    step_triaging : ⚙️ automatic
    state "investigating" as step_investigating
    step_investigating : ✋ manual
    state "escalated" as step_escalated
    step_escalated : ✋ manual
    state "resolved" as step_resolved
    state "triage_failed" as step_triage_failed
    [*] --> step_triaging
    step_triaging --> step_investigating: ⚙ classify_severity
    step_triaging --> step_triage_failed: ✖ on_error
    step_investigating --> step_escalated: escalate
    step_investigating --> step_resolved: resolve
    step_investigating --> step_escalated: ⏱ auto_escalate after 4 hours
    step_escalated --> step_resolved: resolve
    step_escalated --> step_investigating: ↶ undo within 1 hour
    step_resolved --> step_investigating: ↶ undo within 1 hour
    step_resolved --> step_escalated: ↶ undo within 1 hour
    note right of step_investigating
        ↻ every 1 hour: send_status_update
    end note
    step_resolved --> [*]
    step_triage_failed --> [*]

A deadline from a date on the record

BasicWorkflow.Subscription ends its grace period 14 days after invoice_due_at, a date on the record, not 14 days after the step started. The chart names the field in the label. The final notice is a timeout that runs an action, so it is a note, not an edge.

workflow do
  step :active do
    transition :payment_failed, to: :grace_period, accept: [:invoice_due_at]
  end

  step :grace_period do
    transition :payment_received, to: :active
    transition :cancel, to: :cancelled

    every :dunning_email do
      interval {3, :days}
      action :send_dunning_email
    end

    timeout :grace_expired do
      fire_after {14, :days}
      field :invoice_due_at
      transition_to :suspended
    end
  end

  step :suspended do
    transition :payment_received, to: :active
    transition :cancel, to: :cancelled

    timeout :final_notice, fire_after: {7, :days}, action: :send_final_notice
    timeout :give_up, fire_after: {30, :days}, transition_to: :cancelled
  end

  step :cancelled, terminal: true
end
stateDiagram-v2
    state "active" as step_active
    step_active : ✋ manual
    state "grace_period" as step_grace_period
    step_grace_period : ✋ manual
    state "suspended" as step_suspended
    step_suspended : ✋ manual
    state "cancelled" as step_cancelled
    [*] --> step_active
    step_active --> step_grace_period: payment_failed
    step_grace_period --> step_active: payment_received
    step_grace_period --> step_cancelled: cancel
    step_grace_period --> step_suspended: ⏱ grace_expired 14 days after invoice_due_at
    step_suspended --> step_active: payment_received
    step_suspended --> step_cancelled: cancel
    step_suspended --> step_cancelled: ⏱ give_up after 30 days
    note right of step_grace_period
        ↻ every 3 days: send_dunning_email
    end note
    note right of step_suspended
        ⏱ after 7 days: send_final_notice
    end note
    step_cancelled --> [*]

A scheduled instant and a wait state

BasicWorkflow.ScheduledPost goes live at publish_at, an instant stored on the record. Nothing runs on entry to scheduled and no caller can move it, so the chart marks it as a wait state.

workflow do
  step :scheduled do
    timeout :go_live do
      fire_at :publish_at
      transition_to :live
    end
  end

  step :live do
    transition :archive, to: :archived
  end

  step :archived, terminal: true
end
stateDiagram-v2
    state "scheduled" as step_scheduled
    step_scheduled : ⏳ wait state
    state "live" as step_live
    step_live : ✋ manual
    state "archived" as step_archived
    [*] --> step_scheduled
    step_scheduled --> step_live: ⏱ go_live at publish_at
    step_live --> step_archived: archive
    step_archived --> [*]

Options

  • undo: false, or --no-undo, leaves out the undo edges.
  • notes: false, or --no-notes, leaves out the step notes.

AshWorkflow.Charts.render/3 gives every other option to the format. The :json format takes pretty: true.

Formats

FormatModuleFileUse it for
:mermaidAshWorkflow.Charts.Mermaid.mmdGitHub, Livebook and ExDoc show it without other tools
:jsonAshWorkflow.Charts.Json.jsonA client that draws the workflow itself
:dotAshWorkflow.Charts.Dot.dotA chart with colour and line styles, drawn by Graphviz

Every format draws from the same AshWorkflow.Charts.Graph, which AshWorkflow.Charts.Graph.build/2 makes from the DSL. So all formats show the same steps, edges and labels.

The JSON has a nodes list and an edges list, with string keys. This is the JSON for the document approval chart above:

{
  "edges": [
    {
      "condition": null,
      "fallback": false,
      "from": "auto_check",
      "kind": "on_success",
      "label": "run_document_checks",
      "name": "run_document_checks",
      "to": "review"
    },
    {
      "condition": null,
      "fallback": false,
      "from": "auto_check",
      "kind": "on_error",
      "label": "on_error",
      "name": "run_document_checks",
      "to": "check_failed"
    },
    {
      "condition": null,
      "fallback": false,
      "from": "review",
      "kind": "transition",
      "label": "approve",
      "name": "approve",
      "to": "approved"
    },
    {
      "condition": null,
      "fallback": false,
      "from": "review",
      "kind": "transition",
      "label": "reject",
      "name": "reject",
      "to": "rejected"
    }
  ],
  "nodes": [
    {
      "id": "auto_check",
      "initial": true,
      "kind": "automatic",
      "notes": []
    },
    {
      "id": "review",
      "initial": false,
      "kind": "manual",
      "notes": [
        {
          "action": "send_review_reminder",
          "kind": "timeout",
          "label": "after 3 days",
          "name": "reminder",
          "retry": null
        }
      ]
    },
    {
      "id": "approved",
      "initial": false,
      "kind": "terminal",
      "notes": []
    },
    {
      "id": "rejected",
      "initial": false,
      "kind": "terminal",
      "notes": []
    },
    {
      "id": "check_failed",
      "initial": false,
      "kind": "terminal",
      "notes": []
    }
  ],
  "resource": "BasicWorkflow.DocumentApproval"
}

A node kind is automatic, manual, wait_state or terminal. An edge kind is transition, on_success, on_error, timeout or undo. D3 has no layout for directed graphs, so pair it with a layout library such as dagre or ELK.js. AshWorkflow.Charts.Json.to_map/1 returns the same data as Elixir maps, for a LiveView that pushes it to a JavaScript hook.

DOT

DOT is the text format of Graphviz. Its dot command lays the diagram out and draws it as SVG. A DOT chart shows the same steps, edges and labels as the Mermaid chart, with colour and line styles.

GitHub and ExDoc do not draw DOT. So render/3 can run dot for you and give you the SVG:

AshWorkflow.Charts.render(BasicWorkflow.Incident, :dot, svg: true)
mix ash_workflow.diagram --format dot --svg --output priv/diagrams

svg: true runs dot from the PATH. This library does not download Graphviz, so install it with the package manager of your system, for example apt-get install graphviz or brew install graphviz. A release runs the dot of the machine that runs it, so install Graphviz there too. For a dot that is not on the PATH, set config :ash_workflow, :dot, path: "/opt/graphviz/bin/dot".

With no dot, svg: true raises ArgumentError. AshWorkflow.Charts.Dot.executable/0 gives the dot that svg: true runs.

This is the incident workflow from above, as Graphviz 15.1.1 drew it:

The incident workflow, drawn by Graphviz

What the colours mean

In the chartMeans
Blue boxAn automatic step
Amber boxA manual step: a person must act
Lavender box, dashed borderA wait state
Green box, double borderA terminal step that only transitions and on_success reach
Red box, double borderA terminal step that only on_error reaches
Amber box, double borderA terminal step that only timeouts reach
Grey box, double borderA terminal step with a mix of these, or with no edge
Thick blue lineon_success
Red lineon_error
Dashed amber lineA timeout
Dotted grey line, hollow arrowheadUndo
Plain lineA transition

A step has round corners, and a terminal step has square corners. The colour of a terminal step comes from the edges that reach it. So a rejected step that a person chooses is green, the same as approved. AshWorkflow.Charts.Palette.terminal_class/2 gives the class of one step.

Each edge kind other than a transition also has a symbol, so a reader who cannot tell the colours apart still knows the kind. By the WCAG 2 formula, the label text has a contrast above 8.6:1 on its fill or on the background, in both themes. Each line and each border has a contrast above 3.6:1 on the background.

Your own colours

A class holds the DOT attributes of one kind of step or edge. To change a class, pass classes:. A key is a Graphviz attribute name, such as fillcolor, penwidth or style. A step class can set a node attribute, and an edge class can set an edge attribute: see the "Used By" column of the Graphviz attribute reference. No class can set label, and the undo class cannot set dir, because the chart writes them itself.

Each class merges over its default, so this changes the fill of manual steps and keeps their border. It also draws the undo edges dashed:

AshWorkflow.Charts.render(BasicWorkflow.Incident, :dot,
  classes: [manual: [fillcolor: "#FFE4E6"], undo: [style: "dashed"]]
)

This list applies to both themes. To give each theme its own colours, put the classes under :light and :dark. Only the entry for the current theme applies:

AshWorkflow.Charts.render(BasicWorkflow.Incident, :dot,
  theme: :dark,
  classes: [
    light: [manual: [fillcolor: "#FFE4E6"]],
    dark: [manual: [fillcolor: "#881337"]]
  ]
)

To change the colours of every chart, put the same list under config :ash_workflow, :dot, classes: [...]. AshWorkflow.Charts.Dot.default_classes/1 lists the classes and their defaults.

Dark theme

theme: :dark, or --theme dark, selects a dark background, with deep fills, light borders and light text. An SVG has one theme, so a page that follows the reader's theme needs one chart for each theme. The mix task writes a dark chart to a file such as BasicWorkflow.Incident-dark.svg, so both charts can go in the same directory:

mix ash_workflow.diagram --format dot --svg --output priv/diagrams
mix ash_workflow.diagram --format dot --svg --theme dark --output priv/diagrams

To draw every chart dark, set config :ash_workflow, :dot, theme: :dark. A light fill under the dark theme's light text is hard to read. So put a fill for one theme under that theme's key in classes:, as in "Your own colours".

The incident workflow, drawn by Graphviz with the dark theme

Layout

dot puts the steps in ranks, from the initial step down to the end steps. An undo edge points back up to the earlier step, next to the edge that it undoes. The file writes it from the earlier step, with dir="back", so the steps keep their forward order. The arrowhead is at the tail, so the undo class sets arrowtail, not arrowhead.

direction: :right turns the chart on its side. That gives a long, thin chart, so keep it for a short chain of steps.

The DOT source

Without svg: true, render/3 gives the DOT source. This is the source for the incident workflow:

digraph "BasicWorkflow.Incident" {
  graph [rankdir=TB, bgcolor="#FFFFFF", fontname="Helvetica", pad=0.3, nodesep=0.5, ranksep=0.6];
  node [shape=box, fontname="Helvetica", fontsize=14, margin="0.2,0.1", penwidth=2];
  edge [fontname="Helvetica", fontsize=12, fontcolor="#0A0F25", color="#0A0F25", penwidth=1.5];
  "start" [shape=circle, label="", width=0.2, style=filled, fillcolor="#0A0F25", color="#0A0F25"];
  "step_triaging" [label="triaging\n⚙️ automatic", fillcolor="#E8F0FE", color="#1A56DB", fontcolor="#0A0F25", style="rounded,filled"];
  "step_investigating" [label="investigating\n✋ manual\n—\n↻ every 1 hour: send_status_update", fillcolor="#FFF4E5", color="#B7791F", fontcolor="#0A0F25", style="rounded,filled"];
  "step_escalated" [label="escalated\n✋ manual", fillcolor="#FFF4E5", color="#B7791F", fontcolor="#0A0F25", style="rounded,filled"];
  "step_resolved" [label="resolved", fillcolor="#E6F4EA", color="#1E7B34", fontcolor="#0A0F25", style="filled", peripheries=2];
  "step_triage_failed" [label="triage_failed", fillcolor="#FDE8E8", color="#C81E1E", fontcolor="#0A0F25", style="filled", peripheries=2];
  "start" -> "step_triaging";
  "step_triaging" -> "step_investigating" [label="⚙ classify_severity", color="#1A56DB", penwidth=3];
  "step_triaging" -> "step_triage_failed" [label="✖ on_error", color="#C81E1E"];
  "step_investigating" -> "step_escalated" [label="escalate"];
  "step_investigating" -> "step_resolved" [label="resolve"];
  "step_investigating" -> "step_escalated" [label="⏱ auto_escalate after 4 hours", color="#B45309", style="dashed"];
  "step_escalated" -> "step_resolved" [label="resolve"];
  "step_investigating" -> "step_escalated" [label="↶ undo within 1 hour", dir="back", color="#6B7280", style="dotted", arrowtail="onormal"];
  "step_investigating" -> "step_resolved" [label="↶ undo within 1 hour", dir="back", color="#6B7280", style="dotted", arrowtail="onormal"];
  "step_escalated" -> "step_resolved" [label="↶ undo within 1 hour", dir="back", color="#6B7280", style="dotted", arrowtail="onormal"];
}

Your own format

A format is a module that implements AshWorkflow.Charts.Backend. It receives the graph, with every label already written, and returns iodata. Pass the module in place of a format name:

defmodule MyApp.PlainTextChart do
  @behaviour AshWorkflow.Charts.Backend

  @impl true
  def render(graph, _opts) do
    for edge <- graph.edges, do: "#{edge.from} -> #{edge.to}: #{edge.label}\n"
  end

  @impl true
  def file_extension, do: "txt"
end

AshWorkflow.Charts.render(MyApp.Candidate, MyApp.PlainTextChart)

mix ash_workflow.diagram --format accepts only the names that AshWorkflow.Charts.formats/0 returns.

Limits

  • A Mermaid state diagram cannot draw a dashed edge, so the edge kind is a symbol in the label. A DOT chart also shows the edge kind with a colour and a line style.
  • Steps are not grouped, so a chart of a large workflow can be wide.