A visual automation engine in Phoenix LiveView

· · 36 views
Table of Contents

Every RSS reader eventually grows the same feature request: "when a post from this feed mentions that word, do something." Star it. Push me a notification. Fire a webhook at my homelab. You can bolt each of these on as a one-off setting, or you can admit what the users are actually asking for — a small automation engine — and build it properly.

SlashFeed took the second path. The result is a trigger → condition → action graph that users assemble on a visual canvas, stored as plain rows, and evaluated by a worker whenever a feed event happens. This post is about how that graph is modelled, queried, and edited, and the tradeoffs I made along the way.

The conceptual model: a graph, not a rule

The tempting design is a flat rule: one trigger, one condition, one action, three columns on a table. It falls apart the moment someone wants two actions, or a condition branch. So the model is a directed graph. Two tables carry it:

create table(:automation_nodes, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :automation_id, references(:automations, ...), null: false
  add :kind, :string, null: false   # "trigger" | "condition" | "action"
  add :type, :string, null: false   # e.g. "trigger.new_entry"
  add :config, :map
  add :position_x, :integer, default: 0
  add :position_y, :integer, default: 0
  add :label, :string
  timestamps(type: :utc_datetime)
end

An AutomationNode is one card on the canvas. An AutomationEdge is a directed connection between two of them, carrying a branch label ("default", or "true"/"false" for condition splits). The edge table has a unique_index(:automation_edges, [:from_node_id, :to_node_id, :branch]) so you cannot draw the same wire twice, and every foreign key is on_delete: :delete_all — delete a node and its edges vanish with it at the database level. The context leans on that rather than reimplementing cascade in Elixir:

def delete_node(node_id) do
  {count, _} =
    from(n in AutomationNode, where: n.id == ^node_id)
    |> Repo.delete_all()

  {:ok, count}
end

kind and type do different jobs. kind is the coarse category the UI groups by; type is the specific behaviour the evaluator dispatches on. Keeping the type as a namespaced string ("trigger.entry_matches_keyword") rather than an enum means adding a new node is a data change plus an evaluator clause — no migration.

Triggers, conditions, actions

The palette catalog in the editor is the source of truth for what exists. Triggers span feed events (trigger.new_entry, trigger.entry_from_feed, trigger.entry_from_category, trigger.entry_matches_keyword, trigger.feed_error), scheduled and gamification events (trigger.scheduled, trigger.streak_milestone), and user-lifecycle events (trigger.entry_read, trigger.entry_starred, trigger.digest_sent). Actions cover the obvious surface: action.in_app_notification, action.web_push, action.email, action.webhook, action.star_entry, action.apply_tag, action.ai_summarise, action.send_to_channel. Conditions are pure predicates — condition.contains_keyword, condition.reading_time_gt, condition.is_weekend, condition.domain_matches — chained between a trigger and its actions.

Each node's config map holds its parameters, edited through a per-type form. A keyword trigger, for instance, renders exactly the fields it needs:

defp render_node_config(type, assigns)
     when type in ["trigger.entry_matches_keyword", "condition.contains_keyword"] do
  ~H"""
  <input type="text" name="keyword"
    value={Map.get(@selected_node.config, "keyword", "")} ... />
  ...
  """
end

The engine interface: filter cheaply, evaluate precisely

When a feed event fires, the engine worker needs the candidate automations fast. The context does not try to evaluate anything in SQL — it returns any enabled automation whose graph contains a trigger node of a relevant type, and lets the graph evaluator do the precise work per automation:

def list_active_automations_for_feed(_feed_id) do
  trigger_types = [
    "trigger.new_entry", "trigger.entry_from_feed",
    "trigger.entry_from_category", "trigger.entry_matches_keyword", ...
  ]

  automation_ids_query =
    from(n in AutomationNode,
      where: n.kind == "trigger" and n.type in ^trigger_types,
      select: n.automation_id
    )

  from(a in Automation,
    where: a.enabled == true and a.id in subquery(automation_ids_query),
    preload: [:user, :nodes]
  )
  |> Repo.all()
end

Notice the feed_id argument is deliberately ignored. A trigger.new_entry automation should fire for any feed, so filtering on feed_id in this query would wrongly exclude it. Feed-level and category-level checks belong to the per-automation evaluator, which has each node's config in hand. This is the classic coarse-filter/fine-filter split: the query trims thousands of automations to a handful with a cheap indexed subquery, and the evaluator spends real CPU only on those. Sibling queries specialise the same shape: list_active_automations_by_trigger_type/1 for the scheduler (scheduled and streak triggers), list_active_automations_for_feed_error/1 for terminal fetch failures, and list_active_automations_for_user_by_trigger/2 for user-lifecycle events routed over a separate PubSub topic.

The audit log

Every run writes one immutable AutomationExecution row — status, duration_ms, an output map, and an error_message. There is no update path; log_execution/1 only inserts. That is deliberate: an audit log you can edit is not an audit log. The executions tab in the editor pages through them newest-first, and the admin panel aggregates them for admin_stats/0 (total, active, executions and errors in the last 24h) and admin_top_action_types/2, which joins executions back to their action nodes to rank what people actually automate.

Related but distinct: feed-error handling reuses the board's ensure_ticket/3 idempotency helper so a flapping feed does not spawn a card per failed poll. It keys on source_type plus source_ref_id and returns the existing card if one is present:

def ensure_ticket(source_type, source_ref_id, attrs)
    when is_binary(source_type) and is_binary(source_ref_id) do
  case Repo.get_by(Ticket, source_type: source_type, source_ref_id: source_ref_id) do
    %Ticket{} = existing -> {:ok, :existing, existing}
    nil -> do_create_system_ticket(source_type, source_ref_id, attrs)
  end
end

Idempotency at the write boundary is worth far more than trying to dedupe events upstream.

The canvas: SVG edges, HTML nodes, one JS hook

The editor is n8n-style: a palette on the left, an infinite-feeling dot-grid canvas on the right. Nodes are absolutely-positioned HTML divs; edges are SVG <path> elements in an overlay. I keep them in separate layers on purpose — nodes want rich HTML (icons, config summaries, delete buttons), edges want cheap vector paths and arrow markers.

Edges are cubic beziers computed server-side. The control-point offset scales with horizontal distance but never drops below 80px, so short connections still curve pleasantly instead of collapsing into a straight stub:

defp bezier_path(from_node, to_node) do
  x1 = from_node.position_x + @node_w
  y1 = from_node.position_y + div(@node_h, 2)
  x2 = to_node.position_x
  y2 = to_node.position_y + div(@node_h, 2)
  dx = max(trunc(abs(x2 - x1) * 0.5), 80)
  "M#{x1},#{y1} C#{x1 + dx},#{y1} #{x2 - dx},#{y2} #{x2},#{y2}"
end

Dragging is the one place where a round-trip per mouse-move would be absurd, so a colocated JS hook (CanvasDrag) owns the gesture. It moves the node div locally and recomputes affected edge paths in JS using the same bezier formula, then pushes a single node_moved event on pointer-up to persist the final position. The hook has two hard-won comments worth calling out: it never calls setPointerCapture() (it redirects the follow-up click and breaks phx-click selection) and it uses a 4px movement threshold so a jittery click is not mistaken for a drag. Getting drag and click to coexist on the same element was most of the work.

One nice consequence of storing positions is auto_layout_nodes/2: automations created programmatically arrive with every node at (0, 0), so on first open the editor topologically sorts the graph and lays it out left-to-right. After that, user positions win.

What I would flag to the next person

The type strings are load-bearing across three layers — palette, engine queries, evaluator — and nothing but tests keeps them in sync. That is the price of avoiding an enum, and I would pay it again; adding trigger.entry_has_image was a one-line palette entry plus one query addition. The bigger lurking cost is that the bezier math is duplicated in Elixir and JavaScript. They agree today because the constants (@node_w, @node_h) are mirrored by hand. If I touch node geometry, that is the first thing that will drift.