Skip to main content

docs/workflow_authoring.livemd

# Workflow Authoring

This Livebook focuses on the authoring surface: workflow DSL structure,
normalized workflow specs, dependency joins, input mappings, execution, and
graph inspection.

It uses in-memory ETS journal storage so you can run it without a host app.

```elixir
Mix.install([
  {:squidie, "~> 0.1.2"}
])
```

## Runtime Setup

The runtime options below keep all durable facts inside this Livebook session.

```elixir
storage = {Jido.Storage.ETS, table: :squidie_workflow_authoring_livebook}

defmodule SquidieAuthoringLivebook.Repo do
end

Application.put_env(:squidie, :repo, SquidieAuthoringLivebook.Repo)
Application.put_env(:squidie, :queue, "workflow-authoring")

opts = [
  runtime: :journal,
  journal_storage: storage,
  queue: "workflow-authoring"
]

defmodule SquidieAuthoringLivebook.Output do
  def step(step), do: Map.take(step, [:name, :module, :opts])

  def attempt(attempt) do
    Map.take(attempt, [
      :step,
      :status,
      :attempt_number,
      :visible_at,
      :applied?,
      :wakeup_emitted?
    ])
  end

  def node(node), do: Map.take(node, [:id, :status, :current?])

  def edge(edge) do
    Map.take(edge, [:id, :from, :to, :type, :status, :selected?, :pending?])
  end
end
```

## Define Step Modules

Prefer native `Squidie.Step` modules for application steps. The workflow DSL
stays readable, while each step keeps its own input and output contract.

```elixir
defmodule SquidieAuthoringLivebook.ScoutWestRoad do
  use Squidie.Step,
    name: :scout_west_road,
    description: "Scouts the western road",
    input_schema: [
      bearer: [type: :string, required: true],
      west_mark: [type: :string, required: true],
      mountain_mark: [type: :string, required: true]
    ],
    output_schema: [
      west_road: [type: :map, required: true]
    ]

  @impl Squidie.Step
  def run(
        %{bearer: bearer, west_mark: west_mark, mountain_mark: mountain_mark},
        %Squidie.Step.Context{}
      ) do
    {:ok,
     %{
       west_road: %{
         bearer: bearer,
         map_mark: west_mark,
         mountain_mark: mountain_mark,
         distance_leagues: 12,
         danger: "watched"
       }
     }}
  end
end

defmodule SquidieAuthoringLivebook.ScoutMountainPass do
  use Squidie.Step,
    name: :scout_mountain_pass,
    description: "Scouts the mountain pass",
    input_schema: [
      bearer: [type: :string, required: true],
      mark: [type: :string, required: true]
    ],
    output_schema: [
      mountain_pass: [type: :map, required: true]
    ]

  @impl Squidie.Step
  def run(%{bearer: bearer, mark: mark}, %Squidie.Step.Context{}) do
    {:ok,
     %{
       mountain_pass: %{
         bearer: bearer,
         map_mark: mark,
         snow_depth: 3,
         danger: "storms"
       }
     }}
  end
end

defmodule SquidieAuthoringLivebook.ChooseRoute do
  use Squidie.Step,
    name: :choose_route,
    description: "Chooses a route from joined scouting reports",
    input_schema: [
      bearer: [type: :string, required: true],
      west_danger: [type: :string, required: true],
      west_distance_leagues: [type: :integer, required: true],
      mountain_danger: [type: :string, required: true],
      mountain_snow_depth: [type: :integer, required: true]
    ],
    output_schema: [
      route_plan: [type: :map, required: true]
    ]

  @impl Squidie.Step
  def run(input, %Squidie.Step.Context{}) do
    {:ok,
     %{
       route_plan: %{
         bearer: input.bearer,
         route: "moria",
         reason:
           "the western road is #{input.west_danger} and the pass has #{input.mountain_snow_depth} feet of snow",
         compared: %{
           west_distance_leagues: input.west_distance_leagues,
           west_danger: input.west_danger,
           mountain_danger: input.mountain_danger,
           mountain_snow_depth: input.mountain_snow_depth
         }
       }
     }}
  end
end
```

## Define A Dependency Workflow

This workflow has one root step, one dependent scout, and one join step:

- `:scout_west_road` consumes the bearer and nested map marks from the payload
- `:scout_mountain_pass` waits for the western scout and consumes data from its
  output
- `:choose_route` waits for both scouts and maps nested context into the
  input shape it needs

```elixir
defmodule SquidieAuthoringLivebook.RoutePlanningWorkflow do
  use Squidie.Workflow

  workflow do
    trigger :plan_errand do
      manual()

      payload do
        field :bearer, :string, default: "Frodo"
        field :map_marks, :map,
          default: %{west_road: "watched", mountain_pass: "snow"}
      end
    end

    step :scout_west_road, SquidieAuthoringLivebook.ScoutWestRoad,
      input: [
        bearer: [:bearer],
        west_mark: [:map_marks, :west_road],
        mountain_mark: [:map_marks, :mountain_pass]
      ]

    step :scout_mountain_pass, SquidieAuthoringLivebook.ScoutMountainPass,
      after: [:scout_west_road],
      input: [
        bearer: [:west_road, :bearer],
        mark: [:west_road, :mountain_mark]
      ]

    step :choose_route, SquidieAuthoringLivebook.ChooseRoute,
      after: [:scout_west_road, :scout_mountain_pass],
      input: [
        bearer: [:west_road, :bearer],
        west_danger: [:west_road, :danger],
        west_distance_leagues: [:west_road, :distance_leagues],
        mountain_danger: [:mountain_pass, :danger],
        mountain_snow_depth: [:mountain_pass, :snow_depth]
      ]
  end
end
```

