Current section
Files
Jump to
Current section
Files
lib/sw4rm/workflow.ex
defmodule Sw4rm.Workflow do
@moduledoc """
Local DAG-based workflow orchestration.
Manages multi-step, multi-agent execution graphs with dependencies,
parallel execution, and status tracking.
"""
use GenServer
defmodule Node do
@moduledoc "A single node in a workflow DAG."
@type t :: %__MODULE__{}
defstruct [
:node_id,
:agent_id,
:task_type,
:params,
trigger: :automatic,
status: :pending,
result: nil
]
end
defmodule Edge do
@moduledoc "A dependency edge between two nodes."
@type t :: %__MODULE__{}
defstruct [:from_node, :to_node, :condition]
end
# -- Client API --
@doc "Start the workflow engine."
def start_link(opts \\ []) do
name = Keyword.get(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc "Submit a workflow DAG."
def submit(server, workflow_def), do: GenServer.call(server, {:submit, workflow_def})
@doc "Get workflow status."
def status(server, workflow_id), do: GenServer.call(server, {:status, workflow_id})
@doc "Update a node's status."
def update_node(server, workflow_id, node_id, status, result \\ nil) do
GenServer.call(server, {:update_node, workflow_id, node_id, status, result})
end
@doc "Get nodes ready for execution (all dependencies met)."
def ready_nodes(server, workflow_id), do: GenServer.call(server, {:ready_nodes, workflow_id})
@doc "Cancel a workflow."
def cancel(server, workflow_id), do: GenServer.call(server, {:cancel, workflow_id})
@doc "List all workflows."
def list(server), do: GenServer.call(server, :list)
# -- Callbacks --
@impl true
def init(_opts), do: {:ok, %{workflows: %{}}}
@impl true
def handle_call({:submit, def_}, _from, s) do
wf_id = Map.get(def_, :workflow_id, Sw4rm.Envelope.generate_uuid())
nodes =
def_
|> Map.get(:nodes, [])
|> Enum.into(%{}, fn n ->
node = struct(Node, n)
{node.node_id, node}
end)
edges =
def_
|> Map.get(:edges, [])
|> Enum.map(&struct(Edge, &1))
# Validate no cycles (simple DFS)
case validate_dag(nodes, edges) do
:ok ->
workflow = %{
workflow_id: wf_id,
name: Map.get(def_, :name),
nodes: nodes,
edges: edges,
status: :running,
created_at: System.system_time(:second),
metadata: Map.get(def_, :metadata, %{})
}
{:reply, {:ok, wf_id}, %{s | workflows: Map.put(s.workflows, wf_id, workflow)}}
{:error, _} = err ->
{:reply, err, s}
end
end
def handle_call({:status, wf_id}, _from, s) do
case Map.get(s.workflows, wf_id) do
nil -> {:reply, {:error, :not_found}, s}
wf -> {:reply, {:ok, workflow_summary(wf)}, s}
end
end
def handle_call({:update_node, wf_id, node_id, status, result}, _from, s) do
case Map.get(s.workflows, wf_id) do
nil ->
{:reply, {:error, :not_found}, s}
wf ->
case Map.get(wf.nodes, node_id) do
nil ->
{:reply, {:error, :node_not_found}, s}
node ->
updated_node = %{node | status: status, result: result}
nodes = Map.put(wf.nodes, node_id, updated_node)
wf_status = compute_workflow_status(nodes)
wf = %{wf | nodes: nodes, status: wf_status}
{:reply, :ok, %{s | workflows: Map.put(s.workflows, wf_id, wf)}}
end
end
end
def handle_call({:ready_nodes, wf_id}, _from, s) do
case Map.get(s.workflows, wf_id) do
nil ->
{:reply, {:error, :not_found}, s}
wf ->
ready =
wf.nodes
|> Map.values()
|> Enum.filter(fn node ->
node.status == :pending and all_deps_complete?(node.node_id, wf.edges, wf.nodes)
end)
{:reply, {:ok, ready}, s}
end
end
def handle_call({:cancel, wf_id}, _from, s) do
case Map.get(s.workflows, wf_id) do
nil ->
{:reply, {:error, :not_found}, s}
wf ->
{:reply, :ok, %{s | workflows: Map.put(s.workflows, wf_id, %{wf | status: :cancelled})}}
end
end
def handle_call(:list, _from, s) do
summaries = Enum.map(s.workflows, fn {_id, wf} -> workflow_summary(wf) end)
{:reply, summaries, s}
end
# -- Helpers --
defp all_deps_complete?(node_id, edges, nodes) do
edges
|> Enum.filter(&(&1.to_node == node_id))
|> Enum.all?(fn edge ->
case Map.get(nodes, edge.from_node) do
nil -> true
dep -> dep.status == :completed
end
end)
end
defp compute_workflow_status(nodes) do
statuses = nodes |> Map.values() |> Enum.map(& &1.status)
cond do
Enum.any?(statuses, &(&1 == :failed)) -> :failed
Enum.all?(statuses, &(&1 == :completed)) -> :completed
true -> :running
end
end
defp workflow_summary(wf) do
nodes = Map.values(wf.nodes)
%{
workflow_id: wf.workflow_id,
name: wf.name,
status: wf.status,
total_nodes: length(nodes),
completed: Enum.count(nodes, &(&1.status == :completed)),
pending: Enum.count(nodes, &(&1.status == :pending)),
failed: Enum.count(nodes, &(&1.status == :failed)),
running: Enum.count(nodes, &(&1.status == :running))
}
end
defp validate_dag(nodes, edges) do
# Simple cycle detection via topological sort (Kahn's algorithm)
node_ids = Map.keys(nodes)
in_degree =
Enum.reduce(node_ids, %{}, fn id, acc -> Map.put(acc, id, 0) end)
|> then(fn deg ->
Enum.reduce(edges, deg, fn edge, acc ->
Map.update(acc, edge.to_node, 1, &(&1 + 1))
end)
end)
queue = Enum.filter(node_ids, fn id -> Map.get(in_degree, id, 0) == 0 end)
do_topo_sort(queue, edges, in_degree, 0, length(node_ids))
end
defp do_topo_sort([], _edges, _in_degree, count, total) do
if count == total, do: :ok, else: {:error, :cycle_detected}
end
defp do_topo_sort([node | rest], edges, in_degree, count, total) do
{new_queue, new_in_degree} =
edges
|> Enum.filter(&(&1.from_node == node))
|> Enum.reduce({rest, in_degree}, fn edge, {q, deg} ->
new_deg = Map.update!(deg, edge.to_node, &(&1 - 1))
if Map.get(new_deg, edge.to_node) == 0 do
{q ++ [edge.to_node], new_deg}
else
{q, new_deg}
end
end)
do_topo_sort(new_queue, edges, new_in_degree, count + 1, total)
end
end