Mutations and Diffs
This tutorial edits an Agent's routes, members, and Plugins as data with Jido.Agent.mutate/2, reads the resulting Jido.Agent.Diff, and then returns the same edits from a Turn.
Install Jido
Mix.install([
{:jido, github: "mikehostetler/new_jido", branch: "main"}
])
Set up an Agent
The Agent starts with one route. The other modules are the parts the mutations add.
defmodule MutationGuide.Refund do
use Jido.Action,
name: "mutation_guide_refund",
schema: Jido.Joi.object(%{amount_cents: Jido.Joi.integer()})
@impl Jido.Action
def run(%{amount_cents: amount}, %{agent_state: state}) do
{:ok, %{state | refunded_cents: state.refunded_cents + amount}}
end
end
defmodule MutationGuide.FraudReviewer do
use Jido.Agent, name: "fraud_reviewer"
routes do
route "refund.review" do
action _input, schema: Jido.Joi.object(%{}), context: context do
{:ok, context.agent_state}
end
end
end
end
defmodule MutationGuide.RefundLog do
use Jido.Plugin
@impl true
def state_spec(_opts), do: {:refund_log, Jido.Joi.integer() |> Jido.Joi.default(0)}
end
defmodule MutationGuide.RefundDesk do
use Jido.Agent, name: "refund_desk"
state do
schema Jido.Joi.object(%{refunded_cents: Jido.Joi.integer() |> Jido.Joi.default(0)})
end
routes do
signal_source "/billing/refunds"
route "refund.issue", MutationGuide.Refund, as: :issue, defaults: %{amount_cents: 100}
end
end
desk = MutationGuide.RefundDesk.instantiate!(id: "desk-1")
Enum.map(desk.routes, & &1.path)
The cell returns ["refund.issue"].
Apply a mutation
Jido.Agent.mutate/2 takes an Agent and an ordered list of mutation Directives. It returns a validated candidate and the Diff. The source does not change. No process is involved.
alias Jido.Agent.Directive
{:ok, candidate, diff} =
Jido.Agent.mutate(desk, [
Directive.route_add("refund.partial", MutationGuide.Refund, as: :partial),
Directive.route_update("refund.issue", %{defaults: %{amount_cents: 500}})
])
{
Enum.map(desk.routes, & &1.path),
Enum.map(candidate.routes, & &1.path),
Enum.map(diff.routes.added, & &1.path),
Enum.map(diff.routes.updated, & &1.after.defaults)
}
The cell returns:
{["refund.issue"], ["refund.issue", "refund.partial"], ["refund.partial"], [%{amount_cents: 500}]}
Instances and definitions both mutate. A definition has no state, so Plugin state edits do not apply to it.
Read a Diff
A Jido.Agent.Diff describes the final difference between the two values.
| Field | Content |
|---|---|
fields |
Changed top-level fields, as %{field => %{before: _, after: _}} |
state |
nil, or %{before: _, after: _} |
routes, agents, connections, plugins |
%{added: [], removed: [], updated: [], order: %{before: [], after: []}} |
Jido matches routes by path, members by name, connections by {from, to, path}, and Plugins by provider. Each updated entry is %{before: _, after: _}.
Jido.Agent.Diff.changed?/2 checks one field, and Jido.Agent.Diff.empty?/1 checks them all. A reorder counts as a change. An edit that a later edit cancels does not.
alias Jido.Agent.Directive
{:ok, _same, cancelled} =
Jido.Agent.mutate(desk, [
Directive.route_add("refund.partial", MutationGuide.Refund),
Directive.route_remove("refund.partial")
])
{Jido.Agent.Diff.changed?(diff, :routes), Jido.Agent.Diff.changed?(diff, :plugins),
Jido.Agent.Diff.empty?(cancelled)}
The cell returns {true, false, true}.
Know what fails
A batch is atomic. One failure returns an error, with the index of the failing operation in path, and no candidate.
alias Jido.Agent.Directive
{:error, duplicate} =
Jido.Agent.mutate(desk, [
Directive.route_add("refund.partial", MutationGuide.Refund),
Directive.route_add("refund.partial", MutationGuide.Refund)
])
{:error, missing} = Jido.Agent.mutate(desk, [Directive.route_remove("refund.void")])
{:error, order} = Jido.Agent.mutate(candidate, [Directive.routes_reorder(["refund.partial"])])
{:error, not_a_mutation} = Jido.Agent.mutate(desk, [Directive.server_stop()])
Enum.map([duplicate, missing, order, not_a_mutation], &{&1.code, &1.path})
The cell returns:
[
already_exists: [:directives, 1],
not_found: [:directives, 0],
invalid_route_order: [:directives, 0],
invalid_mutation: [:directives, 0]
]
Jido checks references only in the final candidate. The order of a batch can therefore add a forward route before the member it targets. A batch that leaves a route pointing at nothing fails.
alias Jido.Agent.Directive
forward = %{kind: :forward, target: %{kind: :agent, name: :fraud}}
{:ok, with_member, member_diff} =
Jido.Agent.mutate(desk, [
Directive.route_add("refund.review", forward),
Directive.agent_add(:fraud, MutationGuide.FraudReviewer)
])
{:error, dangling} = Jido.Agent.mutate(with_member, [Directive.agent_remove(:fraud)])
{Enum.map(member_diff.agents.added, & &1.name), dangling.code}
The cell returns {[:fraud], :unknown_forward_target}.
Add and remove a Plugin
A Plugin edit also manages the Plugin's state field on an instance. plugin_add/3 initializes it from the Plugin's schema, or from the :initial option.
alias Jido.Agent.Directive
{:ok, logged, plugin_diff} =
Jido.Agent.mutate(desk, [Directive.plugin_add(MutationGuide.RefundLog, [], initial: 3)])
{logged.state, Enum.map(plugin_diff.plugins.added, & &1.provider), plugin_diff.state.before}
The cell returns {%{refunded_cents: 0, refund_log: 3}, [MutationGuide.RefundLog], %{refunded_cents: 0}}.
Removal discards data, so you must say so.
alias Jido.Agent.Directive
{:error, %{code: :plugin_state_discard_required}} =
Jido.Agent.mutate(logged, [Directive.plugin_remove(MutationGuide.RefundLog)])
{:ok, removed, _diff} =
Jido.Agent.mutate(logged, [Directive.plugin_remove(MutationGuide.RefundLog, state: :discard)])
removed.state
The cell returns %{refunded_cents: 0}.
Jido.Agent.Directive.plugin_configure/3 changes a Plugin's options. When the options change the state key, it needs a :migrate option of {module, function, args}. That function receives the old value first and returns {:ok, value} or {:error, reason}.
Mutate from a Turn
An Action can return mutation Directives. The Turn applies them to the same candidate as the new state, and the batch still contains them.
alias Jido.Agent.Directive
defmodule MutationGuide.OpenRefunds do
use Jido.Action, name: "mutation_guide_open_refunds"
@impl Jido.Action
def run(_input, %{agent_state: state}) do
{:ok, %{state | refunded_cents: 1},
[Jido.Agent.Directive.route_add("refund.partial", MutationGuide.Refund)]}
end
end
{:ok, opener, _diff} =
Jido.Agent.mutate(desk, [Directive.route_add("refund.open", MutationGuide.OpenRefunds)])
open = Jido.Signal.new!("refund.open", %{}, source: "/billing/admin")
{:ok, opened, [%Directive.RouteAdd{path: "refund.partial"}]} = Jido.Agent.cmd(opener, open)
{opened.state.refunded_cents, Enum.map(opened.routes, & &1.path)}
The cell returns {1, ["refund.issue", "refund.open", "refund.partial"]}.
An AgentServer computes the Diff for a Turn like this and applies it to the running system. Members start or stop, and changed Plugins reconfigure. See Change Live Composition and Live Reconfiguration.
Mutation Directives
Every constructor is on Jido.Agent.Directive.
| Target | Constructors | Update fields or options |
|---|---|---|
| Routes | route_add/3, route_replace/3 |
:as, :defaults, :state, :reply |
| Routes | route_update/2 |
:handler, :as, :defaults, :state, :reply |
| Routes | route_remove/1, routes_reorder/1, routes_set/1 |
Reorder needs every current path |
| Members | agent_add/3, agent_replace/3 |
:count, :initial_state |
| Members | agent_update/2 |
:definition, :count, :initial_state |
| Members | agent_remove/1 |
none |
| Connections | connection_add/2, connection_replace/2 |
:from, :to, :delivery |
| Connections | connection_update/2 |
:from, :to, :changes, where changes holds :delivery |
| Connections | connection_remove/2 |
:from, :to |
| Topology | topology_set/1 |
Replaces all members and connections |
| Plugins | plugin_add/3 |
:initial |
| Plugins | plugin_configure/3 |
:migrate |
| Plugins | plugin_remove/2 |
:state, which accepts :discard |
A route handler can be an Action or Flow module, or a handler Map such as %{kind: :forward, target: %{kind: :parent}}.
Next steps
- Declare Agent Topology covers members and connections.
- State and Directives covers other Directives in a Turn.