## Inspect The Normalized Spec

The DSL compiles into a normalized workflow spec. Tooling can inspect this shape
without parsing source code.

```elixir
{:ok, spec} =
  Squidie.Workflow.to_spec(SquidieAuthoringLivebook.RoutePlanningWorkflow)

%{
  workflow: spec.workflow,
  triggers: Enum.map(spec.triggers, &Map.take(&1, [:name, :type, :payload])),
  payload: spec.payload,
  entry_steps: spec.entry_steps,
  steps: Enum.map(spec.steps, &SquidieAuthoringLivebook.Output.step/1),
  transitions: spec.transitions
}
```

Notice that dependency workflows do not need success transitions. `entry_steps`
shows the root step, and later work is declared with `after: [...]`.

Visual-editor clients can use the JSON-safe editor projection when they need to
inspect or preview a draft workflow without starting it:

```elixir
editor_map =
  spec
  |> Squidie.Workflow.EditorSpec.to_map()
  |> Jason.encode!()
  |> Jason.decode!()

:ok = Squidie.Workflow.EditorSpec.validate_map(editor_map)
{:ok, draft_graph} = Squidie.Workflow.EditorSpec.preview_graph(editor_map)
{:ok, draft_diff} = Squidie.Workflow.EditorSpec.diff(spec, editor_map)

Map.take(draft_graph, ["source", "status", "workflow"])
```

If the editor map uses runtime-authored top-level action keys, pass the host
registry to `validate_map/2` and `preview_graph/2` before accepting the draft
graph. Use `diff/2` or `diff/3` when a service needs to inspect what changed
between a source spec and an edited draft.

## Start A Run

Manual triggers can start through `Squidie.start/3` when the workflow has
one trigger.

```elixir
{:ok, started} =
  Squidie.start(
    SquidieAuthoringLivebook.RoutePlanningWorkflow,
    %{
      bearer: "Frodo",
      map_marks: %{west_road: "watched", mountain_pass: "snow"}
    },
    opts
  )

%{
  run_id: started.run_id,
  status: started.status,
  reason: started.reason,
  visible_attempts: Enum.map(started.visible_attempts, &SquidieAuthoringLivebook.Output.attempt/1),
  planned_runnable_keys: started.planned_runnable_keys
}
```

The first snapshot has visible work for the root step. Later dependency steps
become visible only after their prerequisites complete.

## Drain Visible Work

Each call to `Squidie.execute_next/1` claims one visible attempt, executes the
step, records the result, and returns the updated snapshot.

```elixir
worker_opts = Keyword.put(opts, :owner_id, "authoring-livebook-worker")

{:ok, first_scout} = Squidie.execute_next(worker_opts)
{:ok, second_scout} = Squidie.execute_next(worker_opts)
{:ok, completed} = Squidie.execute_next(worker_opts)
{:ok, :none} = Squidie.execute_next(worker_opts)

%{
  first_step: List.last(first_scout.applied_runnable_keys),
  second_step: List.last(second_scout.applied_runnable_keys),
  final_status: completed.status,
  final_reason: completed.reason,
  context: completed.context,
  attempts: Enum.map(completed.attempts, &SquidieAuthoringLivebook.Output.attempt/1)
}
```

The join step receives only the mapped values declared in the workflow. The
full run context remains durable and inspectable.

## Inspect And Explain

Inspection answers what durable state exists. Explanation answers why the run is
in its current state and which action would make progress.

```elixir
{:ok, inspected} = Squidie.inspect_run(started.run_id, opts)
{:ok, explanation} = Squidie.explain_run(started.run_id, opts)

%{
  inspected_status: inspected.status,
  inspected_context: inspected.context,
  explanation_reason: explanation.reason,
  explanation_summary: explanation.summary,
  explanation_evidence: explanation.evidence
}
```

## Inspect The Graph

Graph inspection turns the same durable state into node and edge output for
host UIs.

```elixir
{:ok, graph} = Squidie.inspect_run_graph(started.run_id, opts)
payload = Squidie.Runs.GraphInspection.to_map(graph)

%{
  status: payload.status,
  current_node_ids: payload.current_node_ids,
  nodes: Enum.map(payload.nodes, &SquidieAuthoringLivebook.Output.node/1),
  edges: Enum.map(payload.edges, &SquidieAuthoringLivebook.Output.edge/1),
  child_links: payload.child_links
}
```

For the complete node, edge, and child-link contract, see
[Graph inspection contract](graph_inspection.md).

## Try Changing The Workflow

Useful edits to try:

- add a retry policy to `:scout_mountain_pass`
- add a second join step that depends on `:choose_route`
- remove one input mapping path and observe the structured validation failure
- switch to a transition-based workflow when the shape is a straight line

Read next:

- [Workflow authoring](workflow_authoring.md)
- [Getting started](getting_started.md)
- [Reference workflows](reference_workflows.md)
- [Host app integration](host_app_integration.md)