+
+ Services
+
+
+
+ No services configured.
+
+
+
- All Workspaces
-
+
+
{service.name}
+
{service.status}
+
+
+
+ Start
+
+
+ Stop
+
+
+
+
-
-
Access scope
-
- Organization role
-
- {@resolve_workspace_scope.organization_role || :none}
-
-
-
-
Workspace role
-
- {@resolve_workspace_scope.workspace_role || :none}
-
+
+ Checkpoints
+
+ <.form
+ for={@checkpoint_form}
+ id="workspace-checkpoint-form"
+ phx-submit="create_checkpoint"
+ class="mt-3 space-y-3"
+ >
+ <.input
+ field={@checkpoint_form[:comment]}
+ type="text"
+ label="Comment"
+ placeholder="before major changes"
+ />
+
+ Create Checkpoint
+
+
+
+
+
+ No checkpoints available.
+
+
+ {checkpoint.id}
+ {checkpoint.comment}
+
-
+
- <% end %>
+
diff --git a/lib/fizz_web/plugs/require_workspace_scope.ex b/lib/fizz_web/plugs/require_project_scope.ex
similarity index 59%
rename from lib/fizz_web/plugs/require_workspace_scope.ex
rename to lib/fizz_web/plugs/require_project_scope.ex
index ec618e3..176960d 100644
--- a/lib/fizz_web/plugs/require_workspace_scope.ex
+++ b/lib/fizz_web/plugs/require_project_scope.ex
@@ -1,6 +1,6 @@
-defmodule FizzWeb.Plugs.RequireWorkspaceScope do
+defmodule FizzWeb.Plugs.RequireProjectScope do
@moduledoc """
- Resolves and assigns a workspace-aware scope for API requests.
+ Resolves and assigns a project-aware scope for API requests.
"""
import Plug.Conn
@@ -11,19 +11,19 @@ defmodule FizzWeb.Plugs.RequireWorkspaceScope do
def init(opts), do: opts
def call(conn, _opts) do
- workspace_id = conn.params["workspace_id"]
+ project_id = conn.params["project_id"]
current_scope = conn.assigns[:current_scope]
- case Accounts.build_scope_for_workspace(current_scope, workspace_id) do
- {:ok, resolve_workspace_scope} ->
+ case Accounts.build_scope_for_project(current_scope, project_id) do
+ {:ok, resolved_project_scope} ->
conn
- |> assign(:resolve_workspace_scope, resolve_workspace_scope)
- |> assign(:workspace_id, workspace_id)
+ |> assign(:resolve_project_scope, resolved_project_scope)
+ |> assign(:project_id, project_id)
- {:error, :workspace_not_found} ->
+ {:error, :project_not_found} ->
conn
|> put_status(:not_found)
- |> json(%{error: "workspace_not_found"})
+ |> json(%{error: "project_not_found"})
|> halt()
{:error, :forbidden} ->
diff --git a/lib/fizz_web/plugs/webhook_handler.ex b/lib/fizz_web/plugs/webhook_handler.ex
deleted file mode 100644
index 8ab804d..0000000
--- a/lib/fizz_web/plugs/webhook_handler.ex
+++ /dev/null
@@ -1,322 +0,0 @@
-defmodule FizzWeb.Plugs.WebhookHandler do
- @moduledoc """
- Plug to handle incoming webhook triggers.
-
- Routes requests to `/api/hooks/:workflow_id`.
- """
- import Plug.Conn
- require Logger
-
- alias Fizz.Repo
- alias Fizz.Workflows.Workflow
- alias Fizz.Executions
- alias Fizz.Workers.ExecutionWorker
- alias Fizz.Accounts.Scope
- alias Fizz.Collaboration.EditSession.Server, as: EditSessionServer
-
- def init(opts), do: opts
-
- def call(conn, _opts) do
- path_segments = conn.params["path"] || []
- is_test = conn.request_path =~ ~r{/hook-test/}
- path = Enum.join(path_segments, "/")
- scope = conn.assigns[:current_scope]
-
- if is_test and not Scope.authenticated?(scope) do
- send_error(conn, 401, "API key required")
- else
- alias Fizz.Runtime.Triggers.Registry
-
- # 1. Try lookup by path first (new way)
- # Registry lookup is much faster as it bypasses DB search
- {workflow, config} =
- case Registry.lookup_webhook(path, conn.method) do
- {:ok, %{workflow_id: id, config: config}} ->
- {Repo.get(Workflow, id), config}
-
- :error ->
- # 2. Try lookup by ID if first segment is a UUID (legacy way)
- workflow =
- case List.first(path_segments) do
- nil ->
- nil
-
- id ->
- case Ecto.UUID.cast(id) do
- {:ok, uuid} -> Repo.get(Workflow, uuid)
- _ -> nil
- end
- end
-
- # If it's a test route, we might need a DB lookup if registry doesn't have it
- workflow =
- if is_nil(workflow) and is_test do
- Fizz.Workflows.get_workflow_by_webhook_draft_path(path, conn.method)
- else
- workflow
- end
-
- {workflow, nil}
- end
-
- case workflow do
- nil ->
- send_error(conn, 404, "Workflow not found")
-
- workflow ->
- cond do
- # Test mode: triggered from /hook-test/
- # We implicitly trust the token/path for test webhooks since it comes from the draft config
- is_test ->
- workflow = Repo.preload(workflow, :draft)
-
- if Scope.can_edit_workflow?(scope, workflow) do
- case EditSessionServer.test_webhook_enabled?(workflow.id, path, conn.method) do
- {:ok, _webhook_test} ->
- config =
- config ||
- webhook_config_for(
- workflow.draft.steps,
- path,
- conn.method
- )
-
- handle_trigger(conn, workflow, scope, config, :preview, true)
-
- {:error, _reason} ->
- send_error(conn, 404, "Test webhook is not enabled")
- end
- else
- send_error(conn, 403, "Access denied")
- end
-
- # Production mode checks
- workflow.status != :active ->
- send_error(conn, 403, "Workflow is not active")
-
- is_nil(workflow.published_version_id) ->
- send_error(conn, 400, "Workflow is not published")
-
- true ->
- if Scope.can_view_workflow?(scope, workflow) do
- # If we didn't get config from registry (e.g. legacy ID lookup), fetch it now
- config =
- config ||
- (
- workflow = Repo.preload(workflow, :published_version)
-
- webhook_config_for(
- workflow.published_version.steps,
- path,
- conn.method
- )
- )
-
- handle_trigger(conn, workflow, scope, config, :production)
- else
- send_error(conn, 404, "Workflow not found")
- end
- end
- end
- end
- end
-
- defp webhook_config_for(steps, path, method) do
- normalized_path = normalize_path(path)
- normalized_method = normalize_method(method)
-
- step =
- Enum.find(steps || [], fn step ->
- step.type_id == "webhook_trigger" &&
- normalize_path(Map.get(step.config, "path") || Map.get(step.config, :path) || step.id) ==
- normalized_path &&
- method_matches?(
- Map.get(step.config, "http_method") || Map.get(step.config, :http_method),
- normalized_method
- )
- end)
-
- if step, do: step.config || %{}, else: %{}
- end
-
- defp handle_trigger(conn, workflow, scope, config, execution_type, test_webhook? \\ false) do
- response_mode =
- Map.get(config, "response_mode") || Map.get(config, :response_mode) || "immediate"
-
- # 1. Extract payload
- payload = %{
- "body" => conn.body_params,
- "params" => conn.params,
- "headers" => Enum.into(conn.req_headers, %{}),
- "method" => conn.method
- }
-
- # 2. Create execution record base attributes
- attrs = %{
- workflow_id: workflow.id,
- execution_type: execution_type,
- trigger: %{
- "type" => "webhook",
- "data" => payload
- },
- metadata: %{
- "source" => "webhook",
- "remote_ip" => to_string(:inet.ntoa(conn.remote_ip))
- }
- }
-
- case response_mode do
- "on_respond_node" ->
- # Pass handler PID as ephemeral option
- case Executions.create_execution(scope, attrs) do
- {:ok, execution} ->
- _ = maybe_notify_webhook_test_execution(test_webhook?, workflow.id, execution.id)
- _ = maybe_disable_test_webhook(test_webhook?, workflow.id)
-
- # Start execution directly (do not use run_sync as it blocks)
- # Pass webhook_handler_pid to Server via Supervisor
- case Fizz.Runtime.Execution.Supervisor.start_execution(execution.id,
- webhook_handler_pid: self()
- ) do
- {:ok, pid} ->
- monitor_ref = Process.monitor(pid)
- wait_for_response(conn, monitor_ref, execution.id)
-
- {:error, {:already_started, pid}} ->
- monitor_ref = Process.monitor(pid)
- wait_for_response(conn, monitor_ref, execution.id)
-
- {:error, reason} ->
- handle_creation_error(conn, reason)
- end
-
- {:error, reason} ->
- handle_creation_error(conn, reason)
- end
-
- "on_completion" ->
- case Executions.create_execution(scope, attrs) do
- {:ok, execution} ->
- _ = maybe_notify_webhook_test_execution(test_webhook?, workflow.id, execution.id)
- _ = maybe_disable_test_webhook(test_webhook?, workflow.id)
- ExecutionWorker.run_sync(execution.id)
- # Re-fetch to get output
- execution = Repo.get!(Fizz.Executions.Execution, execution.id)
-
- conn
- |> put_resp_content_type("application/json")
- |> send_resp(200, Jason.encode!(execution.output || %{}))
-
- {:error, reason} ->
- handle_creation_error(conn, reason)
- end
-
- _ ->
- # "immediate" or default
- case Executions.create_execution(scope, attrs) do
- {:ok, execution} ->
- _ = maybe_notify_webhook_test_execution(test_webhook?, workflow.id, execution.id)
- _ = maybe_disable_test_webhook(test_webhook?, workflow.id)
- ExecutionWorker.enqueue(execution.id)
-
- conn
- |> put_resp_content_type("application/json")
- |> send_resp(
- 202,
- Jason.encode!(%{
- status: "accepted",
- execution_id: execution.id
- })
- )
-
- {:error, reason} ->
- handle_creation_error(conn, reason)
- end
- end
- end
-
- defp maybe_disable_test_webhook(true, workflow_id) do
- _ = EditSessionServer.disable_test_webhook(workflow_id)
- :ok
- end
-
- defp maybe_disable_test_webhook(false, _workflow_id), do: :ok
-
- defp maybe_notify_webhook_test_execution(true, workflow_id, execution_id) do
- _ = EditSessionServer.notify_webhook_test_execution(workflow_id, execution_id)
- :ok
- end
-
- defp maybe_notify_webhook_test_execution(false, _workflow_id, _execution_id), do: :ok
-
- defp normalize_path(nil), do: nil
-
- defp normalize_path(path) when is_binary(path) do
- path
- |> String.trim()
- |> String.trim_leading("/")
- |> String.trim_trailing("/")
- |> case do
- "" -> nil
- trimmed -> trimmed
- end
- end
-
- defp normalize_method(nil), do: "POST"
-
- defp normalize_method(method) when is_binary(method) do
- method
- |> String.trim()
- |> case do
- "" -> "POST"
- trimmed -> String.upcase(trimmed)
- end
- end
-
- defp method_matches?(configured_method, incoming_method) do
- normalized_config = normalize_method(configured_method)
- normalized_incoming = normalize_method(incoming_method)
-
- normalized_config == "ANY" or normalized_config == normalized_incoming
- end
-
- defp handle_creation_error(conn, :access_denied) do
- send_error(conn, 403, "Access denied")
- end
-
- defp handle_creation_error(conn, :workflow_not_published) do
- send_error(conn, 400, "Workflow is not published")
- end
-
- defp handle_creation_error(conn, reason) do
- Logger.error("Failed to create execution for webhook: #{inspect(reason)}")
- send_error(conn, 500, "Internal error")
- end
-
- defp wait_for_response(conn, monitor_ref, _execution_id) do
- receive do
- {:webhook_response, data} ->
- # We got the response we wanted!
- Process.demonitor(monitor_ref, [:flush])
-
- conn
- |> put_resp_content_type(Map.get(data, :content_type, "application/json"))
- |> send_resp(Map.get(data, :status, 200), Jason.encode!(Map.get(data, :body, %{})))
-
- {:DOWN, ^monitor_ref, :process, _pid, _reason} ->
- # Process died before sending response
- send_error(conn, 502, "Workflow completed without sending a response")
- after
- 30_000 ->
- Process.demonitor(monitor_ref, [:flush])
- send_error(conn, 504, "Workflow timed out waiting for response")
- end
- end
-
- defp send_error(conn, status, message) do
- conn
- |> put_resp_content_type("application/json")
- |> send_resp(status, Jason.encode!(%{errors: %{detail: message}}))
- |> halt()
- end
-end
diff --git a/lib/fizz_web/router.ex b/lib/fizz_web/router.ex
index 02ab0c3..a7dc904 100644
--- a/lib/fizz_web/router.ex
+++ b/lib/fizz_web/router.ex
@@ -29,6 +29,12 @@ defmodule FizzWeb.Router do
post "/workos", WorkOSWebhookController, :create
end
+ scope "/triggers", FizzWeb.Triggers do
+ pipe_through :api
+
+ post "/wh/:webhook_path", WebhookController, :receive
+ end
+
# Other scopes may use custom stacks.
# scope "/api", FizzWeb do
# pipe_through :api
@@ -51,15 +57,6 @@ defmodule FizzWeb.Router do
end
end
- ## Authentication routes
- scope "/api", FizzWeb do
- pipe_through :api
-
- get "/workflows/:id/contract", WorkflowContractController, :show
- match :*, "/hooks/*path", Plugs.WebhookHandler, :handle
- match :*, "/hook-test/*path", Plugs.WebhookHandler, :handle
- end
-
scope "/", FizzWeb do
pipe_through [:browser]
@@ -75,20 +72,29 @@ defmodule FizzWeb.Router do
live_session :require_authenticated_user,
on_mount: [{FizzWeb.UserAuth, :require_authenticated}] do
live "/settings/", UserManagementLive, :index
- live "/workspaces", WorkspacesLive.Index, :index
- live "/workspaces/:workspace_id", WorkspacesLive.Show, :show
- live "/workspaces/:workspace_id/sprites", SpritesLive.Index, :index
- live "/workspaces/:workspace_id/sprites/:sprite_id", SpritesLive.Show, :show
+ live "/projects", ProjectsLive.Index, :index
+ live "/projects/:project_id", ProjectsLive.Show, :show
+ live "/projects/:project_id/workflows", WorkflowsLive.Index, :index
+ live "/projects/:project_id/workflows/:definition_id", WorkflowsLive.Show, :show
- live "/workspaces/:workspace_id/workflows", WorkflowLive.Index, :index
- live "/workspaces/:workspace_id/workflows/:id", WorkflowLive.Show, :show
+ live "/projects/:project_id/workflows/:definition_id/runs/:run_id",
+ WorkflowsLive.RunShow,
+ :show
- live "/workspaces/:workspace_id/workflows/:workflow_id/execution/:execution_id",
- ExecutionLive.Show,
+ live "/projects/:project_id/workflows/:definition_id/edit",
+ WorkflowsLive.Editor,
+ :edit
+
+ live "/projects/:project_id/workflows/:definition_id/edit/revisions",
+ WorkflowsLive.Revisions,
:show
- live "/workspaces/:workspace_id/workflows/:id/edit", WorkflowLive.Edit, :edit
- live "/workspaces/:workspace_id/workflows/:id/revisions", WorkflowLive.Revision, :index
+ live "/projects/:project_id/workflows/:definition_id/edit/runs/:run_id",
+ WorkflowsLive.Editor,
+ :debug
+
+ live "/projects/:project_id/workspaces", WorkspacesLive.Index, :index
+ live "/projects/:project_id/workspaces/:workspace_id", WorkspacesLive.Show, :show
end
end
end
diff --git a/lib/fizz_web/telemetry.ex b/lib/fizz_web/telemetry.ex
index fcb888a..9b3ef5f 100644
--- a/lib/fizz_web/telemetry.ex
+++ b/lib/fizz_web/telemetry.ex
@@ -51,11 +51,11 @@ defmodule FizzWeb.Telemetry do
tags: [:event],
unit: {:native, :millisecond}
),
- counter("fizz.sprites.sprite.created.count"),
- counter("fizz.sprites.sprite.deleted.count"),
- counter("fizz.sprites.job.queued.count"),
- counter("fizz.sprites.job.state.count"),
- counter("fizz.sprites.console.closed.count"),
+ counter("fizz.workspaces.workspace.created.count"),
+ counter("fizz.workspaces.workspace.deleted.count"),
+ counter("fizz.workspaces.job.queued.count"),
+ counter("fizz.workspaces.job.state.count"),
+ counter("fizz.workspaces.console.closed.count"),
# Database Metrics
summary("fizz.repo.query.total_time",
diff --git a/lib/fizz_web/user_socket.ex b/lib/fizz_web/user_socket.ex
index 65c8e08..a177873 100644
--- a/lib/fizz_web/user_socket.ex
+++ b/lib/fizz_web/user_socket.ex
@@ -4,8 +4,8 @@ defmodule FizzWeb.UserSocket do
alias Fizz.Accounts
alias Fizz.Accounts.Scope
- channel "sprite_console:*", FizzWeb.SpriteConsoleChannel
- channel "sprite_logs:*", FizzWeb.SpriteLogsChannel
+ channel "workspace_console:*", FizzWeb.WorkspaceConsoleChannel
+ channel "workspace_logs:*", FizzWeb.WorkspaceLogsChannel
@impl true
def connect(_params, socket, %{session: session}) do
diff --git a/lib/mix/tasks/fizz.gen.integration_manifest.ex b/lib/mix/tasks/fizz.gen.integration_manifest.ex
new file mode 100644
index 0000000..86be457
--- /dev/null
+++ b/lib/mix/tasks/fizz.gen.integration_manifest.ex
@@ -0,0 +1,51 @@
+defmodule Mix.Tasks.Fizz.Gen.IntegrationManifest do
+ @moduledoc """
+ Generates the built-in integration manifest module.
+
+ mix fizz.gen.integration_manifest
+ mix fizz.gen.integration_manifest --output /tmp/manifest.ex
+ """
+
+ use Mix.Task
+
+ @shortdoc "Generates Fizz.Integrations.Catalog.Manifest"
+ @default_output "lib/fizz/integrations/catalog/manifest.ex"
+ @source_output "lib/fizz/integrations/catalog/manifest.ex"
+
+ @impl true
+ def run(args) do
+ Mix.Task.run("app.config")
+
+ {opts, _argv, _invalid} =
+ OptionParser.parse(args, strict: [check: :boolean, output: :string], aliases: [o: :output])
+
+ output_path = Keyword.get(opts, :output, @default_output)
+ content = render_manifest()
+
+ if Keyword.get(opts, :check, false) do
+ check_manifest!(output_path, content)
+ else
+ File.mkdir_p!(Path.dirname(output_path))
+ File.write!(output_path, content)
+
+ Mix.shell().info("Generated #{output_path}")
+ end
+ end
+
+ defp render_manifest do
+ File.read!(@source_output)
+ end
+
+ defp check_manifest!(output_path, content) do
+ case File.read(output_path) do
+ {:ok, ^content} ->
+ Mix.shell().info("#{output_path} is up to date")
+
+ {:ok, _stale_content} ->
+ Mix.raise("#{output_path} is stale; run mix fizz.gen.integration_manifest")
+
+ {:error, reason} ->
+ Mix.raise("could not read #{output_path}: #{:file.format_error(reason)}")
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index 0522374..76a1267 100644
--- a/mix.exs
+++ b/mix.exs
@@ -61,7 +61,7 @@ defmodule Fizz.MixProject do
depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
- {:req_llm, "~> 1.5"},
+ {:req_llm, "~> 1.12"},
{:workos, "~> 1.1"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
@@ -70,12 +70,16 @@ defmodule Fizz.MixProject do
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:nvir, "~> 0.16"},
- {:runic, git: "https://github.com/galaddirie/runic.git", branch: "main"},
+ {:runic, path: "vendor/runic"},
+ {:libgraph, "0.16.1-mg.1", hex: :multigraph, override: true},
{:jsv, "~> 0.13.1"},
{:oban, "~> 2.20"},
+ {:crontab, "~> 1.1"},
+ {:exqlite, "~> 0.25"},
{:sprites, git: "https://github.com/superfly/sprites-ex.git"},
{:flame, "~> 0.5.3"},
- {:solid, "~> 1.2"}
+ {:solid, "~> 1.2"},
+ {:gen_stage, "~> 1.3"}
]
end
diff --git a/mix.lock b/mix.lock
index cae5f3b..da44956 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,25 +1,30 @@
%{
"abnf_parsec": {:hex, :abnf_parsec, "2.1.0", "c4e88d5d089f1698297c0daced12be1fb404e6e577ecf261313ebba5477941f9", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e0ed6290c7cc7e5020c006d1003520390c9bdd20f7c3f776bd49bfe3c5cd362a"},
- "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
+ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
+ "crontab": {:hex, :crontab, "1.2.0", "503611820257939d5d0fd272eb2b454f48a470435a809479ddc2c40bb515495c", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ebd7ef4d831e1b20fa4700f0de0284a04cac4347e813337978e25b4cc5cc2207"},
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
- "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
+ "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
- "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
- "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
+ "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
+ "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
+ "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
"ex_aws_auth": {:hex, :ex_aws_auth, "1.3.1", "3963992d6f7cb251b53573603c3615cec70c3f4d86199fdb865ff440295ef7a4", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: true]}], "hexpm", "025793aa08fa419aabdb652db60edbdb2e12346bd447988a1bb5854c4dd64903"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
+ "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
- "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
+ "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"flame": {:hex, :flame, "0.5.3", "af9a16c902dac100b4f6b91e64c10ef95f9785ef7b638fcfcebabbec682990e6", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, ">= 0.0.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8b0b42026c1df2eeffdd8860b77af8437ab7aa72b5e648f8e92d8198f3b7fa1e"},
+ "flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"},
+ "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"},
@@ -27,19 +32,19 @@
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
- "igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+ "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"jsonpatch": {:hex, :jsonpatch, "2.3.1", "49c380f458debbd2bc6e256daeab1081dc89624288f3d77ea83952229388d316", [:make, :mix], [], "hexpm", "06c3e4fff3574cc54d335041f6322fe1b72756e396dd472615ce350d3dd5e758"},
"jsv": {:hex, :jsv, "0.13.1", "837cf3a689239dc309b88db32fd406b2700118d60cab62fc2eeb0fb7a35b2c97", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.1", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:poison, ">= 3.0.0 and < 7.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:texture, "~> 0.3", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "0f95471f0b658f43127f4c845bc2f193f5be934d8f451e010f483fdd5af16cb3"},
"kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"},
- "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
- "libgraph": {:git, "https://github.com/zblanco/libgraph.git", "416f99e002cebe82f5640014bc2c79dcf318dcd1", [branch: "zw/multigraph-indexes"]},
- "live_vue": {:hex, :live_vue, "1.0.0", "86a5f4bafc6796043227669547024dd9dd76034c565d6cf9556cbbd47e578e51", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:jsonpatch, "~> 2.3", [hex: :jsonpatch, repo: "hexpm", optional: false]}, {:lazy_html, ">= 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_vite, "~> 0.4", [hex: :phoenix_vite, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9dad6558db9a3b5ff8413d9e6d300ea329ff80e7e70e2ba2f4186b9aa7a40754"},
- "llm_db": {:hex, :llm_db, "2026.2.7", "cd53e500549836fe641ef28d6e373db7b2393939534bd341436e566cee131721", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "69f0ef51997af59e436abe3933d35ef8e6614ef49e0e02f0341334ee8ef0956a"},
+ "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
+ "libgraph": {:hex, :multigraph, "0.16.1-mg.1", "70ab1c1df14794fafc89a821d6cc6f16d250ae4c6d26b3dad76e395a33214763", [:mix], [], "hexpm", "629dd5233cc421d20ea01e0ee101ac0552f9c92d524f6788297415379d35644a"},
+ "live_vue": {:hex, :live_vue, "1.0.1", "f2e446bfeac888f55888d8ca30eb37a0fea8a797719875b1e81315a1bbce6d48", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:jsonpatch, "~> 2.3", [hex: :jsonpatch, repo: "hexpm", optional: false]}, {:lazy_html, ">= 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:nodejs, "~> 3.1", [hex: :nodejs, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_vite, "~> 0.4", [hex: :phoenix_vite, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "321b812d27cbe6086abac1db5d6e666b133b09f9ad23fe6aabf803e30f12cf6f"},
+ "llm_db": {:hex, :llm_db, "2026.5.1", "f73e5cae42cd9a283cf974dff5c32a5ea3c8e22bada2997760b233264ad4df6e", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "d318792b24ac9bc5da5ba722f24ea2bf13bc406ceed20a10612245585137c334"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
- "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
+ "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
@@ -48,44 +53,45 @@
"oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
- "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
+ "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
- "phoenix_vite": {:hex, :phoenix_vite, "0.4.0", "3d3d6da839ded7f68babc20aa8bfe11ea016cab72399f48c5d18eef0e1bbe6dc", [:mix], [{:bun, ">= 1.5.1 and < 2.0.0-0", [hex: :bun, repo: "hexpm", optional: true]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "778f3e683faa7efb862d7a0de03def2872c1c1a6f27ca0f0d9bd25741f7e3b33"},
- "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
+ "phoenix_vite": {:hex, :phoenix_vite, "0.4.2", "550157fc847ec6b4f07f9d2942357f6a2e0cab882b372e73aaf26cd4db924112", [:mix], [{:bun, ">= 1.5.1 and < 2.0.0-0", [hex: :bun, repo: "hexpm", optional: true]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b84910c4d506f1997425c9055e46e7195efd9885a41f911a134d0cfaee29666c"},
+ "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
- "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
- "req_llm": {:hex, :req_llm, "1.5.1", "bdb059985cc8f954eba54b8668c83b3e736be65fa6dccc894520c7cf6fc024e6", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.3", [hex: :ex_aws_auth, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, "~> 2026.1", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 0.2", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "b914724d5f36e004a3dfde7591f8b37b937586c277e98f550ccf7b5c981f3d53"},
- "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
- "runic": {:git, "https://github.com/galaddirie/runic.git", "b3afcb6fd569583a5fe936a98e30070a00e5f74a", [branch: "main"]},
- "server_sent_events": {:hex, :server_sent_events, "0.2.1", "f83b34f01241302a8bf451efc8dde3a36c533d5715463c31c653f3db8695f636", [:mix], [], "hexpm", "c8099ce4f9acd610eb7c8e0f89dba7d5d1c13300ea9884b0bd8662401d3cf96f"},
+ "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
+ "req_llm": {:hex, :req_llm, "1.12.0", "8bdaa32dd055f2df026a778d969a35b9a6e3cbef2a345160f5452d01c6c177e4", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.3", [hex: :ex_aws_auth, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, "~> 2026.5.0", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 1.0.0", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "18bad9ea4f9d5f19ef25ff8df7cf49768fa5dd3da49093b707e9539249f42b8d"},
+ "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
+ "runic": {:hex, :runic, "0.1.0-alpha.4", "e056b44b65bf9b3bcc815f7fef6e144b880081600e7e5d9aa939b537f73d59ba", [:mix], [{:flow, "~> 1.2", [hex: :flow, repo: "hexpm", optional: false]}, {:gen_stage, "~> 1.2", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16.1-mg.1", [hex: :multigraph, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6.1", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "c1eec581d8e2051dd4d51afa3d6a82b08a38aa73b63c61e3feb6d041a2b3bedb"},
+ "server_sent_events": {:hex, :server_sent_events, "1.0.0", "e82089ac6b93ebd3c0562fd728492bbe4b5140678ffc891abfa8cce717c2c1ff", [:mix], [], "hexpm", "7899caea3e27850549f671fc9e6c53d55a8e6a78474f6b9623820aae6bb41ec7"},
"solid": {:hex, :solid, "1.2.2", "615d3fb75e12b575d99976ca49f242b1e603f98489d30bf8634b5ab47d85e33f", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "410d0af6c0cdfd9d58ed2d22158f4fb0733a49f7b59b8e3bdb26f05919ae38ae"},
- "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
- "spitfire": {:hex, :spitfire, "0.3.5", "6e5a775256fec72d8a4f4bb19eb8b91ad64b6f64dc51b1c8b9689e78b16c6e8b", [:mix], [], "hexpm", "7ffcb11de2f6544868148f8fc996482040eb329a990e1624795e53598934a680"},
- "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
+ "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
+ "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
+ "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
"sprites": {:git, "https://github.com/superfly/sprites-ex.git", "80916380b78331a85cf621229d27b7236e027804", []},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
- "swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
- "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+ "swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"},
+ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"texture": {:hex, :texture, "0.3.2", "ca68fc2804ce05ffe33cded85d69b5ebadb0828233227accfe3c574e34fd4e3f", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}], "hexpm", "43bb1069d9cf4309ed6f0ff65ade787a76f986b821ab29d1c96b5b5102cb769c"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
- "tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
+ "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
- "uniq": {:hex, :uniq, "0.6.2", "51846518c037134c08bc5b773468007b155e543d53c8b39bafe95b0af487e406", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "95aa2a41ea331ef0a52d8ed12d3e730ef9af9dbc30f40646e6af334fbd7bc0fc"},
+ "uniq": {:hex, :uniq, "0.6.3", "68acff834cce1817b52928ef346662735c5413a4fec9c3b0d4a9126de5b2b489", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "2b2a900d0a20f3a55d3de0bc8150495e4a71255734dfb23889991bda5aca6c7d"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
- "workos": {:hex, :workos, "1.1.4", "186ad8ab82e99da32c026f85e336247fa4cda1df8e7874dcfe594a5c72668ffa", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "49566d9902f4a7cc2b50d037a03092414bccdcdf428b455b322c74fb61f35306"},
- "zoi": {:hex, :zoi, "0.17.0", "d0b5d5fb6284e075197e4b24787237d103bb3c654bb2cee35949b4beae7fdca0", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "91e59376f0f3ce7d2a57ac80078a0b4b3cf3789508b8189c30074f108276809b"},
+ "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"},
+ "workos": {:hex, :workos, "1.2.1", "ca04c186fe49f1eb2e91439ba95f9b5bb54b3e2c189fc943e0c7c00eb2205316", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "09c99601c6b2dbfe730d57f1ef2817d47d90a0864cbfa8a531dc82fd9a28c5d4"},
+ "zoi": {:hex, :zoi, "0.18.4", "849c1ccdf69a4a7b7b6c2e41766312bcc4edf1e0af5bfb9f2f3d98234191b8ef", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "587fb221824ae7343fca3af90b8a4c53ac5cf9019891cf3aba215b43be2ba05d"},
}
diff --git a/n8n_research.md b/n8n_research.md
new file mode 100644
index 0000000..1e5541a
--- /dev/null
+++ b/n8n_research.md
@@ -0,0 +1,94 @@
+# n8n Research
+
+Scratch checkout: `/tmp/fizz-n8n-research`.
+
+## 0. Fizz Decisions Applied So Far
+
+The following n8n-inspired ideas have now been applied in Fizz:
+
+- integration metadata is moving toward generated manifests and validated definitions
+- dynamic field values are routed through a backend dispatcher instead of component-specific
+ LiveView branches
+- resource locators and resource mappers are generic field types
+- credential requirements are explicit first-class declarations rather than generic runtime
+ slots
+- credential option loading is edit-time field resolution, while runtime auth resolution
+ remains a separate concern
+
+One n8n idea deliberately not copied: n8n has broad node parameter state in the frontend
+store. Fizz should keep LiveView as the source of truth and use Vue only for transient
+request/loading state unless we need cross-client field-state replay.
+
+## 1. Node and Integration Architecture
+
+n8n defines integrations as node types with a small runtime contract and a large metadata contract. The core `INodeType` contract requires `description` and optionally provides runtime entry points such as `execute`, `poll`, `trigger`, `webhook`, `supplyData`, `methods`, `webhookMethods`, and `customOperations` (`packages/workflow/src/interfaces.ts:2057`). The metadata contract, `INodeTypeDescription`, carries version, display metadata, inputs, outputs, parameters, credentials, request defaults, declarative request operations, and webhooks (`packages/workflow/src/interfaces.ts:2584`). The key design choice is that UI shape, credential needs, routing hints, and execution hooks are all visible from one node description.
+
+The module layout is layered. `packages/workflow` owns interfaces and helpers. `packages/core/src/nodes-loader` loads node and credential classes. `packages/cli` aggregates loaders and exposes runtime registries. `packages/nodes-base` contains built-in integrations. Registration begins from package metadata: `packages/nodes-base/package.json` declares `n8n.credentials` and `n8n.nodes` arrays (`package.json:25`, `package.json:426`). Individual node files are not discovered by scanning arbitrary code at runtime; they are listed and loaded through the package contract.
+
+Discovery is loader-driven. `PackageDirectoryLoader` reads the package manifest and loads each declared node/credential (`packages/core/src/nodes-loader/package-directory-loader.ts:27`). `DirectoryLoader` infers exported class names from filenames, instantiates classes, validates descriptions, stores known types, and builds credential-to-node mappings (`directory-loader.ts:150`, `directory-loader.ts:166`, `directory-loader.ts:235`). `load-nodes-and-credentials.ts` aggregates built-ins, custom directories, and module-provided loaders (`packages/cli/src/load-nodes-and-credentials.ts:93`) and prefixes public node names with package identity (`load-nodes-and-credentials.ts:521`).
+
+Versioning is explicit. Slack uses a thin `VersionedNodeType` wrapper that maps supported versions to implementation classes and sets a default version (`packages/nodes-base/nodes/Slack/Slack.node.ts:7`). `VersionedNodeType` resolves the right implementation for a requested version (`packages/workflow/src/versioned-node-type.ts:10`). This keeps old workflows stable while new node behavior evolves.
+
+Patterns worth adopting: one metadata-first definition per integration operation, explicit registration through a manifest, build-time validation of definitions, versioned operation definitions, and lazy loading of runtime modules. For Elixir, this should become behaviours plus structs plus generated manifests, not dynamic package loading or TypeScript class inheritance.
+
+## 2. Credentials and Auth
+
+n8n models credentials separately from nodes. The core `ICredentialType` contract declares `name`, `displayName`, optional `extends`, `properties`, `authenticate`, `preAuthentication`, `test`, `genericAuth`, `httpRequestNode`, and `supportedNodes` (`packages/workflow/src/interfaces.ts:356`). Nodes reference credential requirements with `INodeCredentialDescription`, including `displayOptions` and `testedBy` (`interfaces.ts:2233`). During loading, n8n records which nodes support which credentials and attaches supported-node metadata back to credential types (`packages/core/src/nodes-loader/directory-loader.ts:235`, `directory-loader.ts:271`).
+
+The layout keeps credential declaration, persistence, and runtime auth separate. Credential definitions live under `packages/nodes-base/credentials`; nodes consume those definitions under `packages/nodes-base/nodes`; interface contracts live in `packages/workflow`; execution helpers live in `packages/core`; and persistence/OAuth controller logic lives in `packages/cli`. Credential encryption/decryption is centralized by `packages/core/src/credentials.ts:19`.
+
+OAuth is layered through reusable base credentials. `OAuth2Api.credentials.ts` defines generic grant type, auth URL, token URL, client fields, scopes, PKCE, SSL, JWE, and dynamic client registration support (`packages/nodes-base/credentials/OAuth2Api.credentials.ts:3`). Provider credentials extend it and mostly override defaults. Google supplies auth/token URLs and offline consent (`GoogleOAuth2Api.credentials.ts:3`), Google Sheets pins scopes (`GoogleSheetsOAuth2Api.credentials.ts:9`), and Slack adds signing-secret and scope details (`SlackOAuth2Api.credentials.ts:29`).
+
+API-key auth is often declarative. GitHub injects an authorization header and defines a `/user` credential test (`GithubApi.credentials.ts:38`). Slack injects bearer auth and tests `users.profile.get` with response-body rules (`SlackApi.credentials.ts:47`). Generic HTTP auth types such as bearer, header, basic, query, and custom JSON are first-class and marked as generic auth (`HttpBearerAuth.credentials.ts:4`, `HttpCustomAuth.credentials.ts:5`).
+
+At execution time, request helpers inspect credential parent types: OAuth routes through OAuth helpers, while non-OAuth credentials run optional `preAuthentication` and then `authenticate` (`request-helper-functions.ts:1157`). OAuth tokens are refreshed and persisted centrally (`request-helper-functions.ts:945`).
+
+Patterns worth adopting: credential definitions should be provider-owned but not node-owned; auth resolution should return a typed auth material struct; credential tests should be discoverable from credential metadata; generic API-key/header/bearer auth should not require a provider module unless custom behavior is needed.
+
+## 3. Execution Model
+
+n8n separates execution contracts from orchestration. Node extension points are declared in `INodeType`: `execute`, `poll`, `trigger`, `webhook`, `webhookMethods`, and `customOperations` (`packages/workflow/src/interfaces.ts:2057`). The runtime orchestration lives mainly under `packages/core/src/execution-engine`, while persistence, webhook registration, queueing, activation, and API surfaces live under `packages/cli`.
+
+Normal workflow execution is stack based. `WorkflowExecute` initializes a `nodeExecutionStack` with the start node (`workflow-execute.ts:161`), then `processRunExecutionData` loops until the stack is empty (`workflow-execute.ts:1514`). Each stack item is routed through `runNode`, which dispatches to normal execution, polling, triggers, webhook pass-through, or declarative routing (`workflow-execute.ts:1273`). `executeNode` builds an execution context, invokes the node implementation, and runs close hooks without masking the original error (`workflow-execute.ts:1011`). Multi-input nodes can wait in `waitingExecution` until all required input data is present (`workflow-execute.ts:412`).
+
+Errors are explicit execution data. Nodes can set `retryOnFail`, `maxTries`, `waitBetweenTries`, `continueOnFail`, and `onError` (`packages/workflow/src/interfaces.ts:1369`). The engine caps retry counts and wait intervals (`workflow-execute.ts:1716`). Failures become `taskData.error`; `continueOnFail` and `onError` can pass input through or route failures to an error output (`workflow-execute.ts:1941`). Failed executions can also be retried from persisted execution data with `retryOf` metadata (`packages/cli/src/executions/execution.service.ts:223`).
+
+Activation is separate from execution. `ActiveWorkflowManager` distinguishes database-registered webhooks from in-memory triggers and pollers (`active-workflow-manager.ts:618`). Pollers derive schedules from node parameters and emit workflow runs (`active-workflows.ts:151`). Webhook helpers prepare run data, start a `WorkflowRunner`, and support multiple response modes (`webhook-helpers.ts:418`). Long waits persist execution state through `putExecutionToWait` (`base-execute-context.ts:110`) and are resumed by `WaitTracker` polling waiting executions (`wait-tracker.ts:43`).
+
+Patterns worth adopting: keep activation, execution, and persistence separate; make operation execution context typed; model errors and retries as operation metadata; use Oban/OTP supervision for durable retries and waits instead of ad-hoc timers; and preserve resumability at workflow-run boundaries.
+
+## 4. UI for Integrations
+
+n8n's integration UI is schema-driven. Node parameters use shared property types such as `options`, `collection`, `fixedCollection`, `resourceLocator`, `resourceMapper`, `filter`, and `assignmentCollection` (`packages/workflow/src/interfaces.ts:1566`). `INodeProperties` includes `type`, `default`, `options`, `typeOptions`, `displayOptions`, and `routing` (`interfaces.ts:1778`). Visibility is declarative through `displayOptions.show/hide`, including special keys such as `@version` and `@feature` and operators such as `_cnd.gte` and `_cnd.regex` (`interfaces.ts:1735`).
+
+The frontend consumes compiled node descriptions instead of hardcoding provider forms. Node type descriptions are fetched from `types/nodes.json` and stored by name/version (`packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts:375`). The node details panel reads `nodeType.properties` directly (`NodeSettings.vue:232`), splits fields into tabs (`ndv.utils.ts:734`), then renders `ParameterInputList` (`NodeSettings.vue:767`).
+
+Rendering dispatch is generic. `ParameterInputList` filters parameters through `shouldDisplayNodeParameter` and then dispatches by schema `type` to components such as collection, fixed collection, resource mapper, filters, assignment collection, button parameter, or a full parameter input (`ParameterInputList.vue:174`, `ParameterInputList.vue:761`). The shared visibility engine is `NodeHelpers.displayParameter`, which evaluates `displayOptions` against current parameters and handles expression-backed dependencies conservatively (`packages/workflow/src/node-helpers.ts:412`). The frontend wrapper adds product-specific hiding, auth-field movement, cloud flags, and expression resolution before delegating to the shared visibility logic (`useNodeSettingsParameters.ts:186`).
+
+Provider-specific dynamic UI is still schema-addressed. `ParameterInput` calls `getNodeParameterOptions` using `typeOptions.loadOptionsMethod` (`ParameterInput.vue:820`). `ResourceLocator` calls `searchListMethod` (`ResourceLocator.vue:826`). `ResourceMapper` calls `resourceMapperMethod` (`ResourceMapper.vue:357`). Backend routes mirror those concepts with `/options`, `/resource-locator-results`, `/resource-mapper-fields`, and `/action-result` (`dynamic-node-parameters.controller.ts:18`).
+
+Patterns worth adopting: keep a small registry of generic UI field components, let operation definitions declare dynamic resolver names, add declarative field visibility, and isolate provider-specific widgets behind generic field types rather than per-provider modal code.
+
+## 5. Testing Patterns
+
+n8n's strongest integration testing primitive is `NodeTestHarness`. It turns exported workflow JSON fixtures into executable Jest tests. It discovers workflow JSON files beside the test, requires pinned data, converts pin data into expected node output, runs `WorkflowExecute`, and compares node output while stripping volatile fields unless requested (`packages/core/nodes-testing/node-test-harness.ts:45`, `node-test-harness.ts:103`, `node-test-harness.ts:298`). `WorkflowTestData` is the shared contract for workflow fixtures, expected data, optional `nock` mocks, triggers, and credentials (`packages/workflow/src/interfaces.ts:3323`).
+
+Tests stay close to integrations. Node tests live under `packages/nodes-base/nodes/
/test`, `__test__`, or `__tests__`, often split by resource/action. Slack action tests live beside workflow JSON fixtures (`packages/nodes-base/nodes/Slack/test/v2/node/message/post.test.ts:49`, `post.workflow.json:58`). Larger integrations keep fixtures such as API responses next to workflow tests, as Baserow does (`Baserow/__tests__/workflow/workflow.test.ts:4`, `apiResponses.ts:1`).
+
+Isolation is layered. Global setup blocks real network calls with `nock` (`packages/nodes-base/test/globalSetup.ts:3`). Workflow tests can define raw interceptors or structured mocks, as Azure Storage does (`Microsoft/Storage/test/blob/get.test.ts:7`). Operation-level tests fake execution context functions and call actions directly (`packages/nodes-base/test/nodes/Helpers.ts:5`, `Merge/test/v3/operations.test.ts:100`). Transport and dynamic-option helpers mock HTTP request helpers, credentials, and node parameters (`NocoDB/test/v2/transport/transport.test.ts:8`, `listSearch.test.ts:11`).
+
+Credential tests exist at multiple levels. Workflow tests inject credentials into harness runs, while isolated credential tests verify auth behavior directly. Azure shared-key credentials test header cleanup and signature generation (`Microsoft/Storage/test/credentials/sharedKey.test.ts:10`, `sharedKey.test.ts:42`). Core credential encryption/decryption is tested separately (`packages/core/src/__tests__/credentials.test.ts:15`).
+
+Patterns worth adopting: add an ExUnit workflow fixture harness, keep operation fixtures beside integration modules, use `Req.Test` consistently for HTTP isolation, provide fake operation contexts for direct unit tests, and use golden workflow outputs for regression coverage.
+
+## 6. Scaling to Thousands of Nodes
+
+n8n scales its node catalog through manifests and lazy metadata. Each node package declares credentials and nodes under an `n8n` package manifest (`packages/nodes-base/package.json:25`, `package.json:426`). Runtime loading is abstracted behind a `NodeLoader` contract that exposes lightweight `known` metadata, `types`, and on-demand `getNode/getCredential` functions (`packages/workflow/src/interfaces.ts:2821`). `LazyPackageDirectoryLoader` reads generated `dist/known/*.json` and `dist/types/*.json` files without importing every implementation (`packages/core/src/nodes-loader/lazy-package-directory-loader.ts:7`). Concrete node code is loaded only when requested (`directory-loader.ts:243`).
+
+The repository layout uses package boundaries for large catalogs. The monorepo is split in `pnpm-workspace.yaml` (`pnpm-workspace.yaml:1`). Built-in integrations live in `packages/nodes-base`, while LangChain integrations live in `packages/@n8n/nodes-langchain` with their own manifest and category folders (`packages/@n8n/nodes-langchain/package.json:44`). Large integrations are split internally: Slack has a thin version wrapper (`Slack.node.ts:7`), while feature slices live in separate description files and are composed into the node definition (`Slack/V2/SlackV2.node.ts:146`).
+
+Extension points are deliberate. Community packages are discovered through `n8n-nodes-*` naming conventions (`packages/core/src/nodes-loader/scan-directory-for-packages.ts:22`). Custom directories load `*.node.js` and `*.credentials.js` recursively (`custom-directory-loader.ts:20`). Backend modules can register synthetic loaders through `ModuleRegistry.nodeLoaders` (`packages/@n8n/backend-common/src/modules/module-registry.ts:20`), and the MCP registry uses that to generate node types from external server records (`packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts:29`).
+
+Build tooling prevents rot. `nodes-base` runs static asset copying, translation generation, metadata generation, and node-definition generation in its build chain (`packages/nodes-base/package.json:11`). `generate-metadata` writes known/type/method-reference JSON files (`packages/core/bin/generate-metadata:67`). Node docs/assets live beside code, such as Slack's `.node.json` category and docs metadata (`Slack.node.json:1`). Validation catches bad metadata early (`packages/core/src/nodes-loader/validate-node-description.ts:46`).
+
+Patterns worth adopting: generated manifests, compile/build-time validation, scaffolded integration structure, colocated docs/assets/tests, append-only catalog extension, and lazy runtime dispatch through module names stored in validated definitions.
diff --git a/package-lock.json b/package-lock.json
index eaf4096..c889b30 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"color-convert": "^3.1.3",
+ "json-editor-vue": "^0.18.1",
"live_vue": "file:./deps/live_vue",
"phoenix": "file:./deps/phoenix",
"phoenix_html": "file:./deps/phoenix_html",
@@ -140,20 +141,20 @@
}
},
"deps/phoenix": {
- "version": "1.8.3",
+ "version": "1.8.5",
"license": "MIT",
"devDependencies": {
- "@babel/cli": "7.28.3",
- "@babel/core": "7.28.5",
- "@babel/preset-env": "7.28.5",
- "@eslint/js": "^9.28.0",
+ "@babel/cli": "7.28.6",
+ "@babel/core": "7.29.0",
+ "@babel/preset-env": "7.29.0",
+ "@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.0.0",
"documentation": "^14.0.3",
- "eslint": "9.39.1",
- "eslint-plugin-jest": "29.2.1",
+ "eslint": "10.0.2",
+ "eslint-plugin-jest": "29.15.0",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
- "jsdom": "^27.0.0",
+ "jsdom": "^28.1.0",
"mock-socket": "^9.3.1"
}
},
@@ -161,7 +162,7 @@
"version": "4.3.0"
},
"deps/phoenix_live_view": {
- "version": "1.1.22",
+ "version": "1.1.27",
"license": "MIT",
"dependencies": {
"morphdom": "2.7.8"
@@ -1027,18 +1028,234 @@
}
}
},
+ "deps/phoenix/node_modules/@eslint/config-array": {
+ "version": "0.23.3",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
+ "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "deps/phoenix/node_modules/@eslint/config-helpers": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
+ "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "deps/phoenix/node_modules/@eslint/core": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
+ "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "deps/phoenix/node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "deps/phoenix/node_modules/@eslint/object-schema": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
+ "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "deps/phoenix/node_modules/@eslint/plugin-kit": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
+ "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.1.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "deps/phoenix/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "deps/phoenix/node_modules/brace-expansion": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "deps/phoenix/node_modules/eslint": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
+ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.2",
+ "@eslint/config-helpers": "^0.5.2",
+ "@eslint/core": "^1.1.0",
+ "@eslint/plugin-kit": "^0.6.0",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.1",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.1.1",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "deps/phoenix/node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "deps/phoenix/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "deps/phoenix/node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"deps/phoenix/node_modules/jsdom": {
- "version": "27.4.0",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
- "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@acemir/cssom": "^0.9.28",
- "@asamuzakjp/dom-selector": "^6.7.6",
- "@exodus/bytes": "^1.6.0",
- "cssstyle": "^5.3.4",
- "data-urls": "^6.0.0",
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
@@ -1048,11 +1265,11 @@
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0",
- "webidl-conversions": "^8.0.0",
- "whatwg-mimetype": "^4.0.0",
- "whatwg-url": "^15.1.0",
- "ws": "^8.18.3",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
@@ -1067,6 +1284,32 @@
}
}
},
+ "deps/phoenix/node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "deps/phoenix/node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@acemir/cssom": {
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
@@ -1089,23 +1332,26 @@
}
},
"node_modules/@asamuzakjp/css-color": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
- "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
+ "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@csstools/css-calc": "^3.0.0",
- "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-calc": "^3.1.1",
+ "@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.2.5"
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
- "version": "6.7.8",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz",
- "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==",
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1113,7 +1359,7 @@
"bidi-js": "^1.0.3",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.5"
+ "lru-cache": "^11.2.6"
}
},
"node_modules/@asamuzakjp/nwsapi": {
@@ -1124,9 +1370,9 @@
"license": "MIT"
},
"node_modules/@babel/cli": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.3.tgz",
- "integrity": "sha512-n1RU5vuCX0CsaqaXm9I0KUCNKNQMy5epmzl/xdSSm70bSqhg9GWhgeosypyQLc0bK24+Xpk1WGzZlI9pJtkZdg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz",
+ "integrity": "sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1179,21 +1425,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
- "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helpers": "^7.28.4",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -1307,9 +1553,9 @@
}
},
"node_modules/@babel/helper-define-polyfill-provider": {
- "version": "0.6.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz",
- "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==",
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
+ "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2770,81 +3016,81 @@
}
},
"node_modules/@babel/preset-env": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz",
- "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz",
+ "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/compat-data": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
"@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
- "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
- "@babel/plugin-syntax-import-assertions": "^7.27.1",
- "@babel/plugin-syntax-import-attributes": "^7.27.1",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
- "@babel/plugin-transform-async-generator-functions": "^7.28.0",
- "@babel/plugin-transform-async-to-generator": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.0",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
"@babel/plugin-transform-block-scoped-functions": "^7.27.1",
- "@babel/plugin-transform-block-scoping": "^7.28.5",
- "@babel/plugin-transform-class-properties": "^7.27.1",
- "@babel/plugin-transform-class-static-block": "^7.28.3",
- "@babel/plugin-transform-classes": "^7.28.4",
- "@babel/plugin-transform-computed-properties": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
"@babel/plugin-transform-destructuring": "^7.28.5",
- "@babel/plugin-transform-dotall-regex": "^7.27.1",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
"@babel/plugin-transform-duplicate-keys": "^7.27.1",
- "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0",
"@babel/plugin-transform-dynamic-import": "^7.27.1",
- "@babel/plugin-transform-explicit-resource-management": "^7.28.0",
- "@babel/plugin-transform-exponentiation-operator": "^7.28.5",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-for-of": "^7.27.1",
"@babel/plugin-transform-function-name": "^7.27.1",
- "@babel/plugin-transform-json-strings": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
"@babel/plugin-transform-literals": "^7.27.1",
- "@babel/plugin-transform-logical-assignment-operators": "^7.28.5",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
"@babel/plugin-transform-member-expression-literals": "^7.27.1",
"@babel/plugin-transform-modules-amd": "^7.27.1",
- "@babel/plugin-transform-modules-commonjs": "^7.27.1",
- "@babel/plugin-transform-modules-systemjs": "^7.28.5",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.0",
"@babel/plugin-transform-modules-umd": "^7.27.1",
- "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
"@babel/plugin-transform-new-target": "^7.27.1",
- "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
- "@babel/plugin-transform-numeric-separator": "^7.27.1",
- "@babel/plugin-transform-object-rest-spread": "^7.28.4",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
"@babel/plugin-transform-object-super": "^7.27.1",
- "@babel/plugin-transform-optional-catch-binding": "^7.27.1",
- "@babel/plugin-transform-optional-chaining": "^7.28.5",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-parameters": "^7.27.7",
- "@babel/plugin-transform-private-methods": "^7.27.1",
- "@babel/plugin-transform-private-property-in-object": "^7.27.1",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
"@babel/plugin-transform-property-literals": "^7.27.1",
- "@babel/plugin-transform-regenerator": "^7.28.4",
- "@babel/plugin-transform-regexp-modifiers": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.29.0",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
"@babel/plugin-transform-reserved-words": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
- "@babel/plugin-transform-spread": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
"@babel/plugin-transform-sticky-regex": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-typeof-symbol": "^7.27.1",
"@babel/plugin-transform-unicode-escapes": "^7.27.1",
- "@babel/plugin-transform-unicode-property-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
- "@babel/plugin-transform-unicode-sets-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
"@babel/preset-modules": "0.1.6-no-external-plugins",
- "babel-plugin-polyfill-corejs2": "^0.4.14",
- "babel-plugin-polyfill-corejs3": "^0.13.0",
- "babel-plugin-polyfill-regenerator": "^0.6.5",
- "core-js-compat": "^3.43.0",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
"semver": "^6.3.1"
},
"engines": {
@@ -2855,14 +3101,14 @@
}
},
"node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz",
- "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==",
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
+ "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-define-polyfill-provider": "^0.6.5",
- "core-js-compat": "^3.43.0"
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "core-js-compat": "^3.48.0"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -2957,10 +3203,114 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
+ "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-json": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
+ "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/json": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
+ "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
+ "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.41.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
+ "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.6.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@csstools/color-helpers": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
- "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
@@ -2978,9 +3328,9 @@
}
},
"node_modules/@csstools/css-calc": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz",
- "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
@@ -3002,9 +3352,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
- "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"dev": true,
"funding": [
{
@@ -3018,8 +3368,8 @@
],
"license": "MIT",
"dependencies": {
- "@csstools/color-helpers": "^6.0.1",
- "@csstools/css-calc": "^3.0.0"
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.1.1"
},
"engines": {
"node": ">=20.19.0"
@@ -3053,9 +3403,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.0.27",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
- "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
+ "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
"dev": true,
"funding": [
{
@@ -3067,7 +3417,15 @@
"url": "https://opencollective.com/csstools"
}
],
- "license": "MIT-0"
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
@@ -3700,19 +4058,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@eslint/js": {
- "version": "9.39.2",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
- "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- }
- },
"node_modules/@eslint/object-schema": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
@@ -3800,11 +4145,44 @@
"react-dom": ">=16.8.0"
}
},
- "node_modules/@floating-ui/utils": {
- "version": "0.2.10",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
- "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
- "license": "MIT"
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT"
+ },
+ "node_modules/@fortawesome/fontawesome-common-types": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz",
+ "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/free-regular-svg-icons": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.2.0.tgz",
+ "integrity": "sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==",
+ "license": "(CC-BY-4.0 AND MIT)",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "7.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/free-solid-svg-icons": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz",
+ "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==",
+ "license": "(CC-BY-4.0 AND MIT)",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "7.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
@@ -4524,7 +4902,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -4535,7 +4912,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -4546,7 +4922,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -4562,13 +4937,86 @@
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@jsep-plugin/assignment": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz",
+ "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "peerDependencies": {
+ "jsep": "^0.4.0||^1.0.0"
+ }
+ },
+ "node_modules/@jsep-plugin/regex": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz",
+ "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "peerDependencies": {
+ "jsep": "^0.4.0||^1.0.0"
+ }
+ },
+ "node_modules/@jsonquerylang/jsonquery": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@jsonquerylang/jsonquery/-/jsonquery-5.1.1.tgz",
+ "integrity": "sha512-Fj4SoA6Ku09EF+t7OEI8QLipA2A+fJCdEOwnDWG84o5jXMRjkcN5NCMH7kFZb5fP62xz914XV5LBOiDdiUXObg==",
+ "license": "ISC",
+ "bin": {
+ "jsonquery": "bin/cli.js"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
+ "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/json": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
+ "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
+ "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -6176,6 +6624,17 @@
}
}
},
+ "node_modules/@replit/codemirror-indentation-markers": {
+ "version": "6.5.3",
+ "resolved": "https://registry.npmjs.org/@replit/codemirror-indentation-markers/-/codemirror-indentation-markers-6.5.3.tgz",
+ "integrity": "sha512-hL5Sfvw3C1vgg7GolLe/uxX5T3tmgOA3ZzqlMv47zjU1ON51pzNWiVbS22oh6crYhtVhv8b3gdXwoYp++2ilHw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -6553,6 +7012,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
+ "node_modules/@sphinxxxx/color-conversion": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/@sphinxxxx/color-conversion/-/color-conversion-2.2.2.tgz",
+ "integrity": "sha512-XExJS3cLqgrmNBIP3bBw6+1oQ1ksGjFh0+oClDKFYpCCqx/hlqwWO5KO/S63fzUo67SxI9dMrF0y5T/Ey7h8Zw==",
+ "license": "ISC"
+ },
"node_modules/@stylistic/eslint-plugin": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.8.0.tgz",
@@ -6574,6 +7039,15 @@
"eslint": ">=9.0.0"
}
},
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -6956,11 +7430,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/extend": {
@@ -7132,6 +7612,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
@@ -7312,7 +7798,6 @@
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8355,10 +8840,9 @@
}
},
"node_modules/acorn": {
- "version": "8.15.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
- "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
- "dev": true,
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -8414,9 +8898,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8545,6 +9029,15 @@
"node": ">=10"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -8555,6 +9048,15 @@
"node": ">=12"
}
},
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/babel-jest": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
@@ -9133,6 +9635,17 @@
"node": ">= 0.12.0"
}
},
+ "node_modules/codemirror-wrapped-line-indent": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/codemirror-wrapped-line-indent/-/codemirror-wrapped-line-indent-1.0.9.tgz",
+ "integrity": "sha512-oc976hHLt35u6Ojbhub+IWOxEpapZSqYieLEdGhsgFZ4rtYQtdb5KjxzgjCCyVe3t0yk+a6hmaIOEsjU/tZRxQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@codemirror/language": "^6.9.0",
+ "@codemirror/state": "^6.2.1",
+ "@codemirror/view": "^6.17.1"
+ }
+ },
"node_modules/collect-v8-coverage": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
@@ -9269,6 +9782,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -9285,14 +9804,14 @@
}
},
"node_modules/css-tree": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
- "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "mdn-data": "2.12.2",
- "source-map-js": "^1.0.1"
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
@@ -9306,16 +9825,16 @@
"license": "MIT"
},
"node_modules/cssstyle": {
- "version": "5.3.7",
- "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
- "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
+ "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@asamuzakjp/css-color": "^4.1.1",
- "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "@asamuzakjp/css-color": "^5.0.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
"css-tree": "^3.1.0",
- "lru-cache": "^11.2.4"
+ "lru-cache": "^11.2.6"
},
"engines": {
"node": ">=20"
@@ -9443,17 +9962,17 @@
}
},
"node_modules/data-urls": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
- "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^15.1.0"
+ "whatwg-url": "^16.0.0"
},
"engines": {
- "node": ">=20"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-mimetype": {
@@ -9625,6 +10144,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/devalue": {
+ "version": "5.6.4",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
+ "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
+ "license": "MIT"
+ },
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
@@ -9635,6 +10160,15 @@
"node": ">=0.3.1"
}
},
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
"node_modules/doctrine-temporary-fork": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine-temporary-fork/-/doctrine-temporary-fork-2.1.0.tgz",
@@ -9950,6 +10484,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10005,9 +10540,9 @@
}
},
"node_modules/eslint-plugin-jest": {
- "version": "29.2.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.2.1.tgz",
- "integrity": "sha512-0WLIezrIxitUGbjMIGwznVzSIp0uFJV0PZ2fiSvpyVcxe+QMXKUt7MRhUpzdbctnnLwiOTOFkACplgB0wAglFw==",
+ "version": "29.15.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.0.tgz",
+ "integrity": "sha512-ZCGr7vTH2WSo2hrK5oM2RULFmMruQ7W3cX7YfwoTiPfzTGTFBMmrVIz45jZHd++cGKj/kWf02li/RhTGcANJSA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10018,8 +10553,9 @@
},
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "jest": "*"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "jest": "*",
+ "typescript": ">=4.8.4 <6.0.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
@@ -10027,6 +10563,9 @@
},
"jest": {
"optional": true
+ },
+ "typescript": {
+ "optional": true
}
}
},
@@ -10082,6 +10621,7 @@
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
@@ -10097,6 +10637,7 @@
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@eslint/core": "^0.17.0"
},
@@ -10110,6 +10651,7 @@
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -10123,6 +10665,7 @@
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -10136,6 +10679,7 @@
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
@@ -10144,6 +10688,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "license": "MIT"
+ },
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -10189,6 +10739,16 @@
"node": ">=0.10"
}
},
+ "node_modules/esrap": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
+ "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@typescript-eslint/types": "^8.2.0"
+ }
+ },
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -10301,7 +10861,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -10318,6 +10877,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -11057,6 +11632,12 @@
"node": ">= 4"
}
},
+ "node_modules/immutable-json-patch": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/immutable-json-patch/-/immutable-json-patch-6.0.2.tgz",
+ "integrity": "sha512-KwCA5DXJiyldda8SPha1zB+6+vbEi5/jRRcYii/6yFXlyu9ZjiSH/wPq8Ri2Hk8iGjjTMcHW3Z21S4MOpl7sOw==",
+ "license": "ISC"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -11290,6 +11871,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
"node_modules/is-relative": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
@@ -12284,6 +12874,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jmespath": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
+ "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -12617,6 +13216,15 @@
"node": ">=18"
}
},
+ "node_modules/jsep": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
+ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.16.0"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -12637,6 +13245,52 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-editor-vue": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/json-editor-vue/-/json-editor-vue-0.18.1.tgz",
+ "integrity": "sha512-SQCtNngo/ScFjXC7KUOqLOeeLgvl+xwWbxfNelqIOHC6uLilQl7AlWzNJyrDqo+RWnc53nT0OngXki5uTx9SJg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "vanilla-jsoneditor": "^3.0.0",
+ "vue-demi": "^0.14.10"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": ">=1",
+ "vue": "2||3"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/json-editor-vue/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -12651,6 +13305,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/json-source-map/-/json-source-map-0.6.1.tgz",
+ "integrity": "sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg==",
+ "license": "MIT"
+ },
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -12671,6 +13331,33 @@
"node": ">=6"
}
},
+ "node_modules/jsonpath-plus": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz",
+ "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jsep-plugin/assignment": "^1.3.0",
+ "@jsep-plugin/regex": "^1.0.4",
+ "jsep": "^1.4.0"
+ },
+ "bin": {
+ "jsonpath": "bin/jsonpath-cli.js",
+ "jsonpath-plus": "bin/jsonpath-cli.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/jsonrepair": {
+ "version": "3.13.3",
+ "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.3.tgz",
+ "integrity": "sha512-BTznj0owIt2CBAH/LTo7+1I5pMvl1e1033LRl/HUowlZmJOIhzC0zbX5bxMngLkfT4WnzPP26QnW5wMr2g9tsQ==",
+ "license": "ISC",
+ "bin": {
+ "jsonrepair": "bin/cli.js"
+ }
+ },
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -13055,6 +13742,12 @@
"resolved": "deps/live_vue",
"link": true
},
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "license": "MIT"
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -13078,6 +13771,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -13118,9 +13817,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -13534,9 +14233,9 @@
}
},
"node_modules/mdn-data": {
- "version": "2.12.2",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
- "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
@@ -13550,6 +14249,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "license": "MIT"
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -14403,6 +15108,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/natural-compare-lite": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
+ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
+ "license": "MIT"
+ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -15687,7 +16398,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -16253,6 +16963,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -16291,6 +17007,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svelte": {
+ "version": "5.55.1",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz",
+ "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.4",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.4",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -16746,6 +17489,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/undici": {
+ "version": "7.24.5",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
+ "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -17100,6 +17853,71 @@
"spdx-expression-parse": "^3.0.0"
}
},
+ "node_modules/vanilla-jsoneditor": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/vanilla-jsoneditor/-/vanilla-jsoneditor-3.12.0.tgz",
+ "integrity": "sha512-3cLH1jdr2t1+t9XnPkF9EiR394ty8hcVNX/GTj83RjEmkUMZyL/HvQ3e1PvQ3Be8rfH3AKcgySZYLKFfpVnjqQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.1",
+ "@codemirror/commands": "^6.7.1",
+ "@codemirror/lang-json": "^6.0.1",
+ "@codemirror/language": "^6.10.3",
+ "@codemirror/lint": "^6.8.2",
+ "@codemirror/search": "^6.5.6",
+ "@codemirror/state": "^6.4.1",
+ "@codemirror/view": "^6.34.1",
+ "@fortawesome/free-regular-svg-icons": "^6.6.0 || ^7.0.1",
+ "@fortawesome/free-solid-svg-icons": "^6.6.0 || ^7.0.1",
+ "@jsonquerylang/jsonquery": "^3.1.1 || ^4.0.0 || ^5.0.0",
+ "@lezer/highlight": "^1.2.1",
+ "@replit/codemirror-indentation-markers": "^6.5.3",
+ "ajv": "^8.17.1",
+ "codemirror-wrapped-line-indent": "^1.0.8",
+ "diff-sequences": "^29.6.3",
+ "immutable-json-patch": "^6.0.1",
+ "jmespath": "^0.16.0",
+ "json-source-map": "^0.6.1",
+ "jsonpath-plus": "^10.3.0",
+ "jsonrepair": "^3.0.0",
+ "lodash-es": "^4.17.23",
+ "memoize-one": "^6.0.0",
+ "natural-compare-lite": "^1.4.0",
+ "svelte": "^5.0.0",
+ "vanilla-picker": "^2.12.3"
+ }
+ },
+ "node_modules/vanilla-jsoneditor/node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/vanilla-jsoneditor/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/vanilla-picker": {
+ "version": "2.12.3",
+ "resolved": "https://registry.npmjs.org/vanilla-picker/-/vanilla-picker-2.12.3.tgz",
+ "integrity": "sha512-qVkT1E7yMbUsB2mmJNFmaXMWE2hF8ffqzMMwe9zdAikd8u2VfnsVY2HQcOUi2F38bgbxzlJBEdS1UUhOXdF9GQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@sphinxxxx/color-conversion": "^2.2.2"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -17882,6 +18700,12 @@
"typescript": ">=5.0.0"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -17951,17 +18775,18 @@
}
},
"node_modules/whatwg-url": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
- "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
- "webidl-conversions": "^8.0.0"
+ "webidl-conversions": "^8.0.1"
},
"engines": {
- "node": ">=20"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
@@ -18249,6 +19074,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "license": "MIT"
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index ca00270..fc08e16 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"color-convert": "^3.1.3",
+ "json-editor-vue": "^0.18.1",
"live_vue": "file:./deps/live_vue",
"phoenix": "file:./deps/phoenix",
"phoenix_html": "file:./deps/phoenix_html",
diff --git a/priv/repo/migrations/20260312172456_rename_workspaces_to_projects.exs b/priv/repo/migrations/20260312172456_rename_workspaces_to_projects.exs
new file mode 100644
index 0000000..b325635
--- /dev/null
+++ b/priv/repo/migrations/20260312172456_rename_workspaces_to_projects.exs
@@ -0,0 +1,126 @@
+defmodule Fizz.Repo.Migrations.RenameWorkspacesToProjects do
+ use Ecto.Migration
+
+ def change do
+ rename table(:workspaces), to: table(:projects)
+ rename table(:workspace_memberships), to: table(:project_memberships)
+
+ rename table(:project_memberships), :workspace_id, to: :project_id
+ rename table(:sprites), :workspace_id, to: :project_id
+ rename table(:sprite_checkpoints), :workspace_id, to: :project_id
+ rename table(:sprite_console_sessions), :workspace_id, to: :project_id
+ rename table(:sprite_exec_jobs), :workspace_id, to: :project_id
+ rename table(:sprite_services), :workspace_id, to: :project_id
+ rename table(:workflows), :workspace_id, to: :project_id
+
+ execute(
+ "ALTER INDEX workspaces_workos_organization_id_index RENAME TO projects_workos_organization_id_index",
+ "ALTER INDEX projects_workos_organization_id_index RENAME TO workspaces_workos_organization_id_index"
+ )
+
+ execute(
+ "ALTER INDEX workspaces_workos_organization_id_slug_index RENAME TO projects_workos_organization_id_slug_index",
+ "ALTER INDEX projects_workos_organization_id_slug_index RENAME TO workspaces_workos_organization_id_slug_index"
+ )
+
+ execute(
+ "ALTER INDEX workspace_memberships_user_id_index RENAME TO project_memberships_user_id_index",
+ "ALTER INDEX project_memberships_user_id_index RENAME TO workspace_memberships_user_id_index"
+ )
+
+ execute(
+ "ALTER INDEX workspace_memberships_workspace_id_user_id_index RENAME TO project_memberships_project_id_user_id_index",
+ "ALTER INDEX project_memberships_project_id_user_id_index RENAME TO workspace_memberships_workspace_id_user_id_index"
+ )
+
+ execute(
+ "ALTER INDEX sprites_workspace_id_name_index RENAME TO sprites_project_id_name_index",
+ "ALTER INDEX sprites_project_id_name_index RENAME TO sprites_workspace_id_name_index"
+ )
+
+ execute(
+ "ALTER INDEX sprites_workspace_id_status_index RENAME TO sprites_project_id_status_index",
+ "ALTER INDEX sprites_project_id_status_index RENAME TO sprites_workspace_id_status_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_checkpoints_workspace_id_inserted_at_index RENAME TO sprite_checkpoints_project_id_inserted_at_index",
+ "ALTER INDEX sprite_checkpoints_project_id_inserted_at_index RENAME TO sprite_checkpoints_workspace_id_inserted_at_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_console_sessions_workspace_id_state_index RENAME TO sprite_console_sessions_project_id_state_index",
+ "ALTER INDEX sprite_console_sessions_project_id_state_index RENAME TO sprite_console_sessions_workspace_id_state_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_exec_jobs_workspace_id_inserted_at_index RENAME TO sprite_exec_jobs_project_id_inserted_at_index",
+ "ALTER INDEX sprite_exec_jobs_project_id_inserted_at_index RENAME TO sprite_exec_jobs_workspace_id_inserted_at_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_services_workspace_id_status_index RENAME TO sprite_services_project_id_status_index",
+ "ALTER INDEX sprite_services_project_id_status_index RENAME TO sprite_services_workspace_id_status_index"
+ )
+
+ execute(
+ "ALTER INDEX workflows_workspace_id_index RENAME TO workflows_project_id_index",
+ "ALTER INDEX workflows_project_id_index RENAME TO workflows_workspace_id_index"
+ )
+
+ execute(
+ "ALTER INDEX workflows_workspace_id_user_id_index RENAME TO workflows_project_id_user_id_index",
+ "ALTER INDEX workflows_project_id_user_id_index RENAME TO workflows_workspace_id_user_id_index"
+ )
+
+ execute(
+ "ALTER TABLE projects RENAME CONSTRAINT workspaces_pkey TO projects_pkey",
+ "ALTER TABLE projects RENAME CONSTRAINT projects_pkey TO workspaces_pkey"
+ )
+
+ execute(
+ "ALTER TABLE project_memberships RENAME CONSTRAINT workspace_memberships_pkey TO project_memberships_pkey",
+ "ALTER TABLE project_memberships RENAME CONSTRAINT project_memberships_pkey TO workspace_memberships_pkey"
+ )
+
+ execute(
+ "ALTER TABLE project_memberships RENAME CONSTRAINT workspace_memberships_user_id_fkey TO project_memberships_user_id_fkey",
+ "ALTER TABLE project_memberships RENAME CONSTRAINT project_memberships_user_id_fkey TO workspace_memberships_user_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE project_memberships RENAME CONSTRAINT workspace_memberships_workspace_id_fkey TO project_memberships_project_id_fkey",
+ "ALTER TABLE project_memberships RENAME CONSTRAINT project_memberships_project_id_fkey TO workspace_memberships_workspace_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE sprites RENAME CONSTRAINT sprites_workspace_id_fkey TO sprites_project_id_fkey",
+ "ALTER TABLE sprites RENAME CONSTRAINT sprites_project_id_fkey TO sprites_workspace_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE sprite_checkpoints RENAME CONSTRAINT sprite_checkpoints_workspace_id_fkey TO sprite_checkpoints_project_id_fkey",
+ "ALTER TABLE sprite_checkpoints RENAME CONSTRAINT sprite_checkpoints_project_id_fkey TO sprite_checkpoints_workspace_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE sprite_console_sessions RENAME CONSTRAINT sprite_console_sessions_workspace_id_fkey TO sprite_console_sessions_project_id_fkey",
+ "ALTER TABLE sprite_console_sessions RENAME CONSTRAINT sprite_console_sessions_project_id_fkey TO sprite_console_sessions_workspace_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE sprite_exec_jobs RENAME CONSTRAINT sprite_exec_jobs_workspace_id_fkey TO sprite_exec_jobs_project_id_fkey",
+ "ALTER TABLE sprite_exec_jobs RENAME CONSTRAINT sprite_exec_jobs_project_id_fkey TO sprite_exec_jobs_workspace_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE sprite_services RENAME CONSTRAINT sprite_services_workspace_id_fkey TO sprite_services_project_id_fkey",
+ "ALTER TABLE sprite_services RENAME CONSTRAINT sprite_services_project_id_fkey TO sprite_services_workspace_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workflows RENAME CONSTRAINT workflows_workspace_id_fkey TO workflows_project_id_fkey",
+ "ALTER TABLE workflows RENAME CONSTRAINT workflows_project_id_fkey TO workflows_workspace_id_fkey"
+ )
+ end
+end
diff --git a/priv/repo/migrations/20260312184724_rename_sprite_runtime_to_workspace_runtime.exs b/priv/repo/migrations/20260312184724_rename_sprite_runtime_to_workspace_runtime.exs
new file mode 100644
index 0000000..67fe89a
--- /dev/null
+++ b/priv/repo/migrations/20260312184724_rename_sprite_runtime_to_workspace_runtime.exs
@@ -0,0 +1,167 @@
+defmodule Fizz.Repo.Migrations.RenameSpriteRuntimeToWorkspaceRuntime do
+ use Ecto.Migration
+
+ def change do
+ rename table(:sprites), to: table(:workspaces)
+ rename table(:sprite_checkpoints), to: table(:workspace_checkpoints)
+ rename table(:sprite_console_sessions), to: table(:workspace_console_sessions)
+ rename table(:sprite_exec_jobs), to: table(:workspace_exec_jobs)
+ rename table(:sprite_exec_log_chunks), to: table(:workspace_exec_log_chunks)
+ rename table(:sprite_services), to: table(:workspace_services)
+
+ rename table(:workspace_checkpoints), :sprite_id, to: :workspace_id
+ rename table(:workspace_console_sessions), :sprite_id, to: :workspace_id
+ rename table(:workspace_exec_jobs), :sprite_id, to: :workspace_id
+ rename table(:workspace_services), :sprite_id, to: :workspace_id
+
+ execute(
+ "ALTER INDEX sprites_remote_name_index RENAME TO workspaces_remote_name_index",
+ "ALTER INDEX workspaces_remote_name_index RENAME TO sprites_remote_name_index"
+ )
+
+ execute(
+ "ALTER INDEX sprites_project_id_name_index RENAME TO workspaces_project_id_name_index",
+ "ALTER INDEX workspaces_project_id_name_index RENAME TO sprites_project_id_name_index"
+ )
+
+ execute(
+ "ALTER INDEX sprites_project_id_status_index RENAME TO workspaces_project_id_status_index",
+ "ALTER INDEX workspaces_project_id_status_index RENAME TO sprites_project_id_status_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_checkpoints_project_id_inserted_at_index RENAME TO workspace_checkpoints_project_id_inserted_at_index",
+ "ALTER INDEX workspace_checkpoints_project_id_inserted_at_index RENAME TO sprite_checkpoints_project_id_inserted_at_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_checkpoints_sprite_id_remote_checkpoint_id_index RENAME TO workspace_checkpoints_workspace_id_remote_checkpoint_id_index",
+ "ALTER INDEX workspace_checkpoints_workspace_id_remote_checkpoint_id_index RENAME TO sprite_checkpoints_sprite_id_remote_checkpoint_id_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_console_sessions_project_id_state_index RENAME TO workspace_console_sessions_project_id_state_index",
+ "ALTER INDEX workspace_console_sessions_project_id_state_index RENAME TO sprite_console_sessions_project_id_state_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_exec_jobs_project_id_inserted_at_index RENAME TO workspace_exec_jobs_project_id_inserted_at_index",
+ "ALTER INDEX workspace_exec_jobs_project_id_inserted_at_index RENAME TO sprite_exec_jobs_project_id_inserted_at_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_exec_log_chunks_job_id_seq_index RENAME TO workspace_exec_log_chunks_job_id_seq_index",
+ "ALTER INDEX workspace_exec_log_chunks_job_id_seq_index RENAME TO sprite_exec_log_chunks_job_id_seq_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_services_project_id_status_index RENAME TO workspace_services_project_id_status_index",
+ "ALTER INDEX workspace_services_project_id_status_index RENAME TO sprite_services_project_id_status_index"
+ )
+
+ execute(
+ "ALTER INDEX sprite_services_sprite_id_name_index RENAME TO workspace_services_workspace_id_name_index",
+ "ALTER INDEX workspace_services_workspace_id_name_index RENAME TO sprite_services_sprite_id_name_index"
+ )
+
+ execute(
+ "ALTER TABLE workspaces RENAME CONSTRAINT sprites_pkey TO workspaces_pkey",
+ "ALTER TABLE workspaces RENAME CONSTRAINT workspaces_pkey TO sprites_pkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT sprite_checkpoints_pkey TO workspace_checkpoints_pkey",
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT workspace_checkpoints_pkey TO sprite_checkpoints_pkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT sprite_console_sessions_pkey TO workspace_console_sessions_pkey",
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT workspace_console_sessions_pkey TO sprite_console_sessions_pkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT sprite_exec_jobs_pkey TO workspace_exec_jobs_pkey",
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT workspace_exec_jobs_pkey TO sprite_exec_jobs_pkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_exec_log_chunks RENAME CONSTRAINT sprite_exec_log_chunks_pkey TO workspace_exec_log_chunks_pkey",
+ "ALTER TABLE workspace_exec_log_chunks RENAME CONSTRAINT workspace_exec_log_chunks_pkey TO sprite_exec_log_chunks_pkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_services RENAME CONSTRAINT sprite_services_pkey TO workspace_services_pkey",
+ "ALTER TABLE workspace_services RENAME CONSTRAINT workspace_services_pkey TO sprite_services_pkey"
+ )
+
+ execute(
+ "ALTER TABLE workspaces RENAME CONSTRAINT sprites_project_id_fkey TO workspaces_project_id_fkey",
+ "ALTER TABLE workspaces RENAME CONSTRAINT workspaces_project_id_fkey TO sprites_project_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspaces RENAME CONSTRAINT sprites_created_by_user_id_fkey TO workspaces_created_by_user_id_fkey",
+ "ALTER TABLE workspaces RENAME CONSTRAINT workspaces_created_by_user_id_fkey TO sprites_created_by_user_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT sprite_checkpoints_project_id_fkey TO workspace_checkpoints_project_id_fkey",
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT workspace_checkpoints_project_id_fkey TO sprite_checkpoints_project_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT sprite_checkpoints_created_by_user_id_fkey TO workspace_checkpoints_created_by_user_id_fkey",
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT workspace_checkpoints_created_by_user_id_fkey TO sprite_checkpoints_created_by_user_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT sprite_checkpoints_sprite_id_fkey TO workspace_checkpoints_workspace_id_fkey",
+ "ALTER TABLE workspace_checkpoints RENAME CONSTRAINT workspace_checkpoints_workspace_id_fkey TO sprite_checkpoints_sprite_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT sprite_console_sessions_project_id_fkey TO workspace_console_sessions_project_id_fkey",
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT workspace_console_sessions_project_id_fkey TO sprite_console_sessions_project_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT sprite_console_sessions_opened_by_user_id_fkey TO workspace_console_sessions_opened_by_user_id_fkey",
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT workspace_console_sessions_opened_by_user_id_fkey TO sprite_console_sessions_opened_by_user_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT sprite_console_sessions_sprite_id_fkey TO workspace_console_sessions_workspace_id_fkey",
+ "ALTER TABLE workspace_console_sessions RENAME CONSTRAINT workspace_console_sessions_workspace_id_fkey TO sprite_console_sessions_sprite_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT sprite_exec_jobs_project_id_fkey TO workspace_exec_jobs_project_id_fkey",
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT workspace_exec_jobs_project_id_fkey TO sprite_exec_jobs_project_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT sprite_exec_jobs_requested_by_user_id_fkey TO workspace_exec_jobs_requested_by_user_id_fkey",
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT workspace_exec_jobs_requested_by_user_id_fkey TO sprite_exec_jobs_requested_by_user_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT sprite_exec_jobs_sprite_id_fkey TO workspace_exec_jobs_workspace_id_fkey",
+ "ALTER TABLE workspace_exec_jobs RENAME CONSTRAINT workspace_exec_jobs_workspace_id_fkey TO sprite_exec_jobs_sprite_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_exec_log_chunks RENAME CONSTRAINT sprite_exec_log_chunks_job_id_fkey TO workspace_exec_log_chunks_job_id_fkey",
+ "ALTER TABLE workspace_exec_log_chunks RENAME CONSTRAINT workspace_exec_log_chunks_job_id_fkey TO sprite_exec_log_chunks_job_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_services RENAME CONSTRAINT sprite_services_project_id_fkey TO workspace_services_project_id_fkey",
+ "ALTER TABLE workspace_services RENAME CONSTRAINT workspace_services_project_id_fkey TO sprite_services_project_id_fkey"
+ )
+
+ execute(
+ "ALTER TABLE workspace_services RENAME CONSTRAINT sprite_services_sprite_id_fkey TO workspace_services_workspace_id_fkey",
+ "ALTER TABLE workspace_services RENAME CONSTRAINT workspace_services_workspace_id_fkey TO sprite_services_sprite_id_fkey"
+ )
+ end
+end
diff --git a/priv/repo/migrations/20260318195545_create_workflow_definitions.exs b/priv/repo/migrations/20260318195545_create_workflow_definitions.exs
new file mode 100644
index 0000000..89cdca9
--- /dev/null
+++ b/priv/repo/migrations/20260318195545_create_workflow_definitions.exs
@@ -0,0 +1,56 @@
+defmodule Fizz.Repo.Migrations.CreateWorkflowDefinitions do
+ use Ecto.Migration
+
+ def change do
+ create table(:workflow_definitions, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+ add :name, :string, null: false
+ add :description, :text
+ add :created_by_user_id, :string, null: false
+ add :archived_at, :utc_datetime_usec
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create index(:workflow_definitions, [:project_id])
+ create index(:workflow_definitions, [:workos_organization_id])
+
+ create table(:workflow_definition_versions, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :workflow_definition_id,
+ references(:workflow_definitions, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :version, :integer, null: false
+ add :status, :string, null: false, default: "draft"
+ add :steps, :map, null: false, default: fragment("'[]'::jsonb")
+ add :connections, :map, null: false, default: fragment("'[]'::jsonb")
+ add :step_groups, :map, null: false, default: fragment("'[]'::jsonb")
+ add :viewport, :map, null: false, default: %{"x" => 0, "y" => 0, "zoom" => 1.0}
+ add :settings, :map, null: false, default: %{}
+ add :compiled_hash, :string
+ add :published_at, :utc_datetime_usec
+ add :published_by_user_id, :string
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :workflow_definition_versions,
+ :workflow_definition_versions_status_check,
+ check: "status IN ('draft', 'published', 'archived')"
+ )
+
+ create unique_index(
+ :workflow_definition_versions,
+ [:workflow_definition_id, :version],
+ name: :workflow_definition_versions_definition_version_index
+ )
+ end
+end
diff --git a/priv/repo/migrations/20260319184207_create_workflow_run_leases.exs b/priv/repo/migrations/20260319184207_create_workflow_run_leases.exs
new file mode 100644
index 0000000..58f9a1b
--- /dev/null
+++ b/priv/repo/migrations/20260319184207_create_workflow_run_leases.exs
@@ -0,0 +1,15 @@
+defmodule Fizz.Repo.Migrations.CreateWorkflowRunLeases do
+ use Ecto.Migration
+
+ def change do
+ create table(:workflow_run_leases, primary_key: false) do
+ add :run_id, :binary_id, primary_key: true
+ add :owner_node, :string
+ add :fence_token, :bigint, null: false, default: 0
+ add :checkpoint_seq, :bigint, null: false, default: 0
+ add :lease_expiry, :utc_datetime_usec, null: false
+ end
+
+ create index(:workflow_run_leases, [:lease_expiry])
+ end
+end
diff --git a/priv/repo/migrations/20260319211156_create_workflow_runs.exs b/priv/repo/migrations/20260319211156_create_workflow_runs.exs
new file mode 100644
index 0000000..1c47a00
--- /dev/null
+++ b/priv/repo/migrations/20260319211156_create_workflow_runs.exs
@@ -0,0 +1,49 @@
+defmodule Fizz.Repo.Migrations.CreateWorkflowRuns do
+ use Ecto.Migration
+
+ def change do
+ create table(:workflow_runs, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :workflow_definition_id,
+ references(:workflow_definitions, type: :binary_id, on_delete: :restrict),
+ null: false
+
+ add :workflow_definition_version_id,
+ references(:workflow_definition_versions, type: :binary_id, on_delete: :restrict),
+ null: false
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+ add :status, :string, null: false, default: "pending"
+ add :input, :map, null: false, default: fragment("'{}'::jsonb")
+ add :output, :map
+ add :error, :map
+ add :storage_uri, :string
+ add :last_active_at, :utc_datetime_usec, null: false, default: fragment("NOW()")
+ add :started_at, :utc_datetime_usec
+ add :completed_at, :utc_datetime_usec
+
+ add :continued_from_run_id,
+ references(:workflow_runs, type: :binary_id, on_delete: :nilify_all)
+
+ add :compiled_hash, :string
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :workflow_runs,
+ :workflow_runs_status_check,
+ check:
+ "status IN ('pending', 'running', 'sleeping', 'passivated', 'completed', 'failed', 'cancelled', 'continued')"
+ )
+
+ create index(:workflow_runs, [:project_id, :status])
+ create index(:workflow_runs, [:workflow_definition_id])
+ create index(:workflow_runs, [:status, :last_active_at])
+ create index(:workflow_runs, [:continued_from_run_id])
+ end
+end
diff --git a/priv/repo/migrations/20260320001013_create_durable_timers_and_signals.exs b/priv/repo/migrations/20260320001013_create_durable_timers_and_signals.exs
new file mode 100644
index 0000000..8b07feb
--- /dev/null
+++ b/priv/repo/migrations/20260320001013_create_durable_timers_and_signals.exs
@@ -0,0 +1,66 @@
+defmodule Fizz.Repo.Migrations.CreateDurableTimersAndSignals do
+ use Ecto.Migration
+
+ def change do
+ create table(:durable_timers, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :run_id, references(:workflow_runs, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :step_id, :string, null: false
+ add :timer_name, :string, null: false
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+ add :fire_at, :utc_datetime_usec, null: false
+ add :status, :string, null: false, default: "pending"
+ add :payload, :map
+ add :claimed_at, :utc_datetime_usec
+ add :claimed_by, :string
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :durable_timers,
+ :durable_timers_status_check,
+ check: "status IN ('pending', 'firing', 'fired', 'cancelled')"
+ )
+
+ create index(:durable_timers, [:fire_at], where: "status = 'pending'")
+ create index(:durable_timers, [:run_id], where: "status = 'pending'")
+ create index(:durable_timers, [:claimed_at], where: "status = 'firing'")
+
+ create table(:signal_inbox, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :run_id, references(:workflow_runs, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :signal_id, :string, null: false
+ add :signal_name, :string, null: false
+ add :payload, :map, null: false, default: fragment("'{}'::jsonb")
+ add :status, :string, null: false, default: "pending"
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+ add :delivered_at, :utc_datetime_usec
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :signal_inbox,
+ :signal_inbox_status_check,
+ check: "status IN ('pending', 'delivered', 'skipped')"
+ )
+
+ create unique_index(:signal_inbox, [:run_id, :signal_id])
+ create index(:signal_inbox, [:run_id], where: "status = 'pending'")
+ end
+end
diff --git a/priv/repo/migrations/20260320010235_create_trigger_tables.exs b/priv/repo/migrations/20260320010235_create_trigger_tables.exs
new file mode 100644
index 0000000..bed40e9
--- /dev/null
+++ b/priv/repo/migrations/20260320010235_create_trigger_tables.exs
@@ -0,0 +1,133 @@
+defmodule Fizz.Repo.Migrations.CreateTriggerTables do
+ use Ecto.Migration
+
+ def change do
+ create table(:trigger_registrations, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :workflow_definition_id,
+ references(:workflow_definitions, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :definition_version_id,
+ references(:workflow_definition_versions, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :step_id, :string, null: false
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+
+ add :run_id, references(:workflow_runs, type: :binary_id, on_delete: :delete_all)
+
+ add :kind, :string, null: false
+ add :status, :string, null: false, default: "active"
+ add :registration_params, :map, null: false, default: fragment("'{}'::jsonb")
+ add :config_digest, :string, null: false
+
+ add :webhook_path, :string
+ add :webhook_secret, :string
+
+ add :cron_expression, :string
+ add :next_fire_at, :utc_datetime_usec
+
+ add :cursor, :map
+ add :poll_interval_ms, :integer
+ add :last_polled_at, :utc_datetime_usec
+ add :batch_size, :integer, null: false, default: 100
+
+ add :error_message, :string
+ add :consecutive_errors, :integer, null: false, default: 0
+ add :last_error_at, :utc_datetime_usec
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :trigger_registrations,
+ :trigger_registrations_kind_check,
+ check: "kind IN ('manual', 'webhook', 'schedule', 'polling', 'subscription', 'chat')"
+ )
+
+ create constraint(
+ :trigger_registrations,
+ :trigger_registrations_status_check,
+ check: "status IN ('active', 'paused', 'errored', 'inactive', 'firing')"
+ )
+
+ create unique_index(
+ :trigger_registrations,
+ [:definition_version_id, :step_id],
+ where: "run_id IS NULL",
+ name: :trigger_registrations_definition_level_step_index
+ )
+
+ create unique_index(
+ :trigger_registrations,
+ [:run_id, :step_id],
+ where: "run_id IS NOT NULL",
+ name: :trigger_registrations_run_level_step_index
+ )
+
+ create unique_index(
+ :trigger_registrations,
+ [:webhook_path],
+ where: "webhook_path IS NOT NULL AND status = 'active'",
+ name: :trigger_registrations_active_webhook_path_index
+ )
+
+ create index(
+ :trigger_registrations,
+ [:next_fire_at],
+ where: "kind = 'schedule' AND status = 'active' AND next_fire_at IS NOT NULL",
+ name: :trigger_registrations_schedule_due_index
+ )
+
+ create index(
+ :trigger_registrations,
+ [:project_id, :status],
+ name: :trigger_registrations_project_status_index
+ )
+
+ create table(:trigger_events, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :trigger_registration_id,
+ references(:trigger_registrations, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+ add :event_id, :string, null: false
+ add :event_data, :map
+ add :status, :string, null: false, default: "pending"
+ add :run_id, :binary_id
+ add :processed_at, :utc_datetime_usec
+
+ timestamps(inserted_at: :created_at, updated_at: false, type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :trigger_events,
+ :trigger_events_status_check,
+ check: "status IN ('pending', 'processing', 'fired', 'skipped', 'failed')"
+ )
+
+ create unique_index(
+ :trigger_events,
+ [:trigger_registration_id, :event_id],
+ name: :trigger_events_registration_event_id_index
+ )
+
+ create index(
+ :trigger_events,
+ [:created_at],
+ where: "status IN ('fired', 'skipped', 'failed')",
+ name: :trigger_events_cleanup_index
+ )
+ end
+end
diff --git a/priv/repo/migrations/20260320010236_add_triggered_by_to_workflow_runs.exs b/priv/repo/migrations/20260320010236_add_triggered_by_to_workflow_runs.exs
new file mode 100644
index 0000000..5a9c031
--- /dev/null
+++ b/priv/repo/migrations/20260320010236_add_triggered_by_to_workflow_runs.exs
@@ -0,0 +1,9 @@
+defmodule Fizz.Repo.Migrations.AddTriggeredByToWorkflowRuns do
+ use Ecto.Migration
+
+ def change do
+ alter table(:workflow_runs) do
+ add :triggered_by, :map
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260320021747_remove_firing_status_from_trigger_registrations.exs b/priv/repo/migrations/20260320021747_remove_firing_status_from_trigger_registrations.exs
new file mode 100644
index 0000000..03d8501
--- /dev/null
+++ b/priv/repo/migrations/20260320021747_remove_firing_status_from_trigger_registrations.exs
@@ -0,0 +1,23 @@
+defmodule Fizz.Repo.Migrations.RemoveFiringStatusFromTriggerRegistrations do
+ use Ecto.Migration
+
+ def up do
+ execute "UPDATE trigger_registrations SET status = 'active' WHERE status = 'firing'"
+
+ execute """
+ ALTER TABLE trigger_registrations
+ DROP CONSTRAINT trigger_registrations_status_check,
+ ADD CONSTRAINT trigger_registrations_status_check
+ CHECK (status IN ('active', 'paused', 'errored', 'inactive'))
+ """
+ end
+
+ def down do
+ execute """
+ ALTER TABLE trigger_registrations
+ DROP CONSTRAINT trigger_registrations_status_check,
+ ADD CONSTRAINT trigger_registrations_status_check
+ CHECK (status IN ('active', 'paused', 'errored', 'inactive', 'firing'))
+ """
+ end
+end
diff --git a/priv/repo/migrations/20260506130000_add_user_id_to_workflow_runs.exs b/priv/repo/migrations/20260506130000_add_user_id_to_workflow_runs.exs
new file mode 100644
index 0000000..403de9c
--- /dev/null
+++ b/priv/repo/migrations/20260506130000_add_user_id_to_workflow_runs.exs
@@ -0,0 +1,52 @@
+defmodule Fizz.Repo.Migrations.AddUserIdToWorkflowRuns do
+ use Ecto.Migration
+
+ def up do
+ alter table(:workflow_runs) do
+ add :user_id, :string
+ end
+
+ execute("""
+ UPDATE workflow_runs AS runs
+ SET user_id = COALESCE(versions.published_by_user_id, definitions.created_by_user_id)
+ FROM workflow_definition_versions AS versions
+ JOIN workflow_definitions AS definitions
+ ON definitions.id = versions.workflow_definition_id
+ WHERE runs.workflow_definition_version_id = versions.id
+ AND runs.workflow_definition_id = definitions.id
+ AND runs.user_id IS NULL
+ """)
+
+ execute("""
+ DELETE FROM workflow_run_leases
+ WHERE run_id IN (
+ SELECT id FROM workflow_runs WHERE user_id IS NULL
+ )
+ """)
+
+ execute("""
+ DELETE FROM trigger_events
+ WHERE run_id IN (
+ SELECT id FROM workflow_runs WHERE user_id IS NULL
+ )
+ """)
+
+ execute("DELETE FROM workflow_runs WHERE user_id IS NULL")
+
+ alter table(:workflow_runs) do
+ modify :user_id, :string, null: false
+ end
+
+ create index(:workflow_runs, [:user_id])
+ create index(:workflow_runs, [:user_id, :workflow_definition_id])
+ end
+
+ def down do
+ drop index(:workflow_runs, [:user_id, :workflow_definition_id])
+ drop index(:workflow_runs, [:user_id])
+
+ alter table(:workflow_runs) do
+ remove :user_id
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260506130100_add_user_id_to_trigger_registrations.exs b/priv/repo/migrations/20260506130100_add_user_id_to_trigger_registrations.exs
new file mode 100644
index 0000000..0729a3d
--- /dev/null
+++ b/priv/repo/migrations/20260506130100_add_user_id_to_trigger_registrations.exs
@@ -0,0 +1,74 @@
+defmodule Fizz.Repo.Migrations.AddUserIdToTriggerRegistrations do
+ use Ecto.Migration
+
+ def up do
+ alter table(:trigger_registrations) do
+ add :user_id, :string
+ end
+
+ execute("""
+ UPDATE trigger_registrations AS registrations
+ SET user_id = resolved.user_id
+ FROM (
+ SELECT
+ registrations.id,
+ COALESCE(
+ runs.user_id,
+ versions.published_by_user_id,
+ definitions.created_by_user_id
+ ) AS user_id
+ FROM trigger_registrations AS registrations
+ JOIN workflow_definition_versions AS versions
+ ON versions.id = registrations.definition_version_id
+ JOIN workflow_definitions AS definitions
+ ON definitions.id = versions.workflow_definition_id
+ LEFT JOIN workflow_runs AS runs
+ ON runs.id = registrations.run_id
+ WHERE registrations.user_id IS NULL
+ AND registrations.workflow_definition_id = definitions.id
+ ) AS resolved
+ WHERE registrations.id = resolved.id
+ AND registrations.user_id IS NULL
+ """)
+
+ execute("""
+ DELETE FROM trigger_events
+ WHERE trigger_registration_id IN (
+ SELECT id FROM trigger_registrations WHERE user_id IS NULL
+ )
+ """)
+
+ execute("DELETE FROM trigger_registrations WHERE user_id IS NULL")
+
+ alter table(:trigger_registrations) do
+ modify :user_id, :string, null: false
+ end
+
+ drop_if_exists unique_index(:trigger_registrations, [:definition_version_id, :step_id],
+ name: :trigger_registrations_definition_level_step_index
+ )
+
+ create unique_index(:trigger_registrations, [:definition_version_id, :step_id, :user_id],
+ name: :trigger_registrations_definition_level_step_user_index
+ )
+
+ create index(:trigger_registrations, [:user_id])
+ end
+
+ def down do
+ drop index(:trigger_registrations, [:user_id])
+
+ drop unique_index(:trigger_registrations, [:definition_version_id, :step_id, :user_id],
+ name: :trigger_registrations_definition_level_step_user_index
+ )
+
+ create unique_index(:trigger_registrations, [:definition_version_id, :step_id],
+ where: "run_id IS NULL",
+ name: :trigger_registrations_definition_level_step_index
+ )
+
+ alter table(:trigger_registrations) do
+ remove :user_id
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260511195820_create_trigger_sources.exs b/priv/repo/migrations/20260511195820_create_trigger_sources.exs
new file mode 100644
index 0000000..3e1eec3
--- /dev/null
+++ b/priv/repo/migrations/20260511195820_create_trigger_sources.exs
@@ -0,0 +1,94 @@
+defmodule Fizz.Repo.Migrations.CreateTriggerSources do
+ use Ecto.Migration
+
+ def change do
+ create table(:trigger_sources, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :project_id, references(:projects, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :workos_organization_id, :string, null: false
+ add :user_id, :string, null: false
+ add :kind, :string, null: false
+ add :provider, :string, null: false
+ add :source_module, :string, null: false
+ add :source_key, :string, null: false
+ add :status, :string, null: false, default: "active"
+ add :params, :map, null: false, default: fragment("'{}'::jsonb")
+ add :cursor, :map
+ add :poll_interval_ms, :integer, null: false, default: 60_000
+ add :next_poll_at, :utc_datetime_usec
+ add :last_polled_at, :utc_datetime_usec
+ add :backoff_until, :utc_datetime_usec
+ add :lease_owner, :string
+ add :lease_expires_at, :utc_datetime_usec
+ add :error_message, :string
+ add :consecutive_errors, :integer, null: false, default: 0
+ add :last_error_at, :utc_datetime_usec
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(
+ :trigger_sources,
+ :trigger_sources_kind_check,
+ check: "kind IN ('polling', 'subscription')"
+ )
+
+ create constraint(
+ :trigger_sources,
+ :trigger_sources_status_check,
+ check: "status IN ('active', 'paused', 'errored', 'inactive')"
+ )
+
+ create unique_index(:trigger_sources, [:source_key], name: :trigger_sources_source_key_index)
+
+ create index(:trigger_sources, [:project_id, :status],
+ name: :trigger_sources_project_status_index
+ )
+
+ create index(:trigger_sources, [:kind, :status, :next_poll_at],
+ name: :trigger_sources_polling_due_index
+ )
+
+ create index(:trigger_sources, [:lease_expires_at],
+ name: :trigger_sources_lease_expires_at_index
+ )
+
+ alter table(:trigger_registrations) do
+ add :trigger_source_id,
+ references(:trigger_sources, type: :binary_id, on_delete: :nilify_all)
+ end
+
+ create index(:trigger_registrations, [:trigger_source_id],
+ name: :trigger_registrations_trigger_source_id_index
+ )
+
+ create table(:trigger_source_rows, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :trigger_source_id,
+ references(:trigger_sources, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :row_key, :string, null: false
+ add :row_number, :integer, null: false
+ add :row_hash, :string, null: false
+ add :values, :map, null: false, default: fragment("'{}'::jsonb")
+ add :raw_values, {:array, :string}, null: false, default: []
+ add :last_seen_at, :utc_datetime_usec, null: false
+ add :last_changed_at, :utc_datetime_usec, null: false
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(:trigger_source_rows, [:trigger_source_id, :row_key],
+ name: :trigger_source_rows_source_row_key_index
+ )
+
+ create index(:trigger_source_rows, [:trigger_source_id, :row_number],
+ name: :trigger_source_rows_source_row_number_index
+ )
+ end
+end
diff --git a/priv/repo/migrations/20260525170343_add_signal_claims_and_workflow_runtime_indexes.exs b/priv/repo/migrations/20260525170343_add_signal_claims_and_workflow_runtime_indexes.exs
new file mode 100644
index 0000000..4894b9b
--- /dev/null
+++ b/priv/repo/migrations/20260525170343_add_signal_claims_and_workflow_runtime_indexes.exs
@@ -0,0 +1,56 @@
+defmodule Fizz.Repo.Migrations.AddSignalClaimsAndWorkflowRuntimeIndexes do
+ use Ecto.Migration
+
+ def up do
+ alter table(:signal_inbox) do
+ add :claimed_at, :utc_datetime_usec
+ add :claimed_by, :string
+ end
+
+ drop constraint(:signal_inbox, :signal_inbox_status_check)
+
+ create constraint(
+ :signal_inbox,
+ :signal_inbox_status_check,
+ check: "status IN ('pending', 'delivering', 'delivered', 'skipped')"
+ )
+
+ create index(:signal_inbox, [:inserted_at],
+ where: "status = 'pending'",
+ name: :signal_inbox_pending_inserted_at_index
+ )
+
+ create index(:signal_inbox, [:claimed_at],
+ where: "status = 'delivering'",
+ name: :signal_inbox_delivering_claimed_at_index
+ )
+
+ create index(:workflow_runs, [:last_active_at],
+ where: "status IN ('running', 'sleeping')",
+ name: :workflow_runs_passivation_candidates_index
+ )
+ end
+
+ def down do
+ drop index(:workflow_runs, [:last_active_at],
+ name: :workflow_runs_passivation_candidates_index
+ )
+
+ drop index(:signal_inbox, [:claimed_at], name: :signal_inbox_delivering_claimed_at_index)
+
+ drop index(:signal_inbox, [:inserted_at], name: :signal_inbox_pending_inserted_at_index)
+
+ drop constraint(:signal_inbox, :signal_inbox_status_check)
+
+ create constraint(
+ :signal_inbox,
+ :signal_inbox_status_check,
+ check: "status IN ('pending', 'delivered', 'skipped')"
+ )
+
+ alter table(:signal_inbox) do
+ remove :claimed_at
+ remove :claimed_by
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260527033109_replace_slot_bindings_with_credential_bindings.exs b/priv/repo/migrations/20260527033109_replace_slot_bindings_with_credential_bindings.exs
new file mode 100644
index 0000000..1e86ca7
--- /dev/null
+++ b/priv/repo/migrations/20260527033109_replace_slot_bindings_with_credential_bindings.exs
@@ -0,0 +1,36 @@
+defmodule Fizz.Repo.Migrations.ReplaceSlotBindingsWithCredentialBindings do
+ use Ecto.Migration
+
+ def up do
+ drop_if_exists table(:slot_bindings)
+
+ create table(:credential_bindings, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :workflow_definition_id,
+ references(:workflow_definitions, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :user_id, :string, null: false
+ add :step_id, :string, null: false
+ add :requirement_key, :string, null: false
+ add :binding_data, :map, null: false, default: fragment("'{}'::jsonb")
+ add :workos_organization_id, :string, null: false
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(
+ :credential_bindings,
+ [:user_id, :workflow_definition_id, :step_id, :requirement_key],
+ name: :credential_bindings_user_definition_step_requirement_index
+ )
+
+ create index(:credential_bindings, [:workflow_definition_id, :step_id])
+ create index(:credential_bindings, [:user_id, :workos_organization_id])
+ end
+
+ def down do
+ drop_if_exists table(:credential_bindings)
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index cb0baf4..a9a0b46 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -4,9 +4,10 @@
alias Fizz.Repo
alias Fizz.Accounts
-alias Fizz.Accounts.{User, Scope, Workspace, WorkspaceMembership}
+alias Fizz.Accounts.{Project, ProjectMembership}
+alias Fizz.Accounts.Scope
alias Fizz.Workflows
-alias Fizz.Workflows.Workflow
+alias Fizz.Workflows.WorkflowDefinition
IO.puts("🌱 Seeding database...")
@@ -29,379 +30,347 @@ IO.puts("✅ Found both users:")
IO.puts(" galad360@gmail.com: #{user1.email}")
IO.puts(" galad.work@gmail.com: #{user2.email}")
-# Find or create a shared workspace for both users
+# Find or create a shared project for both users
import Ecto.Query
-# First, try to find an existing workspace that both users are members of
-shared_workspace =
+# First, try to find an existing project that both users are members of
+shared_project =
Repo.one(
- from w in Workspace,
- join: wm1 in WorkspaceMembership,
- on: wm1.workspace_id == w.id and wm1.user_id == ^user1.id,
- join: wm2 in WorkspaceMembership,
- on: wm2.workspace_id == w.id and wm2.user_id == ^user2.id,
+ from w in Project,
+ join: wm1 in ProjectMembership,
+ on: wm1.project_id == w.id and wm1.user_id == ^user1.id,
+ join: wm2 in ProjectMembership,
+ on: wm2.project_id == w.id and wm2.user_id == ^user2.id,
limit: 1
)
-workspace1 =
- if shared_workspace do
- IO.puts("✅ Found existing shared workspace: #{shared_workspace.name}")
- shared_workspace
+project1 =
+ if shared_project do
+ IO.puts("✅ Found existing shared project: #{shared_project.name}")
+ shared_project
else
- # Check if user1 has any workspace we can use
- user1_workspace =
+ # Check if user1 has any project we can use
+ user1_project =
Repo.one(
- from w in Workspace,
- join: wm in WorkspaceMembership,
- on: wm.workspace_id == w.id,
+ from w in Project,
+ join: wm in ProjectMembership,
+ on: wm.project_id == w.id,
where: wm.user_id == ^user1.id,
limit: 1
)
- if is_nil(user1_workspace) do
- IO.puts("⚠️ No workspace found for #{user1.email}. Skipping workflow creation.")
- IO.puts(" Workflows require a workspace. Please create one first.")
+ if is_nil(user1_project) do
+ IO.puts("⚠️ No project found for #{user1.email}. Skipping project seed updates.")
+ IO.puts(" Create a project first, then rerun the seeds.")
System.halt(0)
end
- IO.puts("✅ Using existing workspace for #{user1.email}: #{user1_workspace.name}")
- IO.puts(" Adding #{user2.email} to the workspace...")
+ IO.puts("✅ Using existing project for #{user1.email}: #{user1_project.name}")
+ IO.puts(" Adding #{user2.email} to the project...")
- # Add user2 to user1's workspace
+ # Add user2 to user1's project
membership =
- Repo.get_by(WorkspaceMembership, workspace_id: user1_workspace.id, user_id: user2.id) ||
- %WorkspaceMembership{workspace_id: user1_workspace.id, user_id: user2.id}
+ Repo.get_by(ProjectMembership, project_id: user1_project.id, user_id: user2.id) ||
+ %ProjectMembership{project_id: user1_project.id, user_id: user2.id}
case membership
- |> WorkspaceMembership.changeset(%{role: :member})
+ |> ProjectMembership.changeset(%{role: :member})
|> Repo.insert_or_update() do
{:ok, _membership} ->
- IO.puts(" ✅ Added #{user2.email} as member to workspace: #{user1_workspace.name}")
- user1_workspace
+ IO.puts(" ✅ Added #{user2.email} as member to project: #{user1_project.name}")
+ user1_project
{:error, changeset} ->
- IO.puts(" ⚠️ Failed to add #{user2.email} to workspace: #{inspect(changeset.errors)}")
- user1_workspace
+ IO.puts(" ⚠️ Failed to add #{user2.email} to project: #{inspect(changeset.errors)}")
+ user1_project
end
end
-# Create scope for user1 with workspace and organization
-# Note: For seeds, we'll create a minimal scope. In production, use Accounts.build_scope/3
scope1 =
Scope.for_user(user1)
- |> Scope.with_organization_id(workspace1.workos_organization_id)
- |> Scope.with_workspace(workspace1)
- |> Scope.with_workspace_role(:admin)
+ |> Scope.with_organization_id(project1.workos_organization_id)
+ |> Scope.with_project(project1)
+ |> Scope.with_project_role(:admin)
|> Scope.with_organization_role(:owner)
-IO.puts("\n📋 Creating example workflows...")
+step = fn attrs ->
+ Map.merge(
+ %{
+ id: Ecto.UUID.generate(),
+ type_id: "debug",
+ name: "Step",
+ config: %{},
+ position: %{"x" => 0, "y" => 0},
+ notes: nil
+ },
+ attrs
+ )
+end
-# Helper function to create workflow with draft
-create_workflow_with_draft = fn attrs, steps, connections ->
- case Workflows.create_workflow(scope1, attrs) do
- {:ok, workflow} ->
- draft_attrs = %{
- steps: steps,
- connections: connections,
- settings: %{timeout_ms: 300_000, max_retries: 3}
- }
+connection = fn attrs ->
+ Map.merge(
+ %{
+ id: Ecto.UUID.generate(),
+ source_output: "main",
+ target_input: "main"
+ },
+ attrs
+ )
+end
- case Workflows.update_workflow_draft(scope1, workflow, draft_attrs) do
- {:ok, _draft} ->
- workflow
+snapshot_attrs = fn steps, connections ->
+ %{
+ steps: steps,
+ connections: connections,
+ step_groups: [],
+ viewport: %{"x" => 0, "y" => 0, "zoom" => 1.0},
+ settings: %{}
+ }
+end
+ensure_workflow = fn definition_attrs, draft_attrs ->
+ existing_definition =
+ Repo.one(
+ from definition in WorkflowDefinition,
+ where:
+ definition.project_id == ^project1.id and definition.name == ^definition_attrs.name and
+ is_nil(definition.archived_at),
+ limit: 1
+ )
+
+ case existing_definition do
+ %WorkflowDefinition{} = definition ->
+ IO.puts("↩️ Skipping existing workflow: #{definition.name}")
+ {:ok, definition}
+
+ nil ->
+ with {:ok, %{definition: definition, draft: draft}} <-
+ Workflows.create_definition(scope1, definition_attrs),
+ {:ok, _saved_draft} <- Workflows.save_draft(scope1, draft, draft_attrs) do
+ IO.puts("✅ Seeded workflow: #{definition.name}")
+ {:ok, definition}
+ else
{:error, reason} ->
- IO.puts(
- "⚠️ Warning: Failed to create draft for workflow #{workflow.name}: #{inspect(reason)}"
- )
-
- workflow
+ IO.puts("⚠️ Failed to seed workflow #{definition_attrs.name}: #{inspect(reason)}")
+ {:error, reason}
end
-
- {:error, reason} ->
- IO.puts("⚠️ Failed to create workflow #{attrs[:name]}: #{inspect(reason)}")
- nil
end
end
-# ============================================================================
-# Example 1: Linear Workflow - Simple sequential data processing
-# ============================================================================
-IO.puts("Creating Linear Workflow...")
+IO.puts("\n📋 Seeding initial workflow definitions...")
-linear_steps = [
- %{
- id: "start",
+customer_intake_trigger =
+ step.(%{
type_id: "manual_input",
- name: "Start",
+ name: "Manual Trigger",
config: %{
- "trigger_data" =>
- "{\"name\": \"John Doe\", \"timestamp\": \"2026-01-04 20:00:00\", \"arr\":[1,2,3,4,5]}"
+ "input_schema" => %{
+ "type" => "object",
+ "properties" => %{
+ "name" => %{"type" => "string"},
+ "email" => %{"type" => "string"},
+ "message" => %{"type" => "string"}
+ }
+ }
},
- position: %{"x" => -161.17957584024998, "y" => -569.2425531914893}
- },
- %{
- id: "format_greeting",
- type_id: "format",
- name: "Format Greeting",
- config: %{"template" => "Hello {{json.name}}! Welcome to the workflow."},
- position: %{"x" => 40.66991013933023, "y" => 70.26862929139838}
- },
- %{
- id: "add_timestamp",
- type_id: "format",
- name: "Add Timestamp",
- config: %{"template" => "{{json.greeting}} Processed at {{json.timestamp}}"},
- position: %{"x" => 431.25889887340765, "y" => 78.97413492435965}
- },
- %{
- id: "end",
+ position: %{"x" => 80, "y" => 160}
+ })
+
+customer_intake_debug =
+ step.(%{
type_id: "debug",
- name: "End",
- config: %{"message" => "Linear workflow completed"},
- position: %{"x" => 1062.82042415975, "y" => -720.2425531914893}
- },
- %{
- id: "webhook_trigger",
- type_id: "webhook_trigger",
- name: "Webhook Trigger",
- config: %{
- "http_method" => "POST",
- "path" => "7f011ee3-555d-418f-bc6b-603f21983f7a",
- "response_mode" => "immediate",
- "validate_input" => false
+ name: "Inspect Request",
+ config: %{"label" => "Incoming request", "level" => "info"},
+ position: %{"x" => 360, "y" => 160}
+ })
+
+customer_intake_output =
+ step.(%{
+ type_id: "data_output",
+ name: "Output",
+ position: %{"x" => 640, "y" => 160}
+ })
+
+customer_intake_connections = [
+ connection.(%{
+ source_step_id: customer_intake_trigger.id,
+ target_step_id: customer_intake_debug.id
+ }),
+ connection.(%{
+ source_step_id: customer_intake_debug.id,
+ target_step_id: customer_intake_output.id
+ })
+]
+
+_ =
+ ensure_workflow.(
+ %{
+ name: "Customer Intake",
+ description: "Accept a request payload, inspect it, and emit the final output."
},
- position: %{"x" => -161.17957584024998, "y" => -418.24255319148926}
- },
- %{
- id: "split_items",
- type_id: "splitter",
- name: "Split Items",
- config: %{"field" => "{{ json.arr }}"},
- position: %{"x" => 246.82042415975002, "y" => -493.74255319148926}
- },
- %{
- id: "math",
- type_id: "math",
- name: "Multiply by 10",
+ snapshot_attrs.(
+ [customer_intake_trigger, customer_intake_debug, customer_intake_output],
+ customer_intake_connections
+ )
+ )
+
+status_trigger =
+ step.(%{
+ type_id: "manual_input",
+ name: "Manual Trigger",
config: %{
- "operand" => "{{ json }}",
- "operation" => "multiply",
- "value" => "10"
+ "input_schema" => %{
+ "type" => "object",
+ "properties" => %{
+ "status" => %{"type" => "string"},
+ "customer_id" => %{"type" => "string"}
+ }
+ }
},
- position: %{"x" => 654.82042415975, "y" => -569.2425531914893}
- },
- %{
- id: "math_2",
- type_id: "math",
- name: "Multiply by 1",
+ position: %{"x" => 80, "y" => 220}
+ })
+
+status_condition =
+ step.(%{
+ type_id: "condition",
+ name: "Status Is Active?",
config: %{
- "operand" => "{{ json }}",
- "operation" => "multiply",
- "value" => "1"
+ "condition" => "{{ input.status | eq: \"active\" }}",
+ "true_output" => "active",
+ "false_output" => "inactive"
},
- position: %{"x" => 654.82042415975, "y" => -418.24255319148926}
- },
- %{
- id: "format_string",
- type_id: "format",
- name: "Format String",
- config: %{"template" => "({{ json[0] }}, {{ json[1] }})"},
- position: %{"x" => 1062.82042415975, "y" => -493.74255319148926}
- },
- %{
- id: "aggregate_items",
- type_id: "aggregator",
- name: "Aggregate Items",
- config: %{},
- position: %{"x" => 1470.82042415975, "y" => -493.74255319148926}
- },
- %{
- id: "format_string_2",
- type_id: "format",
- name: "Format String 2",
- config: %{"template" => "{{ json }}"},
- position: %{"x" => 1878.82042415975, "y" => -493.74255319148926}
- }
-]
+ position: %{"x" => 360, "y" => 220}
+ })
-linear_connections = [
- %{
- id: "start_to_format",
- source_step_id: "start",
- source_output: "main",
- target_step_id: "format_greeting",
- target_input: "main"
- },
- %{
- id: "format_to_add_timestamp",
- source_step_id: "format_greeting",
- source_output: "main",
- target_step_id: "add_timestamp",
- target_input: "main"
- },
- %{
- id: "add_timestamp_to_end",
- source_step_id: "add_timestamp",
- source_output: "main",
- target_step_id: "end",
- target_input: "main"
- },
- %{
- id: "2bdb85af-0e52-42d5-a92e-47bad4b23c11",
- source_step_id: "start",
- source_output: "main",
- target_step_id: "split_items",
- target_input: "main"
- },
- %{
- id: "0eff119f-e1dd-4e8e-8d95-fdad64b06641",
- source_step_id: "split_items",
- source_output: "main",
- target_step_id: "math",
- target_input: "main"
- },
- %{
- id: "af9d53ea-c06c-4ee6-952a-c1406b937726",
- source_step_id: "split_items",
- source_output: "main",
- target_step_id: "math_2",
- target_input: "main"
- },
- %{
- id: "4af4bde9-42ab-4b44-9589-d43109b5e869",
- source_step_id: "math",
- source_output: "main",
- target_step_id: "format_string",
- target_input: "main"
- },
- %{
- id: "995449ee-2925-46e1-a86e-532b28efb9c7",
- source_step_id: "math_2",
- source_output: "main",
- target_step_id: "format_string",
- target_input: "main"
- },
- %{
- id: "bb5c1a59-a6d1-49a3-94f7-916f07655412",
- source_step_id: "format_string",
- source_output: "main",
- target_step_id: "aggregate_items",
- target_input: "main"
- },
- %{
- id: "d7a53977-73ca-4654-aa9f-fe181c55f281",
- source_step_id: "aggregate_items",
- source_output: "main",
- target_step_id: "format_string_2",
- target_input: "main"
- }
+status_active =
+ step.(%{
+ type_id: "debug",
+ name: "Active Branch",
+ config: %{"label" => "Active account", "level" => "info"},
+ position: %{"x" => 680, "y" => 120}
+ })
+
+status_inactive =
+ step.(%{
+ type_id: "debug",
+ name: "Inactive Branch",
+ config: %{"label" => "Inactive account", "level" => "warn"},
+ position: %{"x" => 680, "y" => 320}
+ })
+
+status_connections = [
+ connection.(%{
+ source_step_id: status_trigger.id,
+ target_step_id: status_condition.id
+ }),
+ connection.(%{
+ source_step_id: status_condition.id,
+ source_output: "active",
+ target_step_id: status_active.id
+ }),
+ connection.(%{
+ source_step_id: status_condition.id,
+ source_output: "inactive",
+ target_step_id: status_inactive.id
+ })
]
-linear_workflow =
- create_workflow_with_draft.(
+_ =
+ ensure_workflow.(
%{
- name: "Linear Data Processing",
- description: "A simple linear workflow that processes data sequentially"
+ name: "Route By Status",
+ description:
+ "Branch a request into active and inactive paths using the new predicate syntax."
},
- linear_steps,
- linear_connections
+ snapshot_attrs.(
+ [status_trigger, status_condition, status_active, status_inactive],
+ status_connections
+ )
)
-if linear_workflow, do: IO.puts("✅ Created Linear Workflow: #{linear_workflow.name}")
+split_trigger =
+ step.(%{
+ type_id: "manual_input",
+ name: "Manual Trigger",
+ config: %{
+ "input_schema" => %{
+ "type" => "object",
+ "properties" => %{
+ "items" => %{
+ "type" => "array",
+ "items" => %{"type" => "number"}
+ }
+ }
+ }
+ },
+ position: %{"x" => 80, "y" => 160}
+ })
-# ============================================================================
-# Example 2: Branching Workflow - Conditional processing with if/else
-# ============================================================================
-IO.puts("Creating Branching Workflow...")
+split_items =
+ step.(%{
+ type_id: "splitter",
+ name: "Split Items",
+ config: %{"field" => "items"},
+ position: %{"x" => 320, "y" => 160}
+ })
-branching_steps = [
- %{
- id: "input",
- type_id: "manual_input",
- name: "Input",
- config: %{"trigger_data" => "{\"name\": \"Alice\", \"status\": \"active\"}"},
- position: %{"x" => 100, "y" => 150}
- },
- %{
- id: "check_status",
- type_id: "condition",
- name: "Check Status",
- config: %{"condition" => "{{json.status}} == 'active'"},
- position: %{"x" => 300, "y" => 150}
- },
- %{
- id: "active_path",
- type_id: "format",
- name: "Active User",
- config: %{"template" => "✅ User {{json.name}} is active"},
- position: %{"x" => 500, "y" => 100}
- },
- %{
- id: "inactive_path",
- type_id: "format",
- name: "Inactive User",
- config: %{"template" => "❌ User {{json.name}} is inactive"},
- position: %{"x" => 500, "y" => 200}
- },
- %{
- id: "output",
- type_id: "debug",
- name: "Output",
- config: %{"message" => "Branching workflow completed"},
- position: %{"x" => 700, "y" => 150}
- }
-]
+double_item =
+ step.(%{
+ type_id: "math",
+ name: "Double Each Item",
+ config: %{
+ "operation" => "multiply",
+ "value" => "{{ input }}",
+ "operand" => 2
+ },
+ position: %{"x" => 560, "y" => 160}
+ })
-branching_connections = [
- %{
- id: "input_to_check",
- source_step_id: "input",
- source_output: "main",
- target_step_id: "check_status",
- target_input: "main"
- },
- %{
- id: "check_to_active",
- source_step_id: "check_status",
- source_output: "true",
- target_step_id: "active_path",
- target_input: "main"
- },
- %{
- id: "check_to_inactive",
- source_step_id: "check_status",
- source_output: "false",
- target_step_id: "inactive_path",
- target_input: "main"
- },
- %{
- id: "active_to_output",
- source_step_id: "active_path",
- source_output: "main",
- target_step_id: "output",
- target_input: "main"
- },
- %{
- id: "inactive_to_output",
- source_step_id: "inactive_path",
- source_output: "main",
- target_step_id: "output",
- target_input: "main"
- }
+sum_results =
+ step.(%{
+ type_id: "aggregator",
+ name: "Sum Results",
+ config: %{"operation" => "sum"},
+ position: %{"x" => 840, "y" => 160}
+ })
+
+split_output =
+ step.(%{
+ type_id: "data_output",
+ name: "Output",
+ position: %{"x" => 1120, "y" => 160}
+ })
+
+split_connections = [
+ connection.(%{
+ source_step_id: split_trigger.id,
+ target_step_id: split_items.id
+ }),
+ connection.(%{
+ source_step_id: split_items.id,
+ target_step_id: double_item.id
+ }),
+ connection.(%{
+ source_step_id: double_item.id,
+ target_step_id: sum_results.id
+ }),
+ connection.(%{
+ source_step_id: sum_results.id,
+ target_step_id: split_output.id
+ })
]
-branching_workflow =
- create_workflow_with_draft.(
+_ =
+ ensure_workflow.(
%{
- name: "Branching User Status",
- description: "Conditional workflow that routes based on user status"
+ name: "Split And Sum Items",
+ description: "Fan out a list of numbers, transform each item, then aggregate the results."
},
- branching_steps,
- branching_connections
+ snapshot_attrs.(
+ [split_trigger, split_items, double_item, sum_results, split_output],
+ split_connections
+ )
)
-if branching_workflow, do: IO.puts("✅ Created Branching Workflow: #{branching_workflow.name}")
-
+IO.puts("✅ Shared project setup is complete.")
IO.puts("\n🎉 Seeding completed!")
-IO.puts("Note: Workflow sharing is handled through workspace memberships in this system.")
diff --git a/refactor_plan.md b/refactor_plan.md
new file mode 100644
index 0000000..eff016d
--- /dev/null
+++ b/refactor_plan.md
@@ -0,0 +1,216 @@
+# Integration Refactor Plan
+
+This plan is informed by n8n, but translated into idiomatic Elixir/Phoenix/OTP. The goal is not to port n8n's TypeScript architecture; the goal is to preserve its durable ideas: metadata-first definitions, generated catalogs, generic UI rendering, versioned executable nodes, isolated credential/auth contracts, and test harnesses that make new integrations cheap.
+
+## Current Checkpoints
+
+- `8931099` completed Phases 0-4: catalog guardrails, unified definitions, operation-backed Google Sheets registry support, and schema-driven credential forms.
+- `9ef8cfd` completed Phase 5: dynamic field resolver dispatch plus generic resource locator/mapper UI support.
+- `d824f3d` deleted the generic slots system and replaced it with first-class credential declarations, credential bindings, and runtime credential resolution.
+- `4944cce` moved Google Sheets operation metadata into operation modules, deleted the old Google Sheets step wrappers, and dispatches operations with typed execution context.
+- Current uncommitted work collapses operations into step types, makes `Fizz.Fields` the source of truth for step fields, moves normalized errors/retry policy into `Fizz.Workflows`, and gives the runner a generic durable step retry path.
+- Current Phase 7 work makes integrations first-class step owners. The new `fizz` integration owns providerless built-in nodes under `Fizz.Integrations.Fizz.Builtins`, external products own their provider-backed steps under integration namespaces, step declaration/type metadata lives under `Fizz.Integrations`, and the step execution behavior lives under `Fizz.Workflows`.
+- The next implementation slice should deepen Phase 7 with pinned workflow fixtures and `Req.Test` HTTP harnesses, then implement the first non-Google API family behind the same step/retry primitives.
+
+## Diagnosis
+
+1. **Declaration scatter is now mostly reduced to provider/catalog registration.** Providers are still listed through `ProviderCatalog` (`lib/fizz/integrations/provider_catalog.ex:9`), but product integrations own their executable step modules via `step_modules/0`, and `Fizz.Integrations.StepRegistry` indexes those step definitions from the manifest. Adding a real integration still requires provider/catalog edits, assets, tests, and docs, but there is no separate step executor list to maintain.
+
+2. **There is now one executable primitive.** `Fizz.Integrations.StepType` is the node definition consumed by the editor and runtime. Integrations should own provider/product/resolver/trigger/client/auth metadata, but not a parallel executable "operation" layer.
+
+3. **Credentials are runtime auth primitives, not a parallel field system.** WorkOS-backed OAuth and Vault-backed API keys are mostly generic, and provider definitions now expose credential creation fields through `Fizz.Fields`. Workflow credential binding storage remains, but declaration maps, schema generation, defaults, option lookup, readiness, auto-binding, runtime binding lookup, and secret extraction now live under the credential field handler instead of dedicated credential schema/catalog modules.
+
+4. **Context boundaries are still blurred, but field ownership is cleaner.** `Accounts` and `Integrations` still depend on each other (`lib/fizz/integrations.ex:9`, `lib/fizz/accounts/external_auth.ex:12`). The generic slot API has been deleted, credential declaration behavior now lives in `Fizz.Fields.Credential`, and workflow-specific persisted choices still live in `Fizz.Workflows.CredentialBinding`. Runtime context still carries compatibility fields such as `scope` and `current_scope` (`lib/fizz/workflows/runtime/context_builder.ex:65`), which leaks Phoenix naming into execution code.
+
+5. **The UI is schema-driven, but field-state ownership is still young.** Vue renders backend schema fields through `FieldWrapper` (`assets/vue/components/flow/fields/FieldWrapper.vue:70`), and `ResourceMapperField.vue` now uses schema/resolver metadata instead of Google Sheets-specific branches. `Fizz.Integrations.DynamicResolver` owns edit-time field dispatch, and Google Sheets async failures now normalize through `Fizz.Workflows.StepError`, but there is not yet a durable server-side field-state model for cross-client async loading/error state.
+
+6. **Tests cover workflows broadly, and Phase 7 now has a first direct step harness.** `Fizz.IntegrationStepCase` gives tests a small API for loading step definitions and executing integration-backed steps directly. The remaining n8n-style gap is workflow JSON fixtures with pinned outputs, credential fixtures, and `Req.Test` HTTP expectations. Direct `Req` usage in provider/client modules still makes some HTTP tests hard (`lib/fizz/integrations/providers/github_oauth.ex:161`).
+
+## Target Architecture
+
+### Core Modules
+
+- `Fizz.Integrations.Catalog`: supervised GenServer/ETS registry for providers, integrations, triggers, and resolvers. It should load from a generated manifest and expose read-only lookup APIs.
+- `Fizz.Integrations.Manifest`: generated module produced by a Mix task from declared integration modules. This replaces hand-maintained lists in `ProviderCatalog`, `Integrations.Registry`, and `Integrations.StepRegistry`.
+- `Fizz.Integrations.StepRegistry`: ETS-backed index of `Fizz.Integrations.StepType` definitions loaded from integration-owned `step_modules/0` declarations.
+- `Fizz.Integrations.StaticIntegration`: boilerplate-free integration module macro for products whose catalog surface and executable step module list are static.
+- `Fizz.Integrations..integration.ex`: colocated product integration
+ declaration file. Root `lib/fizz/integrations/` stays limited to shared
+ catalog/framework primitives.
+- `Fizz.Integrations.PlaceholderStep`: shared explicit execution payload for registered external steps whose API clients are not implemented yet.
+- `Fizz.Integrations.Definition`: structs and validators for integration/provider metadata, delegating field validation to `Fizz.Fields`.
+- `Fizz.Fields`: canonical typed field contract plus JSON Schema adapter output. It owns field definitions, validation, defaults, and schema generation for step fields and provider credential-creation fields.
+- `Fizz.Fields.Credential`: credential field handler. It owns credential declaration maps, option lookup, readiness descriptors, auto-binding, runtime binding lookup, and secret extraction for API-key credential creation.
+- `Fizz.Integrations.StepType`: canonical executable node definition with typed fields, generated config schema/defaults, retry policy, input/output schema, provider/integration metadata, and version.
+- `Fizz.Workflows.StepExecutor`: runtime behavior/helper for all executable steps.
+- `Fizz.Integrations.Fizz.Builtins.*`: providerless Fizz product-domain nodes such as
+ triggers, transforms, control flow, HTTP, and AI orchestration helpers.
+- `Fizz.Integrations.Provider`: provider metadata and auth-provider behavior. Keep WorkOS Pipes helpers behind `Fizz.Integrations.Auth.PipesOAuth`.
+- `Fizz.Integrations.Auth`: execution-time auth resolver returning typed auth material. It should support project-scoped and organization-scoped callers without duplicating `CredentialRef` logic.
+- `Fizz.Integrations.DynamicResolver`: generic edit-time dispatcher for select/search/resource mapper fields. Credential options dispatch through `Fizz.Fields.Credential` as a field handler, not as a separate resolver subsystem.
+- `Fizz.Workflows.StepError`, `Fizz.Workflows.RetryPolicy`, and `Fizz.Workflows.Runner.StepRetry`: generic runtime error and retry primitives for every step, whether it calls an external provider or not.
+- `Fizz.Workflows.ExecutionContext`: typed runtime context struct with one canonical scope field, project ID, user ID, organization ID, run ID, trace context, and execution options.
+
+### Step And Integration Contract
+
+The contract should be data-first, with one executable node primitive and provider
+metadata kept beside integration domain code. Example target shape:
+
+```elixir
+defmodule Fizz.Integrations.Integration do
+ @callback id() :: String.t()
+ @callback display_name() :: String.t()
+ @callback provider_id() :: String.t() | nil
+ @callback actions() :: [String.t()]
+ @callback triggers() :: [module()]
+ @callback step_modules() :: [module()]
+end
+
+defmodule Fizz.Workflows.StepExecutor do
+ @callback execute(config :: map(), input :: term(), context :: map()) ::
+ {:ok, term()}
+ | {:error, term()}
+ | {:skip, term()}
+end
+```
+
+Example integration-backed step definition:
+
+```elixir
+defmodule Fizz.Integrations.Google.Sheets.Actions.AppendRow do
+ use Fizz.Integrations.StepDefinition,
+ id: "google_sheets_append_row",
+ version: 1,
+ name: "Google Sheets - Append Row",
+ category: "Documents",
+ description: "Append a new row of data to a Google Sheet",
+ icon: "/images/google_sheets.svg",
+ kind: :action,
+ provider: "google_oauth",
+ integration: "google_sheets"
+
+ alias Fizz.Fields
+ alias Fizz.Workflows.RetryPolicy
+
+ @fields [
+ Fields.credential("google_oauth", :oauth,
+ key: "credential_ref",
+ label: "Google Account",
+ requirement_key: "auth",
+ required?: true
+ ),
+ Fields.resource_locator("spreadsheet_id", %{
+ "kind" => "google_sheets.spreadsheet",
+ "value_key" => "spreadsheet_id"
+ },
+ label: "Spreadsheet",
+ required?: true
+ )
+ ]
+
+ @retry %RetryPolicy{max_attempts: 3, backoff: :exponential}
+
+ @behaviour Fizz.Workflows.StepExecutor
+
+ def execute(config, input, context) do
+ # call Google Sheets client/resolver/auth helpers here
+ end
+end
+```
+
+### Schema That Drives UI
+
+Keep the existing JSON Schema plus `ui` extension, but make it adapter output from a validated Fizz field contract:
+
+- Field types: `string`, `number`, `boolean`, `json`, `select`, `search`, `credential`, `resource_locator`, `resource_mapper`, `hidden`, and `password`.
+- Visibility: `display` rules modeled after n8n's `displayOptions`, but simpler: `show_if`, `hide_if`, `feature`, and `version`.
+- Dynamic data: `resolver: :spreadsheets`, `resolver: :columns`, `depends_on: [...]`, `params: %{...}`.
+- Auth fields: declared as `Fizz.Fields.credential/2` fields. The persisted config value remains the `"$credential"` declaration map used by workflow binding and runtime auth resolution.
+
+The Vue layer should continue to use a small component registry. `ResourceMapperField.vue`
+is the generic mapper component; Google Sheets should supply resolver metadata and labels,
+not custom component assumptions.
+
+Current state: `ResourceMapperField.vue`, `DynamicResolver`, and `Fizz.Fields` now exist. Continue tightening this contract by keeping error copy, lookup labels, dependencies, and mapper defaults in field definitions/resolver replies instead of Vue provider branches.
+
+### OTP and Context Boundaries
+
+- Supervise `Fizz.Integrations.Catalog`; store read-mostly data in ETS or `:persistent_term` plus an ETS index if reload is required in tests/dev.
+- Use Oban for trigger ingress, scheduled sync, workspace jobs, and maintenance. Workflow step attempts, retry timers, passivation, resume, and checkpointing remain owned by `Fizz.Workflows`.
+- Keep `Accounts` provider-agnostic. Provider validation and credential creation fields belong to `Integrations`/`Fizz.Fields`; token and secret persistence can remain in `Accounts.ExternalAuth` and Vault as long as those layers do not need to know about field rendering.
+- Route project LiveViews through the existing authenticated `live_session :require_authenticated_user` (`lib/fizz_web/router.ex:72`) because all workflow/project screens require login. Add a project-aware `on_mount` inside that session for project routes so LiveViews receive a resolved project scope once.
+
+## Migration Path
+
+### Phase 0 - Baseline and Guardrails
+
+Create catalog validation tests around current providers, step IDs, credential fields, and schema field support. Add a short guide for adding an integration today so the baseline friction is explicit. No runtime behavior changes.
+
+Status: shipped in `8931099`.
+
+### Phase 1 - Append-Only Catalogs and Validation
+
+Change provider/integration config extension from replacement semantics to append semantics. `:integration_providers` and `:integrations` should add to built-ins unless an explicit test-only replacement option is used. Add validation for duplicate IDs, invalid auth suffixes, missing icons, missing credential requirements, and unsupported UI components.
+
+Status: shipped in `8931099`.
+
+### Phase 2 - Introduce Unified Definitions
+
+Add provider definition improvements, field validation foundations, and `Fizz.Integrations.Catalog`. Step definitions remain the executable source of truth.
+
+Status: shipped in `8931099`.
+
+### Phase 3 - Metadata-Driven Step Registry
+
+Make `Fizz.Integrations.StepRegistry` load all executable step modules from the manifest, while integration metadata advertises action step type IDs. Google Sheets append/read are now normal step executor modules with their client/resolver/domain code under integrations.
+
+Status: shipped in `8931099`.
+
+### Phase 4 - Credential Contract and Forms
+
+Introduce provider-owned credential creation fields and credential-test metadata. Refactor API-key creation/rotation UI to render from field definitions instead of fixed `secret` params. Move or decouple `Accounts.ExternalAuth` so it no longer depends on `ProviderCatalog`. Add organization-scoped auth resolver so OpenAI helpers stop reimplementing credential ref logic.
+
+Status: shipped in `8931099`, then tightened in `d824f3d`.
+
+Follow-up result from `d824f3d`: generic slots were deleted. Credentials gained explicit declarations, option resolution, per-user bindings, and runtime credential refs. The current uncommitted field-consolidation slice deletes the dedicated credential declaration/schema modules and moves field-facing credential behavior into `Fizz.Fields.Credential`; provider/auth runtime logic stays in provider/auth modules. There are no backwards-compat shims.
+
+### Phase 5 - Dynamic UI Resolver Layer
+
+Replace editor-specific resolver inspection with `Fizz.Integrations.DynamicResolver.resolve/4`. Add schema-level `depends_on`, `display`, `resource_locator`, and `resource_mapper`. Keep `ResourceMapperField.vue` generic and move Google Sheets specifics into resolver outputs.
+
+Status: shipped in `9ef8cfd`.
+
+### Phase 6 - Execution Semantics
+
+Introduce `Fizz.Workflows.ExecutionContext` and migrate executors to it. Add normalized step errors, retry metadata, rate-limit/backoff handling, and provider/network domain declarations. Keep durable step retries in the workflow timer/worker model; Oban should not represent step attempts.
+
+Current recommendation: keep this phase split. Google Sheets append/read now execute as normal steps. Normalized step errors, retry policy metadata, and the first runner persistence/resume contract now exist: executor failures are wrapped in `Fizz.Workflows.StepExecutionError`, retryable `StepError` values are persisted on `workflow_runs.error`, and the worker schedules durable `step_retry_v1` timers that resume the same runnable with incremented attempt metadata.
+
+Shippable result: `execute(config, input, context)` remains the step runtime contract while modules progressively adopt typed fields and return normalized errors.
+
+### Phase 7 - Testing Harness and Scaffolding
+
+Add an ExUnit integration harness inspired by n8n's `NodeTestHarness`: workflow fixture input, pinned outputs, credential fixtures, `Req.Test` HTTP expectations, and direct step execution. Add a Mix task such as `mix fizz.gen.integration google.sheets` to scaffold provider, credential fields, step, resolver, tests, docs, and assets.
+
+Current result: `Fizz.IntegrationStepCase` exists for direct step definition lookup and execution. Static integration modules now group executable step modules by product, provider-owned steps live under `Fizz.Integrations..Actions`, `.Triggers`, or `.Nodes`, and providerless built-ins live under `Fizz.Integrations.Fizz.Builtins`. Empty TODO executors return a typed `not_implemented` payload through `Fizz.Integrations.PlaceholderStep`. Guardrail tests enforce that every registered step declares an integration owner and that providerless built-ins belong to the `fizz` integration.
+
+Remaining shippable result: add workflow JSON/pinned-output fixtures, credential fixtures, `Req.Test` transport harnesses, and a Mix scaffold task so new integrations have a paved path while step tests grow.
+
+## What We Are Not Borrowing From n8n
+
+- TypeScript class inheritance and `VersionedNodeType` as-is. In Elixir, use behaviours, structs, and explicit version fields.
+- Runtime package scanning from `node_modules`. Use generated manifests and supervised registries.
+- Large frontend-specific state/store architecture. Keep LiveView as the source of truth and Vue as schema-driven UI.
+- Arbitrary dynamic code loading for community integrations until product/security requirements justify it. Start with compile-time modules and generated manifests.
+- Per-node request helper objects that hide too much mutable execution state. Use explicit execution context structs and Req-backed transport modules.
+- Massive all-purpose schemas with every n8n field type. Start with the field types Fizz needs and validate them strictly.
+
+## Open Questions
+
+1. Should integrations eventually be installable outside the repo, or is "thousands of integrations" still a monorepo/catalog problem for now?
+2. Should WorkOS Pipes remain the only OAuth implementation, or do we need first-party OAuth flows for providers not supported by WorkOS?
+3. Do workflow definitions need long-term per-step version pinning before public launch, or can we migrate existing drafts/runs in place for now?
+4. Should `credential_bindings` eventually be renamed or moved after launch, or is the current workflow-specific persistence name acceptable once field declaration ownership is fully under `Fizz.Fields.Credential`?
+5. Which integration family should be the first migration after Google Sheets: Slack, GitHub, Gmail, or AI providers?
+6. Do we need provider-specific rate limits and retry policies in v1 of the refactor, or only normalized error/backoff handling?
+7. How much client-side Vue test coverage do we want for schema rendering versus LiveView integration tests and direct step ExUnit tests?
diff --git a/test/fizz/accounts/external_auth_boundary_test.exs b/test/fizz/accounts/external_auth_boundary_test.exs
new file mode 100644
index 0000000..b0c618b
--- /dev/null
+++ b/test/fizz/accounts/external_auth_boundary_test.exs
@@ -0,0 +1,11 @@
+defmodule Fizz.Accounts.ExternalAuthBoundaryTest do
+ use ExUnit.Case, async: true
+
+ test "accounts auth primitives do not depend on integration provider catalog ownership" do
+ external_auth_source = File.read!("lib/fizz/accounts/external_auth.ex")
+ api_credential_source = File.read!("lib/fizz/accounts/api_credential.ex")
+
+ refute external_auth_source =~ "Fizz.Integrations.Auth.ProviderCatalog"
+ refute api_credential_source =~ "Fizz.Integrations.Auth.ProviderCatalog"
+ end
+end
diff --git a/test/fizz/accounts/organization_identity_test.exs b/test/fizz/accounts/organization_identity_test.exs
index 517d10a..3895b6e 100644
--- a/test/fizz/accounts/organization_identity_test.exs
+++ b/test/fizz/accounts/organization_identity_test.exs
@@ -2,7 +2,7 @@ defmodule Fizz.Accounts.OrganizationIdentityTest do
use Fizz.DataCase
alias Fizz.Accounts
- alias Fizz.Accounts.{Scope, WorkspaceMembership}
+ alias Fizz.Accounts.{Scope, ProjectMembership}
alias Fizz.Repo
import Fizz.AccountsFixtures
@@ -42,7 +42,7 @@ defmodule Fizz.Accounts.OrganizationIdentityTest do
:ok
end
- describe "organization/workspace primitives" do
+ describe "organization/project primitives" do
setup do
user = user_fixture()
@@ -82,30 +82,30 @@ defmodule Fizz.Accounts.OrganizationIdentityTest do
assert request[:url] == "/user_management/organization_memberships"
end
- test "create_workspace/2 creates admin workspace membership for creator", %{
+ test "create_project/2 creates admin project membership for creator", %{
owner_scope: owner_scope
} do
- {:ok, workspace} = Accounts.create_workspace(owner_scope, %{name: "Client A"})
+ {:ok, project} = Accounts.create_project(owner_scope, %{name: "Client A"})
- assert workspace.workos_organization_id == "org_123"
+ assert project.workos_organization_id == "org_123"
- assert %WorkspaceMembership{role: :admin} =
- Repo.get_by(WorkspaceMembership,
- workspace_id: workspace.id,
+ assert %ProjectMembership{role: :admin} =
+ Repo.get_by(ProjectMembership,
+ project_id: project.id,
user_id: owner_scope.user.id
)
end
- test "list_workspaces/1 limits members to assigned workspaces", %{
+ test "list_projects/1 limits members to assigned projects", %{
owner_scope: owner_scope
} do
- {:ok, workspace_a} = Accounts.create_workspace(owner_scope, %{name: "Client A"})
- {:ok, _workspace_b} = Accounts.create_workspace(owner_scope, %{name: "Client B"})
+ {:ok, project_a} = Accounts.create_project(owner_scope, %{name: "Client A"})
+ {:ok, _project_b} = Accounts.create_project(owner_scope, %{name: "Client B"})
user = user_fixture()
- {:ok, _workspace_membership} =
- Accounts.add_workspace_member(owner_scope, workspace_a.id, user, %{role: :member})
+ {:ok, _project_membership} =
+ Accounts.add_project_member(owner_scope, project_a.id, user, %{role: :member})
Process.put(:workos_http_responses, [
{:ok,
@@ -125,9 +125,9 @@ defmodule Fizz.Accounts.OrganizationIdentityTest do
])
{:ok, user_scope} = Accounts.build_scope(Scope.for_user(user), "org_123")
- {:ok, workspaces} = Accounts.list_workspaces(user_scope)
+ {:ok, projects} = Accounts.list_projects(user_scope)
- assert Enum.map(workspaces, & &1.id) == [workspace_a.id]
+ assert Enum.map(projects, & &1.id) == [project_a.id]
end
test "organization members cannot manage organization membership", %{owner_scope: owner_scope} do
diff --git a/test/fizz/accounts/scope_test.exs b/test/fizz/accounts/scope_test.exs
index 6b26053..8e0a081 100644
--- a/test/fizz/accounts/scope_test.exs
+++ b/test/fizz/accounts/scope_test.exs
@@ -1,10 +1,7 @@
defmodule Fizz.Accounts.ScopeTest do
use ExUnit.Case, async: true
- alias Fizz.Accounts.Workspace
- alias Fizz.Executions.Execution
alias Fizz.Accounts.Scope
- alias Fizz.Workflows.Workflow
test "for_user/1 returns nil for anonymous user" do
assert Scope.for_user(nil) == nil
@@ -25,79 +22,11 @@ defmodule Fizz.Accounts.ScopeTest do
refute Scope.organization_admin?(member_scope)
end
- test "workspace_admin?/1 checks workspace admin role" do
- admin_scope = %Scope{} |> Scope.with_workspace_role(:admin)
- member_scope = %Scope{} |> Scope.with_workspace_role(:member)
+ test "project_admin?/1 checks project admin role" do
+ admin_scope = %Scope{} |> Scope.with_project_role(:admin)
+ member_scope = %Scope{} |> Scope.with_project_role(:member)
- assert Scope.workspace_admin?(admin_scope)
- refute Scope.workspace_admin?(member_scope)
- end
-
- test "can_view_workflow?/2 allows viewer/member/admin in same workspace" do
- scope = scope_for_workspace("workspace_123", :viewer)
- workflow = %Workflow{workspace_id: "workspace_123"}
-
- assert Scope.can_view_workflow?(scope, workflow)
- end
-
- test "can_edit_workflow?/2 allows member/admin in same workspace and denies viewer" do
- workflow = %Workflow{workspace_id: "workspace_123"}
- viewer_scope = scope_for_workspace("workspace_123", :viewer)
- member_scope = scope_for_workspace("workspace_123", :member)
- admin_scope = scope_for_workspace("workspace_123", :admin)
-
- refute Scope.can_edit_workflow?(viewer_scope, workflow)
- assert Scope.can_edit_workflow?(member_scope, workflow)
- assert Scope.can_edit_workflow?(admin_scope, workflow)
- end
-
- test "can_view_workflow?/2 and can_edit_workflow?/2 deny cross-workspace access" do
- scope = scope_for_workspace("workspace_abc", :admin)
- workflow = %Workflow{workspace_id: "workspace_xyz"}
-
- refute Scope.can_view_workflow?(scope, workflow)
- refute Scope.can_edit_workflow?(scope, workflow)
- end
-
- test "organization admins can edit workflows in the active workspace even without workspace role" do
- scope =
- %Scope{}
- |> Scope.with_workspace(%Workspace{id: "workspace_123"})
- |> Scope.with_organization_role(:owner)
- |> Map.put(:user, %{id: "user_123"})
- |> Map.put(:actor, :user)
-
- workflow = %Workflow{workspace_id: "workspace_123"}
-
- assert Scope.can_edit_workflow?(scope, workflow)
- assert Scope.can_view_workflow?(scope, workflow)
- end
-
- test "can_view_execution?/2 delegates to workflow access" do
- scope = scope_for_workspace("workspace_123", :viewer)
- execution = %Execution{workflow: %Workflow{workspace_id: "workspace_123"}}
-
- assert Scope.can_view_execution?(scope, execution)
- end
-
- test "can_create_execution?/3 allows nil scope only for production execution in scoped workflows" do
- scoped_workflow = %Workflow{workspace_id: "workspace_123"}
-
- assert Scope.can_create_execution?(nil, scoped_workflow, :production)
- refute Scope.can_create_execution?(nil, scoped_workflow, :preview)
- end
-
- defp scope_for_workspace(workspace_id, workspace_role) do
- %Scope{}
- |> Scope.with_workspace(%Workspace{
- id: workspace_id,
- name: "Workspace",
- slug: "workspace",
- workos_organization_id: "org_123"
- })
- |> Scope.with_workspace_role(workspace_role)
- |> Scope.with_organization_role(:member)
- |> Map.put(:user, %{id: "user_123"})
- |> Map.put(:actor, :user)
+ assert Scope.project_admin?(admin_scope)
+ refute Scope.project_admin?(member_scope)
end
end
diff --git a/test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs b/test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs
deleted file mode 100644
index ee5309a..0000000
--- a/test/fizz/collaboration/edit_session/inversion_commit_drag_layout_test.exs
+++ /dev/null
@@ -1,153 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.InversionCommitDragLayoutTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Collaboration.EditorState
- alias Fizz.Collaboration.EditSession.{Inversion, Operations}
- alias Fizz.Workflows.Embeds.{NodeGroup, Step}
- alias Fizz.Workflows.WorkflowDraft
-
- describe "compute_inverse/3 for :commit_drag_layout" do
- test "captures previous bounds, step positions, and memberships" do
- draft = base_draft()
-
- operation = %{
- type: :commit_drag_layout,
- payload: %{
- txn_id: "txn-inverse-1",
- base_seq: 21,
- groups: [
- %{group_id: "group_a", position: %{x: 130, y: 140, width: 420, height: 320}},
- %{group_id: "group_b", position: %{x: 560, y: 190, width: 260, height: 210}}
- ],
- step_positions: %{
- "step_a" => %{x: 40, y: 30},
- "step_c" => %{x: 55, y: 65}
- },
- group_id_by_step_id: %{
- "step_b" => nil,
- "step_c" => "group_b"
- }
- }
- }
-
- assert {:ok, [inverse]} = Inversion.compute_inverse(draft, %EditorState{}, operation)
- assert inverse.type == :commit_drag_layout
- assert inverse.payload.txn_id == "txn-inverse-1"
- assert inverse.payload.base_seq == 21
-
- assert inverse.payload.groups == [
- %{group_id: "group_a", position: %{x: 100, y: 100, width: 320, height: 240}},
- %{group_id: "group_b", position: %{x: 540, y: 160, width: 280, height: 220}}
- ]
-
- assert inverse.payload.step_positions == %{
- "step_a" => %{x: 60, y: 60},
- "step_c" => %{x: 860, y: 260}
- }
-
- assert inverse.payload.group_id_by_step_id == %{
- "step_b" => "group_a",
- "step_c" => nil
- }
- end
-
- test "inverse operation restores previous draft state" do
- draft = base_draft()
- operation = move_operation()
-
- assert {:ok, [inverse]} = Inversion.compute_inverse(draft, %EditorState{}, operation)
- assert {:ok, moved_draft} = Operations.apply(draft, operation)
- assert {:ok, restored_draft} = Operations.apply(moved_draft, inverse)
-
- assert snapshot(restored_draft) == snapshot(draft)
- end
- end
-
- defp move_operation do
- %{
- type: :commit_drag_layout,
- payload: %{
- txn_id: "txn-inverse-2",
- base_seq: 22,
- groups: [
- %{group_id: "group_a", position: %{x: 130, y: 140, width: 420, height: 320}},
- %{group_id: "group_b", position: %{x: 560, y: 190, width: 260, height: 210}}
- ],
- step_positions: %{
- "step_a" => %{x: 40, y: 30},
- "step_c" => %{x: 55, y: 65}
- },
- group_id_by_step_id: %{
- "step_b" => nil,
- "step_c" => "group_b"
- }
- }
- }
- end
-
- defp snapshot(%WorkflowDraft{} = draft) do
- %{
- groups:
- draft.groups
- |> List.wrap()
- |> Enum.map(fn group ->
- %{
- id: group.id,
- step_ids: group.step_ids,
- output_step_id: group.output_step_id,
- position: group.position
- }
- end),
- steps:
- draft.steps
- |> List.wrap()
- |> Enum.map(fn step ->
- %{id: step.id, position: step.position}
- end)
- }
- end
-
- defp base_draft do
- %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("step_a", %{x: 60, y: 60}),
- step("step_b", %{x: 140, y: 110}),
- step("step_c", %{x: 860, y: 260}),
- step("step_d", %{x: 50, y: 70})
- ],
- connections: [],
- groups: [
- group("group_a", ["step_a", "step_b"], "step_a", %{
- x: 100,
- y: 100,
- width: 320,
- height: 240
- }),
- group("group_b", ["step_d"], "step_d", %{x: 540, y: 160, width: 280, height: 220})
- ]
- }
- end
-
- defp step(id, position) do
- %Step{
- id: id,
- type_id: "math",
- name: id,
- config: %{},
- position: position
- }
- end
-
- defp group(id, step_ids, output_step_id, position) do
- %NodeGroup{
- id: id,
- name: id,
- step_ids: step_ids,
- output_step_id: output_step_id,
- position: position,
- color: nil,
- collapsed: false
- }
- end
-end
diff --git a/test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs b/test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs
deleted file mode 100644
index 171c5ee..0000000
--- a/test/fizz/collaboration/edit_session/inversion_update_step_positions_test.exs
+++ /dev/null
@@ -1,70 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.InversionUpdateStepPositionsTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Collaboration.EditorState
- alias Fizz.Collaboration.EditSession.Inversion
- alias Fizz.Workflows.WorkflowDraft
- alias Fizz.Workflows.Embeds.Step
-
- describe "compute_inverse/3 for :update_step_positions" do
- test "captures previous positions for all updated steps" do
- draft = base_draft()
-
- operation = %{
- type: :update_step_positions,
- payload: %{
- step_positions: %{
- "step_a" => %{x: 100, y: 200},
- "step_b" => %{x: 300, y: 400}
- }
- }
- }
-
- assert {:ok, [inverse]} = Inversion.compute_inverse(draft, %EditorState{}, operation)
- assert inverse.type == :update_step_positions
-
- assert inverse.payload == %{
- step_positions: %{
- "step_a" => %{x: 10, y: 20},
- "step_b" => %{x: 30, y: 40}
- }
- }
- end
-
- test "fails when payload includes unknown step ids" do
- draft = base_draft()
-
- operation = %{
- type: :update_step_positions,
- payload: %{step_positions: %{"missing_step" => %{x: 100, y: 200}}}
- }
-
- assert {:error, {:steps_not_found, missing_ids}} =
- Inversion.compute_inverse(draft, %EditorState{}, operation)
-
- assert "missing_step" in missing_ids
- end
- end
-
- defp base_draft do
- %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("step_a", %{x: 10, y: 20}),
- step("step_b", %{x: 30, y: 40})
- ],
- connections: [],
- groups: []
- }
- end
-
- defp step(id, position) do
- %Step{
- id: id,
- type_id: "math",
- name: id,
- config: %{},
- position: position
- }
- end
-end
diff --git a/test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs b/test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs
deleted file mode 100644
index 9e8400d..0000000
--- a/test/fizz/collaboration/edit_session/operations_add_step_group_resize_test.exs
+++ /dev/null
@@ -1,107 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.OperationsAddStepGroupResizeTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Collaboration.EditSession.Operations
- alias Fizz.Workflows.Embeds.{NodeGroup, Step}
- alias Fizz.Workflows.WorkflowDraft
-
- test "add_step into group expands group bounds when step overflows content area" do
- draft = base_draft()
-
- operation = %{
- type: :add_step,
- payload: %{
- step: %{
- id: "step_new",
- type_id: "math",
- name: "step_new",
- config: %{},
- position: %{x: 320, y: 200}
- },
- group_id: "group_a"
- }
- }
-
- assert :ok = Operations.validate(draft, operation)
- assert {:ok, updated_draft} = Operations.apply(draft, operation)
-
- assert group_step_ids_for(updated_draft, "group_a") == ["step_a", "step_new"]
-
- assert group_position_for(updated_draft, "group_a") == %{
- x: 100,
- y: 100,
- width: 494,
- height: 302
- }
- end
-
- test "add_step expansion uses provided step_size when present" do
- draft = base_draft()
-
- operation = %{
- type: :add_step,
- payload: %{
- step: %{
- id: "step_large",
- type_id: "math",
- name: "step_large",
- config: %{},
- position: %{x: 320, y: 200}
- },
- group_id: "group_a",
- step_size: %{width: 300, height: 100}
- }
- }
-
- assert :ok = Operations.validate(draft, operation)
- assert {:ok, updated_draft} = Operations.apply(draft, operation)
-
- assert group_position_for(updated_draft, "group_a") == %{
- x: 100,
- y: 100,
- width: 644,
- height: 352
- }
- end
-
- defp base_draft do
- %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- %Step{
- id: "step_a",
- type_id: "math",
- name: "step_a",
- config: %{},
- position: %{x: 40, y: 40}
- }
- ],
- connections: [],
- groups: [
- %NodeGroup{
- id: "group_a",
- name: "Group A",
- step_ids: ["step_a"],
- output_step_id: "step_a",
- position: %{x: 100, y: 100, width: 360, height: 240},
- color: nil,
- collapsed: false
- }
- ]
- }
- end
-
- defp group_position_for(%WorkflowDraft{} = draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> group.id == group_id end)
- |> Map.get(:position)
- end
-
- defp group_step_ids_for(%WorkflowDraft{} = draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> group.id == group_id end)
- |> Map.get(:step_ids)
- end
-end
diff --git a/test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs b/test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs
deleted file mode 100644
index 3e2e765..0000000
--- a/test/fizz/collaboration/edit_session/operations_commit_drag_layout_test.exs
+++ /dev/null
@@ -1,225 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.OperationsCommitDragLayoutTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Collaboration.EditSession.Operations
- alias Fizz.Workflows.Embeds.{NodeGroup, Step}
- alias Fizz.Workflows.WorkflowDraft
-
- describe "apply/2 for :commit_drag_layout" do
- test "persists expanded group bounds from drop commit" do
- draft = base_draft()
-
- operation = %{
- type: :commit_drag_layout,
- payload: %{
- txn_id: "txn-expand-1",
- base_seq: 3,
- groups: [
- %{
- group_id: "group_a",
- position: %{x: 120, y: 140, width: 460, height: 320}
- }
- ],
- step_positions: %{},
- group_id_by_step_id: %{}
- }
- }
-
- assert :ok = Operations.validate(draft, operation)
- assert {:ok, updated_draft} = Operations.apply(draft, operation)
-
- assert group_position_for(updated_draft, "group_a") == %{
- x: 120,
- y: 140,
- width: 460,
- height: 320
- }
- end
-
- test "keeps child absolute positions stable when group origin shifts" do
- draft = base_draft()
-
- before_absolute =
- %{
- "step_a" => absolute_position_for(draft, "step_a"),
- "step_b" => absolute_position_for(draft, "step_b"),
- "step_c" => absolute_position_for(draft, "step_c")
- }
-
- operation = %{
- type: :commit_drag_layout,
- payload: %{
- txn_id: "txn-origin-shift-1",
- base_seq: 10,
- groups: [
- %{
- group_id: "group_a",
- position: %{x: 80, y: 90, width: 360, height: 260}
- }
- ],
- step_positions: %{
- "step_a" => %{x: 80, y: 70}
- },
- group_id_by_step_id: %{}
- }
- }
-
- assert :ok = Operations.validate(draft, operation)
- assert {:ok, updated_draft} = Operations.apply(draft, operation)
-
- assert absolute_position_for(updated_draft, "step_a") == before_absolute["step_a"]
- assert absolute_position_for(updated_draft, "step_b") == before_absolute["step_b"]
- assert absolute_position_for(updated_draft, "step_c") == before_absolute["step_c"]
- end
-
- test "applies memberships, bounds, and positions coherently in one operation" do
- draft = base_draft()
-
- operation = %{
- type: :commit_drag_layout,
- payload: %{
- txn_id: "txn-coherent-1",
- base_seq: 14,
- groups: [
- %{
- group_id: "group_a",
- position: %{x: 95, y: 105, width: 380, height: 280}
- },
- %{
- group_id: "group_b",
- position: %{x: 520, y: 120, width: 300, height: 220}
- }
- ],
- step_positions: %{
- "step_b" => %{x: 10, y: 15},
- "step_c" => %{x: 65, y: 75}
- },
- group_id_by_step_id: %{
- "step_b" => nil,
- "step_c" => "group_a"
- }
- }
- }
-
- assert :ok = Operations.validate(draft, operation)
- assert {:ok, updated_draft} = Operations.apply(draft, operation)
-
- assert group_position_for(updated_draft, "group_a") == %{
- x: 95,
- y: 105,
- width: 380,
- height: 280
- }
-
- assert group_position_for(updated_draft, "group_b") == %{
- x: 520,
- y: 120,
- width: 300,
- height: 220
- }
-
- assert position_for(updated_draft, "step_b") == %{x: 10, y: 15}
- assert position_for(updated_draft, "step_c") == %{x: 65, y: 75}
-
- assert group_step_ids_for(updated_draft, "group_a") |> Enum.sort() == ["step_a", "step_c"]
- assert group_step_ids_for(updated_draft, "group_b") == ["step_d"]
- assert group_output_step_for(updated_draft, "group_b") == "step_d"
- end
- end
-
- defp base_draft do
- %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("step_a", %{x: 60, y: 60}),
- step("step_b", %{x: 140, y: 110}),
- step("step_c", %{x: 860, y: 260}),
- step("step_d", %{x: 50, y: 70})
- ],
- connections: [],
- groups: [
- group("group_a", ["step_a"], "step_a", %{x: 100, y: 100, width: 320, height: 240}),
- group("group_b", ["step_b", "step_d"], "step_b", %{
- x: 500,
- y: 100,
- width: 280,
- height: 220
- })
- ]
- }
- end
-
- defp step(id, position) do
- %Step{
- id: id,
- type_id: "math",
- name: id,
- config: %{},
- position: position
- }
- end
-
- defp group(id, step_ids, output_step_id, position) do
- %NodeGroup{
- id: id,
- name: id,
- step_ids: step_ids,
- output_step_id: output_step_id,
- position: position,
- color: nil,
- collapsed: false
- }
- end
-
- defp position_for(%WorkflowDraft{} = draft, step_id) do
- draft.steps
- |> Enum.find(fn step -> step.id == step_id end)
- |> Map.get(:position)
- end
-
- defp group_position_for(%WorkflowDraft{} = draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> group.id == group_id end)
- |> Map.get(:position)
- end
-
- defp group_step_ids_for(%WorkflowDraft{} = draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> group.id == group_id end)
- |> Map.get(:step_ids)
- end
-
- defp group_output_step_for(%WorkflowDraft{} = draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> group.id == group_id end)
- |> Map.get(:output_step_id)
- end
-
- defp absolute_position_for(%WorkflowDraft{} = draft, step_id) do
- step_position = position_for(draft, step_id)
-
- case find_group_for_step(draft, step_id) do
- nil ->
- step_position
-
- group ->
- group_position = Map.get(group, :position) || %{}
-
- %{
- x: (Map.get(group_position, :x) || 0) + (Map.get(step_position, :x) || 0),
- y: (Map.get(group_position, :y) || 0) + (Map.get(step_position, :y) || 0)
- }
- end
- end
-
- defp find_group_for_step(%WorkflowDraft{} = draft, step_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group ->
- step_id in (Map.get(group, :step_ids) || [])
- end)
- end
-end
diff --git a/test/fizz/collaboration/edit_session/operations_subnodes_test.exs b/test/fizz/collaboration/edit_session/operations_subnodes_test.exs
deleted file mode 100644
index 726d94a..0000000
--- a/test/fizz/collaboration/edit_session/operations_subnodes_test.exs
+++ /dev/null
@@ -1,104 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.OperationsSubnodesTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Collaboration.EditSession.Operations
- alias Fizz.Workflows.WorkflowDraft
- alias Fizz.Workflows.Embeds.{Connection, Step}
-
- describe "validate/2 add_connection with slot constraints" do
- test "rejects unknown target_input slot" do
- draft = base_draft()
-
- operation = %{
- type: :add_connection,
- payload: %{
- connection: %{
- id: "c_unknown",
- source_step_id: "model",
- target_step_id: "agent",
- source_output: "main",
- target_input: "unknown_slot"
- }
- }
- }
-
- assert {:error, {:invalid_target_input, "unknown_slot"}} =
- Operations.validate(draft, operation)
- end
-
- test "rejects source step type not accepted by slot" do
- draft = base_draft()
-
- operation = %{
- type: :add_connection,
- payload: %{
- connection: %{
- id: "c_bad_type",
- source_step_id: "math",
- target_step_id: "agent",
- source_output: "main",
- target_input: "model"
- }
- }
- }
-
- assert {:error, {:slot_disallows_source_type, "model", "math"}} =
- Operations.validate(draft, operation)
- end
-
- test "rejects additional connection to one-cardinality slot" do
- draft =
- base_draft()
- |> Map.put(:connections, [
- %Connection{
- id: "existing_model",
- source_step_id: "model",
- source_output: "main",
- target_step_id: "agent",
- target_input: "model"
- }
- ])
-
- operation = %{
- type: :add_connection,
- payload: %{
- connection: %{
- id: "c_extra_model",
- source_step_id: "model_alt",
- target_step_id: "agent",
- source_output: "main",
- target_input: "model"
- }
- }
- }
-
- assert {:error, {:slot_cardinality_exceeded, "model"}} =
- Operations.validate(draft, operation)
- end
- end
-
- defp base_draft do
- %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("agent", "ai_agent"),
- step("model", "openai_model"),
- step("model_alt", "anthropic_model"),
- step("prompt", "ai_prompt_template"),
- step("math", "math")
- ],
- connections: [],
- groups: []
- }
- end
-
- defp step(id, type_id) do
- %Step{
- id: id,
- type_id: type_id,
- name: id,
- config: %{},
- position: %{}
- }
- end
-end
diff --git a/test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs b/test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs
deleted file mode 100644
index 9f3d5e0..0000000
--- a/test/fizz/collaboration/edit_session/operations_update_step_positions_test.exs
+++ /dev/null
@@ -1,87 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.OperationsUpdateStepPositionsTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Collaboration.EditSession.Operations
- alias Fizz.Workflows.WorkflowDraft
- alias Fizz.Workflows.Embeds.Step
-
- describe "validate/2 for :update_step_positions" do
- test "accepts known step ids" do
- draft = base_draft()
-
- operation = %{
- type: :update_step_positions,
- payload: %{
- step_positions: %{
- "step_a" => %{x: 100, y: 200},
- "step_b" => %{x: 300, y: 400}
- }
- }
- }
-
- assert :ok = Operations.validate(draft, operation)
- end
-
- test "rejects missing step ids" do
- draft = base_draft()
-
- operation = %{
- type: :update_step_positions,
- payload: %{step_positions: %{"missing_step" => %{x: 100, y: 200}}}
- }
-
- assert {:error, {:steps_not_found, missing_ids}} = Operations.validate(draft, operation)
- assert "missing_step" in missing_ids
- end
- end
-
- describe "apply/2 for :update_step_positions" do
- test "updates all provided step positions in one operation" do
- draft = base_draft()
-
- operation = %{
- type: :update_step_positions,
- payload: %{
- step_positions: %{
- "step_a" => %{x: 110, y: 210},
- "step_b" => %{x: 310, y: 410}
- }
- }
- }
-
- assert {:ok, updated_draft} = Operations.apply(draft, operation)
- assert position_for(updated_draft, "step_a") == %{x: 110, y: 210}
- assert position_for(updated_draft, "step_b") == %{x: 310, y: 410}
- assert position_for(updated_draft, "step_c") == %{x: 50, y: 60}
- end
- end
-
- defp base_draft do
- %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("step_a", %{x: 10, y: 20}),
- step("step_b", %{x: 30, y: 40}),
- step("step_c", %{x: 50, y: 60})
- ],
- connections: [],
- groups: []
- }
- end
-
- defp step(id, position) do
- %Step{
- id: id,
- type_id: "math",
- name: id,
- config: %{},
- position: position
- }
- end
-
- defp position_for(%WorkflowDraft{} = draft, step_id) do
- draft.steps
- |> Enum.find(fn step -> step.id == step_id end)
- |> Map.get(:position)
- end
-end
diff --git a/test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs b/test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs
deleted file mode 100644
index cbbf138..0000000
--- a/test/fizz/collaboration/edit_session/server_commit_drag_layout_test.exs
+++ /dev/null
@@ -1,167 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.ServerCommitDragLayoutTest do
- use Fizz.DataCase, async: false
-
- import Fizz.AccountsFixtures
-
- alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership}
- alias Fizz.Collaboration.EditSession.Server
- alias Fizz.Repo
- alias Fizz.Workflows
-
- test "overlapping drag layout commits converge by server seq order" do
- user_one = user_fixture()
- user_two = user_fixture()
- workspace = workspace_fixture!(user_one, [user_two])
- scope = scoped_workspace_access(user_one, workspace)
- workflow = workflow_fixture!(scope)
- _server_pid = start_supervised!({Server, workflow_id: workflow.id, scope: scope})
-
- op_one =
- commit_drag_layout_operation(user_one.id, "txn-server-1", %{
- group: %{x: 120, y: 140, width: 420, height: 300},
- step: %{x: 20, y: 25}
- })
-
- op_two =
- commit_drag_layout_operation(user_two.id, "txn-server-2", %{
- group: %{x: 180, y: 220, width: 460, height: 340},
- step: %{x: 65, y: 70}
- })
-
- task_one = Task.async(fn -> Server.apply_operation(workflow.id, op_one) end)
- task_two = Task.async(fn -> Server.apply_operation(workflow.id, op_two) end)
-
- assert {:ok, %{seq: seq_one, status: :applied}} = Task.await(task_one)
- assert {:ok, %{seq: seq_two, status: :applied}} = Task.await(task_two)
- assert seq_one != seq_two
-
- assert {:ok, %{type: :full_sync, draft: draft, seq: final_seq}} =
- Server.get_sync_state(workflow.id)
-
- assert final_seq == max(seq_one, seq_two)
-
- expected_payload =
- if seq_one > seq_two do
- op_one.payload
- else
- op_two.payload
- end
-
- expected_group_position =
- expected_payload
- |> Map.get(:groups)
- |> List.first()
- |> Map.get(:position)
-
- expected_step_position =
- expected_payload
- |> Map.get(:step_positions)
- |> Map.get("step_a")
-
- assert group_position_for(draft, "group_a") == expected_group_position
- assert step_position_for(draft, "step_a") == expected_step_position
- end
-
- defp commit_drag_layout_operation(user_id, txn_id, %{group: group_position, step: step_position}) do
- %{
- id: Ecto.UUID.generate(),
- type: :commit_drag_layout,
- payload: %{
- txn_id: txn_id,
- base_seq: 0,
- groups: [%{group_id: "group_a", position: group_position}],
- step_positions: %{"step_a" => step_position},
- group_id_by_step_id: %{}
- },
- user_id: user_id,
- client_seq: nil
- }
- end
-
- defp group_position_for(draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> Map.get(group, :id) == group_id end)
- |> Map.get(:position)
- end
-
- defp step_position_for(draft, step_id) do
- draft.steps
- |> List.wrap()
- |> Enum.find(fn step -> Map.get(step, :id) == step_id end)
- |> Map.get(:position)
- end
-
- defp workflow_fixture!(scope) do
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "Server Commit Layout #{System.unique_integer([:positive])}",
- description: "server test"
- })
-
- {:ok, _draft} =
- Workflows.update_workflow_draft(scope, workflow, %{
- steps: [
- %{
- id: "step_a",
- type_id: "math",
- name: "Step A",
- config: %{},
- position: %{x: 60, y: 60}
- },
- %{
- id: "step_b",
- type_id: "math",
- name: "Step B",
- config: %{},
- position: %{x: 150, y: 130}
- }
- ],
- connections: [],
- groups: [
- %{
- id: "group_a",
- name: "Group A",
- step_ids: ["step_a", "step_b"],
- output_step_id: "step_a",
- position: %{x: 100, y: 100, width: 320, height: 240},
- color: nil,
- collapsed: false
- }
- ]
- })
-
- workflow
- end
-
- defp workspace_fixture!(owner_user, additional_users) do
- unique = System.unique_integer([:positive])
-
- workspace =
- %Workspace{}
- |> Workspace.changeset(%{
- name: "Server Commit Workspace #{unique}",
- slug: "server-commit-workspace-#{unique}",
- workos_organization_id: "org_#{unique}"
- })
- |> Repo.insert!()
-
- member_ids = [owner_user.id | Enum.map(additional_users, & &1.id)]
-
- Enum.each(member_ids, fn user_id ->
- %WorkspaceMembership{workspace_id: workspace.id, user_id: user_id}
- |> WorkspaceMembership.changeset(%{role: :admin})
- |> Repo.insert!()
- end)
-
- workspace
- end
-
- defp scoped_workspace_access(user, workspace) do
- Scope.for_user(user)
- |> Scope.with_organization_id(workspace.workos_organization_id)
- |> Scope.with_organization_role(:owner)
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
- end
-end
diff --git a/test/fizz/collaboration/edit_session/server_persistence_test.exs b/test/fizz/collaboration/edit_session/server_persistence_test.exs
deleted file mode 100644
index fc4f548..0000000
--- a/test/fizz/collaboration/edit_session/server_persistence_test.exs
+++ /dev/null
@@ -1,86 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.ServerPersistenceTest do
- use Fizz.DataCase, async: false
-
- import Ecto.Query
- import Fizz.AccountsFixtures
-
- alias Fizz.Accounts.Scope
- alias Fizz.Collaboration.EditSession.Server
- alias Fizz.Repo
- alias Fizz.Workflows
- alias Fizz.Workflows.WorkflowDraft
-
- test "persist_sync refreshes the session draft timestamp from the database" do
- user = user_fixture()
- org_scope = organization_scope_fixture(user: user)
- workspace = workspace_fixture(org_scope)
-
- scope =
- org_scope
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
-
- workflow = workflow_fixture!(scope)
- stale_updated_at = DateTime.add(DateTime.utc_now(), -3600, :second)
-
- Repo.update_all(
- from(d in WorkflowDraft, where: d.workflow_id == ^workflow.id),
- set: [updated_at: stale_updated_at]
- )
-
- _server_pid = start_supervised!({Server, workflow_id: workflow.id, scope: scope})
-
- assert {:ok, %{type: :full_sync, draft: initial_draft}} = Server.get_sync_state(workflow.id)
- assert DateTime.compare(initial_draft.updated_at, stale_updated_at) == :eq
-
- assert {:ok, %{status: :applied}} =
- Server.apply_operation(workflow.id, update_step_position_operation(user.id))
-
- assert :ok = Server.persist_sync(workflow.id)
-
- persisted_draft = Repo.get_by!(WorkflowDraft, workflow_id: workflow.id)
-
- assert {:ok, %{type: :full_sync, draft: synced_draft}} = Server.get_sync_state(workflow.id)
- assert DateTime.compare(persisted_draft.updated_at, stale_updated_at) == :gt
- assert DateTime.compare(synced_draft.updated_at, stale_updated_at) == :gt
- assert DateTime.compare(synced_draft.updated_at, persisted_draft.updated_at) == :eq
- end
-
- defp update_step_position_operation(user_id) do
- %{
- id: Ecto.UUID.generate(),
- type: :update_step_position,
- payload: %{
- step_id: "step_a",
- position: %{x: 180, y: 220}
- },
- user_id: user_id,
- client_seq: nil
- }
- end
-
- defp workflow_fixture!(scope) do
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "Persisted Draft #{System.unique_integer([:positive])}",
- description: "persistence test"
- })
-
- {:ok, _draft} =
- Workflows.update_workflow_draft(scope, workflow, %{
- steps: [
- %{
- id: "step_a",
- type_id: "math",
- name: "Step A",
- config: %{},
- position: %{x: 60, y: 60}
- }
- ],
- connections: [],
- groups: []
- })
-
- workflow
- end
-end
diff --git a/test/fizz/collaboration/edit_session/supervisor_test.exs b/test/fizz/collaboration/edit_session/supervisor_test.exs
deleted file mode 100644
index a6e84b8..0000000
--- a/test/fizz/collaboration/edit_session/supervisor_test.exs
+++ /dev/null
@@ -1,58 +0,0 @@
-defmodule Fizz.Collaboration.EditSession.SupervisorTest do
- use Fizz.DataCase, async: false
-
- import Fizz.AccountsFixtures
-
- alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership}
- alias Fizz.Collaboration.EditSession.Supervisor
- alias Fizz.Repo
- alias Fizz.Workflows
-
- test "ensure_session creates a draft when one does not exist" do
- user = user_fixture()
- workspace = workspace_fixture!(user)
- scope = scoped_workspace_access(user, workspace)
-
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "Draftless #{System.unique_integer([:positive])}",
- description: "Workflow without a draft row"
- })
-
- assert {:error, :not_found} = Workflows.get_draft(scope, workflow.id)
-
- assert {:ok, pid} = Supervisor.ensure_session(scope, workflow.id)
- assert {:ok, _draft} = Workflows.get_draft(scope, workflow.id)
-
- ref = Process.monitor(pid)
- assert :ok = Supervisor.stop_session(workflow.id)
- assert_receive {:DOWN, ^ref, :process, ^pid, _reason}
- end
-
- defp workspace_fixture!(user) do
- unique = System.unique_integer([:positive])
-
- workspace =
- %Workspace{}
- |> Workspace.changeset(%{
- name: "Edit Session Workspace #{unique}",
- slug: "edit-session-workspace-#{unique}",
- workos_organization_id: "org_#{unique}"
- })
- |> Repo.insert!()
-
- %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id}
- |> WorkspaceMembership.changeset(%{role: :admin})
- |> Repo.insert!()
-
- workspace
- end
-
- defp scoped_workspace_access(user, workspace) do
- Scope.for_user(user)
- |> Scope.with_organization_id(workspace.workos_organization_id)
- |> Scope.with_organization_role(:owner)
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
- end
-end
diff --git a/test/fizz/executions/cancel_active_step_executions_test.exs b/test/fizz/executions/cancel_active_step_executions_test.exs
deleted file mode 100644
index 06aef56..0000000
--- a/test/fizz/executions/cancel_active_step_executions_test.exs
+++ /dev/null
@@ -1,128 +0,0 @@
-defmodule Fizz.Executions.CancelActiveStepExecutionsTest do
- use Fizz.DataCase, async: false
-
- alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership}
- alias Fizz.Executions
- alias Fizz.Repo
- alias Fizz.Workflows
-
- setup do
- user = Fizz.AccountsFixtures.user_fixture()
- workspace = workspace_fixture!(user)
- scope = scoped_workspace_access(user, workspace)
- workflow = workflow_fixture!(scope)
- execution = execution_fixture!(scope, workflow)
-
- %{scope: scope, execution: execution}
- end
-
- test "broadcasts cancelled step events with per-item identity", %{
- scope: scope,
- execution: execution
- } do
- _step_zero =
- step_execution_fixture!(scope, execution, %{
- step_id: "fan_out_step",
- item_index: 0,
- items_total: 2
- })
-
- _step_one =
- step_execution_fixture!(scope, execution, %{
- step_id: "fan_out_step",
- item_index: 1,
- items_total: 2
- })
-
- :ok =
- Phoenix.PubSub.subscribe(Fizz.PubSub, Fizz.Executions.PubSub.execution_topic(execution.id))
-
- assert {2, nil} = Executions.cancel_active_step_executions(execution.id)
-
- payloads =
- Enum.map(1..2, fn _ ->
- assert_receive {:execution_event, %{event_name: :step_cancelled, payload: payload}}, 1_000
- payload
- end)
-
- assert Enum.sort(Enum.map(payloads, & &1["item_index"])) == [0, 1]
- assert Enum.all?(payloads, &(&1["status"] == "cancelled"))
- assert Enum.all?(payloads, &(&1["attempt"] == 1))
- assert Enum.all?(payloads, &(&1["step_id"] == "fan_out_step"))
- end
-
- defp workspace_fixture!(user) do
- unique = System.unique_integer([:positive])
-
- workspace =
- %Workspace{}
- |> Workspace.changeset(%{
- name: "Execution Cancel Workspace #{unique}",
- slug: "execution-cancel-workspace-#{unique}",
- workos_organization_id: "org_#{unique}"
- })
- |> Repo.insert!()
-
- %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id}
- |> WorkspaceMembership.changeset(%{role: :admin})
- |> Repo.insert!()
-
- workspace
- end
-
- defp scoped_workspace_access(user, workspace) do
- Scope.for_user(user)
- |> Scope.with_organization_id(workspace.workos_organization_id)
- |> Scope.with_organization_role(:owner)
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
- end
-
- defp workflow_fixture!(scope) do
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "Cancel Broadcast Workflow #{System.unique_integer([:positive])}",
- description: "cancel broadcast regression"
- })
-
- workflow
- end
-
- defp execution_fixture!(scope, workflow) do
- {:ok, execution} =
- Executions.create_execution(scope, %{
- workflow_id: workflow.id,
- status: :running,
- execution_type: :preview,
- started_at: DateTime.utc_now() |> DateTime.truncate(:microsecond),
- trigger: %{
- type: :manual,
- data: %{"source" => "test"}
- }
- })
-
- execution
- end
-
- defp step_execution_fixture!(scope, execution, attrs) do
- now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
-
- {:ok, step_execution} =
- Executions.create_step_execution(
- scope,
- Map.merge(
- %{
- execution_id: execution.id,
- step_id: "step_a",
- step_type_id: "math",
- status: :running,
- attempt: 1,
- started_at: now
- },
- attrs
- )
- )
-
- step_execution
- end
-end
diff --git a/test/fizz/executions_test.exs b/test/fizz/executions_test.exs
deleted file mode 100644
index 8a018aa..0000000
--- a/test/fizz/executions_test.exs
+++ /dev/null
@@ -1,62 +0,0 @@
-defmodule Fizz.ExecutionsTest do
- use ExUnit.Case, async: true
-
- import Ecto.Changeset
-
- alias Fizz.Executions.Execution
- alias Fizz.Executions.StepExecution
-
- describe "changesets" do
- test "execution changeset requires trigger" do
- changeset =
- Execution.changeset(%Execution{}, %{
- workflow_id: Ecto.UUID.generate(),
- status: :pending,
- execution_type: :preview
- })
-
- refute changeset.valid?
- assert %{trigger: ["can't be blank"]} = errors_on(changeset)
- end
-
- test "step execution changeset allows output_item_count = 0" do
- attrs = %{
- execution_id: Ecto.UUID.generate(),
- step_id: "step_1",
- step_type_id: "manual_input",
- status: :completed,
- output_item_count: 0
- }
-
- changeset = StepExecution.changeset(%StepExecution{}, attrs)
-
- assert changeset.valid?
- assert get_change(changeset, :output_item_count) == 0
- end
-
- test "step execution changeset rejects negative counters" do
- attrs = %{
- execution_id: Ecto.UUID.generate(),
- step_id: "step_1",
- step_type_id: "manual_input",
- status: :pending,
- output_item_count: -1,
- item_index: -1,
- items_total: 0
- }
-
- changeset = StepExecution.changeset(%StepExecution{}, attrs)
-
- refute changeset.valid?
-
- assert "must be greater than or equal to %{number}" in errors_on(changeset).output_item_count
-
- assert "must be greater than or equal to %{number}" in errors_on(changeset).item_index
- assert "must be greater than or equal to %{number}" in errors_on(changeset).items_total
- end
- end
-
- defp errors_on(changeset) do
- traverse_errors(changeset, fn {message, _opts} -> message end)
- end
-end
diff --git a/test/fizz/integrations/credentials_resolver_test.exs b/test/fizz/fields/credential_options_test.exs
similarity index 86%
rename from test/fizz/integrations/credentials_resolver_test.exs
rename to test/fizz/fields/credential_options_test.exs
index c2cfba0..e67282d 100644
--- a/test/fizz/integrations/credentials_resolver_test.exs
+++ b/test/fizz/fields/credential_options_test.exs
@@ -1,8 +1,8 @@
-defmodule Fizz.Integrations.CredentialsResolverTest do
+defmodule Fizz.Fields.CredentialOptionsTest do
use Fizz.DataCase, async: true
alias Fizz.Accounts.{ApiCredential, Scope}
- alias Fizz.Integrations.CredentialsResolver
+ alias Fizz.Fields.Credential
import Fizz.AccountsFixtures
@@ -19,7 +19,7 @@ defmodule Fizz.Integrations.CredentialsResolverTest do
insert_api_credential!(user.id, organization_id, "anthropic_api_key", "Anthropic Primary")
assert {:ok, options} =
- CredentialsResolver.resolve(%{
+ Credential.resolve(%{
q: "",
params: %{
"provider_filter" => ["openai_api_key"],
@@ -35,7 +35,7 @@ defmodule Fizz.Integrations.CredentialsResolverTest do
assert option["owner_user_id"] == user.id
end
- test "supports legacy provider/auth_type params and search query" do
+ test "applies search query with current credential params" do
user = user_fixture()
organization_id = "org_credentials_resolver_search"
scope = Scope.for_user(user) |> Scope.with_organization_id(organization_id)
@@ -47,11 +47,11 @@ defmodule Fizz.Integrations.CredentialsResolverTest do
insert_api_credential!(user.id, organization_id, "openai_api_key", "OpenAI Staging")
assert {:ok, options} =
- CredentialsResolver.resolve(%{
+ Credential.resolve(%{
q: "production",
params: %{
- "provider" => "openai_api_key",
- "auth_type" => "api_key"
+ "provider_filter" => ["openai_api_key"],
+ "auth_types" => ["api_key"]
},
context: %{current_scope: scope}
})
diff --git a/test/fizz/fields/credential_secret_test.exs b/test/fizz/fields/credential_secret_test.exs
new file mode 100644
index 0000000..93fd98c
--- /dev/null
+++ b/test/fizz/fields/credential_secret_test.exs
@@ -0,0 +1,72 @@
+defmodule Fizz.Fields.CredentialSecretTest do
+ use ExUnit.Case, async: false
+
+ alias Fizz.Fields
+ alias Fizz.Fields.Credential
+
+ setup do
+ previous_providers = Application.get_env(:fizz, :integration_providers)
+
+ previous_replace_providers =
+ Application.get_env(:fizz, :replace_integration_providers_for_test)
+
+ on_exit(fn ->
+ restore_env(:integration_providers, previous_providers)
+ restore_env(:replace_integration_providers_for_test, previous_replace_providers)
+ end)
+
+ :ok
+ end
+
+ describe "secret_value/2" do
+ test "extracts a single provider secret field" do
+ assert {:ok, "sk-test"} =
+ Credential.secret_value("openai_api_key", %{
+ credentials: %{"secret" => " sk-test "}
+ })
+ end
+
+ test "extracts multiple provider secret fields as JSON" do
+ install_multi_secret_provider()
+
+ assert {:ok, encoded} =
+ Credential.secret_value("multi_api_key", %{
+ credentials: %{"client_id" => "client", "client_secret" => "secret"}
+ })
+
+ assert Jason.decode!(encoded) == %{
+ "client_id" => "client",
+ "client_secret" => "secret"
+ }
+ end
+
+ test "rejects missing required secret fields" do
+ install_multi_secret_provider()
+
+ assert {:error, :missing_secret_value} =
+ Credential.secret_value("multi_api_key", %{
+ credentials: %{"client_id" => "client", "client_secret" => ""}
+ })
+ end
+ end
+
+ defp install_multi_secret_provider do
+ Application.put_env(:fizz, :replace_integration_providers_for_test, true)
+
+ Application.put_env(:fizz, :integration_providers, [
+ %{
+ id: "multi_api_key",
+ label: "Multi",
+ logo_path: "/images/multi.svg",
+ type: :api_key,
+ credential_fields: [
+ Fields.password("client_id", label: "Client ID", required?: true, default: ""),
+ Fields.password("client_secret", label: "Client Secret", required?: true, default: "")
+ ]
+ }
+ ])
+ end
+
+ defp restore_env(key, nil), do: Application.delete_env(:fizz, key)
+ defp restore_env(key, value), do: Application.put_env(:fizz, key, value)
+end
diff --git a/test/fizz/fields/credential_test.exs b/test/fizz/fields/credential_test.exs
new file mode 100644
index 0000000..d3c4ccd
--- /dev/null
+++ b/test/fizz/fields/credential_test.exs
@@ -0,0 +1,249 @@
+defmodule Fizz.Fields.CredentialTest do
+ use Fizz.DataCase, async: true
+
+ alias Fizz.Accounts.{ApiCredential, OauthConnection}
+ alias Fizz.Fields.Credential
+ alias Fizz.Workflows
+ alias Fizz.Workflows.CredentialDefaults
+ alias Fizz.WorkflowsFixtures
+
+ describe "credential declarations" do
+ test "walks nested config and normalizes credential declarations" do
+ config = %{
+ "credential_ref" => credential_declaration("openai_api_key"),
+ "nested" => [%{"value" => credential_declaration("github_oauth", "oauth")}]
+ }
+
+ declarations = config |> Credential.walk() |> Enum.sort_by(& &1.path)
+
+ assert [
+ %{path: ["credential_ref"], declaration: openai},
+ %{path: ["nested", "0", "value"], declaration: github}
+ ] = declarations
+
+ assert {:ok,
+ %{
+ requirement_key: "auth",
+ provider: "openai_api_key",
+ auth_type: "api_key"
+ }} = Credential.normalize(openai)
+
+ assert :ok = Credential.validate(github)
+ end
+
+ test "reports missing and invalid credential declaration fields" do
+ assert {:error, "credential is missing provider"} =
+ Credential.validate(%{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "auth_type" => "api_key"
+ })
+
+ assert {:error, "credential auth_type must be api_key or oauth"} =
+ Credential.validate(%{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => "openai_api_key",
+ "auth_type" => "password"
+ })
+ end
+ end
+
+ describe "required_credentials/1" do
+ test "returns normalized credential descriptors for workflow step maps" do
+ steps = [
+ %{id: "step_a", config: %{"credential_ref" => credential_declaration("openai_api_key")}},
+ %{id: "step_b", config: %{}}
+ ]
+
+ assert [
+ %{
+ step_id: "step_a",
+ field: "credential_ref",
+ requirement_key: "auth",
+ provider: "openai_api_key",
+ auth_type: "api_key"
+ }
+ ] = Credential.required_credentials(steps)
+ end
+ end
+
+ describe "credential defaults" do
+ test "restores missing credential declarations from step default config" do
+ step =
+ WorkflowsFixtures.step(%{
+ type_id: "google_sheets_append_row",
+ config: %{"credential_ref" => nil, "spreadsheet_id" => "sheet", "values" => %{}}
+ })
+
+ assert [
+ %{
+ config: %{
+ "credential_ref" => %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => "google_oauth",
+ "auth_type" => "oauth"
+ }
+ }
+ }
+ ] = CredentialDefaults.normalize_steps([step])
+ end
+
+ test "does not overwrite concrete legacy credential refs" do
+ legacy_ref = %{
+ "id" => Ecto.UUID.generate(),
+ "provider" => "google_oauth",
+ "auth_type" => "oauth"
+ }
+
+ step =
+ WorkflowsFixtures.step(%{
+ type_id: "google_sheets_append_row",
+ config: %{"credential_ref" => legacy_ref, "spreadsheet_id" => "sheet", "values" => %{}}
+ })
+
+ assert [%{config: %{"credential_ref" => ^legacy_ref}}] =
+ CredentialDefaults.normalize_steps([step])
+ end
+ end
+
+ describe "ensure_auto_bindings/3" do
+ test "auto-binds one available WorkOS OAuth connection for restored credentials" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ append_step =
+ WorkflowsFixtures.step(%{
+ type_id: "google_sheets_append_row",
+ config: %{
+ "credential_ref" => nil,
+ "spreadsheet_id" => "sheet_123",
+ "values" => %{"A" => "1"}
+ }
+ })
+
+ {:ok, %{draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "OAuth credential workflow #{System.unique_integer([:positive])}",
+ description: "Credential auto-bind test"
+ })
+
+ {:ok, version} =
+ Workflows.save_draft(
+ scope,
+ draft,
+ WorkflowsFixtures.snapshot_attrs(%{steps: [append_step]})
+ )
+
+ connection = insert_oauth_connection!(scope.user.id, scope.organization_id, "google_oauth")
+
+ assert {:ok, [binding]} = Credential.ensure_auto_bindings(version, scope.user.id, scope)
+ assert binding.step_id == append_step.id
+ assert binding.requirement_key == "auth"
+ assert binding.binding_data == %{"credential_id" => connection.id}
+ assert Credential.readiness(version, scope.user.id, scope) == :ready
+ end
+ end
+
+ describe "candidate_options/2" do
+ test "returns a tagged error for invalid scope" do
+ assert {:error, :invalid_scope} =
+ Credential.candidate_options(credential_declaration("openai_api_key"), nil)
+ end
+ end
+
+ describe "upsert_binding/3" do
+ test "persists a binding when it matches the credential requirement" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ version = draft_with_credential_requirement(scope, "openai_api_key")
+ credential = insert_api_credential!(scope.user.id, scope.organization_id, "openai_api_key")
+
+ assert {:ok, binding} =
+ Credential.upsert_binding(version, scope, %{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ step_id: hd(version.steps).id,
+ requirement_key: "auth",
+ binding_data: %{"credential_id" => credential.id},
+ workos_organization_id: scope.organization_id
+ })
+
+ assert binding.binding_data == %{"credential_id" => credential.id}
+ end
+
+ test "rejects a credential binding that does not satisfy the requirement" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ version = draft_with_credential_requirement(scope, "openai_api_key")
+
+ credential =
+ insert_api_credential!(scope.user.id, scope.organization_id, "anthropic_api_key")
+
+ assert {:error, changeset} =
+ Credential.upsert_binding(version, scope, %{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ step_id: hd(version.steps).id,
+ requirement_key: "auth",
+ binding_data: %{"credential_id" => credential.id},
+ workos_organization_id: scope.organization_id
+ })
+
+ assert "credential_not_available" in errors_on(changeset).binding_data
+ end
+ end
+
+ defp credential_declaration(provider, auth_type \\ "api_key") do
+ %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => provider,
+ "auth_type" => auth_type
+ }
+ end
+
+ defp draft_with_credential_requirement(scope, provider) do
+ {:ok, %{draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Credential workflow #{System.unique_integer([:positive])}",
+ description: "Credential test"
+ })
+
+ step =
+ WorkflowsFixtures.step(%{
+ type_id: "openai_image_generation",
+ name: "Image",
+ config: %{"credential_ref" => credential_declaration(provider)}
+ })
+
+ attrs = WorkflowsFixtures.snapshot_attrs(%{steps: [step]})
+
+ {:ok, version} = Workflows.save_draft(scope, draft, attrs)
+ version
+ end
+
+ defp insert_api_credential!(user_id, organization_id, provider) do
+ unique = System.unique_integer([:positive])
+
+ %ApiCredential{}
+ |> ApiCredential.changeset(%{
+ user_id: user_id,
+ workos_organization_id: organization_id,
+ provider: provider,
+ provider_label: "Credential #{unique}",
+ vault_object_id: "vault_obj_#{unique}",
+ vault_object_name: "vault_name_#{unique}"
+ })
+ |> Repo.insert!()
+ end
+
+ defp insert_oauth_connection!(user_id, organization_id, provider) do
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ workos_organization_id: organization_id,
+ user_id: user_id,
+ provider: provider,
+ status: :active
+ })
+ |> Repo.insert!()
+ end
+end
diff --git a/test/fizz/fields/credential_workos_oauth_autobind_test.exs b/test/fizz/fields/credential_workos_oauth_autobind_test.exs
new file mode 100644
index 0000000..a328f7b
--- /dev/null
+++ b/test/fizz/fields/credential_workos_oauth_autobind_test.exs
@@ -0,0 +1,153 @@
+defmodule Fizz.Fields.CredentialWorkOSOAuthAutobindTest do
+ use Fizz.DataCase, async: false
+
+ alias Fizz.Accounts.OauthConnection
+ alias Fizz.Repo
+ alias Fizz.Fields.Credential
+ alias Fizz.Workflows
+ alias Fizz.Workflows.Readiness
+ alias Fizz.WorkflowsFixtures
+ alias Fizz.WorkOSHTTPMock
+
+ setup do
+ previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
+ previous_workos_http_backoff_ms = Application.get_env(:fizz, :workos_http_backoff_ms)
+ previous_workos_client = Application.get_env(:workos, WorkOS.Client)
+ store_pid = start_supervised!({Agent, fn -> [] end})
+
+ :ok = WorkOSHTTPMock.configure(self(), store_pid)
+ Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPMock)
+ Application.put_env(:fizz, :workos_http_backoff_ms, 0)
+
+ Application.put_env(:workos, WorkOS.Client,
+ api_key: "sk_test_123",
+ client_id: "client_test_123",
+ client: Fizz.Accounts.WorkOS.ReqClient
+ )
+
+ on_exit(fn ->
+ restore_env(:fizz, :workos_http_client_module, previous_http_client)
+ restore_env(:fizz, :workos_http_backoff_ms, previous_workos_http_backoff_ms)
+ restore_env(:workos, WorkOS.Client, previous_workos_client)
+ WorkOSHTTPMock.reset()
+ end)
+
+ :ok
+ end
+
+ test "readiness auto-binds the single active WorkOS Pipes OAuth account" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ version = draft_with_google_oauth_credential(scope)
+
+ other_user = Fizz.AccountsFixtures.user_fixture()
+
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ workos_organization_id: scope.organization_id,
+ user_id: other_user.id,
+ provider: "google_oauth",
+ status: :active
+ })
+ |> Repo.insert!()
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ active_google_token_response()
+ ])
+
+ assert :ready = Readiness.check(version, scope.user.id, scope)
+
+ connection =
+ Repo.get_by!(OauthConnection,
+ workos_organization_id: scope.organization_id,
+ user_id: scope.user.id,
+ provider: "google_oauth"
+ )
+
+ assert connection.status == :active
+ assert connection.scopes == ["https://www.googleapis.com/auth/spreadsheets"]
+
+ assert [binding] =
+ Credential.list_for_user(
+ scope.organization_id,
+ version.workflow_definition_id,
+ scope.user.id
+ )
+
+ assert binding.step_id == hd(version.steps).id
+ assert binding.requirement_key == "auth"
+ assert binding.binding_data == %{"credential_id" => connection.id}
+
+ assert_receive {:workos_http_request, membership_request}
+ assert membership_request[:url] == "/user_management/organization_memberships"
+
+ assert_receive {:workos_http_request, token_request}
+ assert token_request[:url] == "/data-integrations/google/token"
+ end
+
+ defp draft_with_google_oauth_credential(scope) do
+ {:ok, %{draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Google OAuth credential #{System.unique_integer([:positive])}",
+ description: "Auto-bind test"
+ })
+
+ step =
+ WorkflowsFixtures.step(%{
+ type_id: "debug",
+ name: "Needs Google",
+ config: %{
+ "credential_ref" => %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => "google_oauth",
+ "auth_type" => "oauth"
+ }
+ }
+ })
+
+ attrs = WorkflowsFixtures.snapshot_attrs(%{steps: [step]})
+ {:ok, version} = Workflows.save_draft(scope, draft, attrs)
+
+ version
+ end
+
+ defp active_google_token_response do
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "active" => true,
+ "access_token" => %{
+ "access_token" => "ya29.test",
+ "expires_at" => "2026-12-31T23:59:59.000Z",
+ "scopes" => ["https://www.googleapis.com/auth/spreadsheets"],
+ "missing_scopes" => []
+ }
+ }
+ }}
+ end
+
+ defp membership_response(workos_user_id, organization_id) do
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "data" => [
+ %{
+ "id" => "om_#{organization_id}",
+ "status" => "active",
+ "user_id" => workos_user_id,
+ "organization_id" => organization_id,
+ "role" => %{"slug" => "owner"}
+ }
+ ]
+ }
+ }}
+ end
+
+ defp put_workos_responses(responses), do: WorkOSHTTPMock.put_responses(responses)
+
+ defp restore_env(app, key, nil), do: Application.delete_env(app, key)
+ defp restore_env(app, key, value), do: Application.put_env(app, key, value)
+end
diff --git a/test/fizz/fields_test.exs b/test/fizz/fields_test.exs
new file mode 100644
index 0000000..3c662f9
--- /dev/null
+++ b/test/fizz/fields_test.exs
@@ -0,0 +1,97 @@
+defmodule Fizz.FieldsTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Fields
+
+ describe "validate!/1" do
+ test "accepts supported field types and components" do
+ fields = [
+ Fields.string("string"),
+ Fields.number("number"),
+ Fields.boolean("boolean"),
+ Fields.json("json"),
+ Fields.select("select", options: [%{"label" => "One", "value" => "one"}]),
+ Fields.search("search"),
+ Fields.hidden("hidden"),
+ Fields.password("password"),
+ Fields.credential("openai_api_key", :api_key),
+ Fields.resource_locator("resource_locator", %{"kind" => "test.resource"}),
+ Fields.resource_mapper("resource_mapper", %{"kind" => "test.mapper"})
+ ]
+
+ assert Fields.validate!(fields) == fields
+ end
+
+ test "rejects unsupported field types and components" do
+ assert_raise ArgumentError, ~r/unsupported field type/, fn ->
+ Fields.field(%{key: "bad", type: :bespoke})
+ end
+
+ assert_raise ArgumentError, ~r/unsupported component/, fn ->
+ Fields.field(%{key: "bad", type: :string, component: "bespoke"})
+ end
+ end
+
+ test "rejects invalid credential and resource metadata" do
+ assert_raise ArgumentError, ~r/requires credential metadata/, fn ->
+ Fields.field(%{key: "credential_ref", type: :credential})
+ end
+
+ assert_raise ArgumentError, ~r/resource_locator field requires/, fn ->
+ Fields.field(%{key: "sheet", type: :resource_locator})
+ end
+
+ assert_raise ArgumentError, ~r/resource_mapper field requires/, fn ->
+ Fields.field(%{key: "values", type: :resource_mapper})
+ end
+ end
+ end
+
+ describe "to_schema/1" do
+ test "generates password field schema" do
+ schema = Fields.to_schema([Fields.password("secret", required?: true, default: "")])
+
+ assert schema["required"] == ["secret"]
+ assert get_in(schema, ["properties", "secret", "type"]) == "string"
+ assert get_in(schema, ["properties", "secret", "writeOnly"]) == true
+ assert get_in(schema, ["properties", "secret", "ui", "component"]) == "password"
+ end
+
+ test "generates credential field schema and default declaration" do
+ field =
+ Fields.credential("google_oauth", :oauth,
+ key: "credential_ref",
+ label: "Google Account"
+ )
+
+ property = Fields.to_schema_property(field)
+
+ assert get_in(property, ["ui", "component"]) == "credential"
+ assert get_in(property, ["ui", "provider"]) == "google_oauth"
+ assert get_in(property, ["ui", "auth_type"]) == "oauth"
+
+ assert Fields.default_value(field) == %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => "google_oauth",
+ "auth_type" => "oauth"
+ }
+ end
+
+ test "generates resource locator and mapper metadata in adapter schema" do
+ locator = %{"kind" => "google_sheets.spreadsheet", "value_key" => "spreadsheet_id"}
+ mapper = %{"kind" => "google_sheets.row_values"}
+
+ schema =
+ Fields.to_schema([
+ Fields.resource_locator("spreadsheet_id", locator),
+ Fields.resource_mapper("values", mapper, depends_on: ["spreadsheet_id"])
+ ])
+
+ assert get_in(schema, ["properties", "spreadsheet_id", "resource_locator"]) == locator
+ assert get_in(schema, ["properties", "values", "resource_mapper"]) == mapper
+ assert get_in(schema, ["properties", "values", "depends_on"]) == ["spreadsheet_id"]
+ assert get_in(schema, ["properties", "values", "ui", "resource_mapper"]) == mapper
+ end
+ end
+end
diff --git a/test/fizz/integrations/ai/model_catalog_test.exs b/test/fizz/integrations/ai/model_catalog_test.exs
new file mode 100644
index 0000000..a375f9a
--- /dev/null
+++ b/test/fizz/integrations/ai/model_catalog_test.exs
@@ -0,0 +1,59 @@
+defmodule Fizz.Integrations.AI.ModelCatalogTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.AI.ModelCatalog
+ alias Fizz.Integrations.Library.Anthropic.ModelResolver, as: AnthropicModelResolver
+ alias Fizz.Integrations.Library.OpenAI.ModelResolver, as: OpenAIModelResolver
+
+ describe "chat_model_options/2" do
+ test "lists current OpenAI text chat models from the packaged catalog" do
+ options = ModelCatalog.chat_model_options(:openai)
+ values = Enum.map(options, & &1["value"])
+
+ assert "gpt-5.5" in values
+ assert "gpt-5.4-mini" in values
+ refute "text-embedding-3-large" in values
+ refute "gpt-image-1" in values
+ assert Enum.all?(values, &(not String.starts_with?(&1, "openai:")))
+ end
+
+ test "filters model options by id or display name" do
+ options = ModelCatalog.chat_model_options(:openai, "5.5")
+ values = Enum.map(options, & &1["value"])
+
+ assert "gpt-5.5" in values
+
+ assert Enum.all?(options, fn option ->
+ haystack =
+ [option["label"], option["value"], option["description"]]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join(" ")
+ |> String.downcase()
+
+ String.contains?(haystack, "5.5")
+ end)
+ end
+
+ test "lists Anthropic text chat models from the packaged catalog" do
+ options = ModelCatalog.chat_model_options(:anthropic)
+ values = Enum.map(options, & &1["value"])
+
+ assert "claude-sonnet-4-6" in values
+ assert Enum.all?(values, &(not String.starts_with?(&1, "anthropic:")))
+ end
+ end
+
+ describe "provider resolvers" do
+ test "OpenAI resolver returns searchable options" do
+ assert {:ok, options} = OpenAIModelResolver.resolve(%{q: "5.5"})
+
+ assert Enum.any?(options, &(&1["value"] == "gpt-5.5"))
+ end
+
+ test "Anthropic resolver returns searchable options" do
+ assert {:ok, options} = AnthropicModelResolver.resolve(%{q: "sonnet"})
+
+ assert Enum.any?(options, &(&1["value"] == "claude-sonnet-4-6"))
+ end
+ end
+end
diff --git a/test/fizz/integrations/catalog_guardrails_test.exs b/test/fizz/integrations/catalog_guardrails_test.exs
new file mode 100644
index 0000000..4f80e8e
--- /dev/null
+++ b/test/fizz/integrations/catalog_guardrails_test.exs
@@ -0,0 +1,331 @@
+defmodule Fizz.Integrations.CatalogGuardrailsTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Auth.ProviderCatalog
+ alias Fizz.Integrations.Catalog.Manifest
+ alias Fizz.Integrations.Steps.Registry, as: Registry
+ alias Fizz.Integrations.Steps.Type, as: StepType
+ alias Fizz.Workflows.StepExecutor
+
+ @expected_provider_ids [
+ "anthropic_api_key",
+ "box_oauth",
+ "custom_api_key",
+ "github_api_key",
+ "github_oauth",
+ "google_oauth",
+ "microsoft_oauth",
+ "notion_oauth",
+ "openai_api_key",
+ "slack_oauth"
+ ]
+
+ @expected_integration_ids [
+ "anthropic",
+ "box",
+ "fizz",
+ "github",
+ "gmail",
+ "google_docs",
+ "google_drive",
+ "google_sheets",
+ "google_slides",
+ "notion",
+ "onedrive",
+ "openai",
+ "outlook",
+ "powerpoint",
+ "sharepoint",
+ "slack",
+ "teams"
+ ]
+
+ @expected_step_type_ids [
+ "aggregator",
+ "ai_agent",
+ "ai_structure_schema",
+ "ai_tool_http",
+ "anthropic_model",
+ "anthropic_vision_analysis",
+ "box_upload_file",
+ "condition",
+ "data_filter",
+ "data_output",
+ "data_transform",
+ "debug",
+ "format",
+ "github_create_issue",
+ "github_create_pr",
+ "github_trigger",
+ "gmail_reply_email",
+ "gmail_send_email",
+ "gmail_trigger",
+ "google_docs_append_text",
+ "google_docs_create_doc",
+ "google_docs_trigger",
+ "google_drive_upload_file",
+ "google_sheets_append_row",
+ "google_sheets_read_rows",
+ "google_sheets_trigger",
+ "google_slides_add_slide",
+ "google_slides_create_presentation",
+ "http_request",
+ "join",
+ "json_parser",
+ "manual_input",
+ "math",
+ "notion_create_page",
+ "notion_trigger",
+ "notion_update_page",
+ "on_chat_trigger",
+ "onedrive_upload_file",
+ "openai_image_generation",
+ "openai_model",
+ "outlook_send_email",
+ "outlook_trigger",
+ "powerpoint_create_presentation",
+ "schedule_trigger",
+ "sharepoint_upload_file",
+ "slack_create_channel",
+ "slack_send_message",
+ "slack_trigger",
+ "splitter",
+ "switch",
+ "teams_send_message",
+ "teams_trigger",
+ "wait"
+ ]
+
+ @expected_credential_requirements [
+ {"anthropic_model", "credential_ref", "anthropic_api_key", "api_key", "auth"},
+ {"anthropic_vision_analysis", "credential_ref", "anthropic_api_key", "api_key", "auth"},
+ {"box_upload_file", "credential_ref", "box_oauth", "oauth", "auth"},
+ {"github_create_issue", "credential_ref", "github_oauth", "oauth", "auth"},
+ {"github_create_pr", "credential_ref", "github_oauth", "oauth", "auth"},
+ {"github_trigger", "credential_ref", "github_oauth", "oauth", "auth"},
+ {"gmail_reply_email", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"gmail_send_email", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"gmail_trigger", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_docs_append_text", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_docs_create_doc", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_docs_trigger", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_drive_upload_file", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_sheets_append_row", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_sheets_read_rows", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_sheets_trigger", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_slides_add_slide", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"google_slides_create_presentation", "credential_ref", "google_oauth", "oauth", "auth"},
+ {"notion_create_page", "credential_ref", "notion_oauth", "oauth", "auth"},
+ {"notion_trigger", "credential_ref", "notion_oauth", "oauth", "auth"},
+ {"notion_update_page", "credential_ref", "notion_oauth", "oauth", "auth"},
+ {"onedrive_upload_file", "credential_ref", "microsoft_oauth", "oauth", "auth"},
+ {"openai_image_generation", "credential_ref", "openai_api_key", "api_key", "auth"},
+ {"openai_model", "credential_ref", "openai_api_key", "api_key", "auth"},
+ {"outlook_send_email", "credential_ref", "microsoft_oauth", "oauth", "auth"},
+ {"outlook_trigger", "credential_ref", "microsoft_oauth", "oauth", "auth"},
+ {"powerpoint_create_presentation", "credential_ref", "microsoft_oauth", "oauth", "auth"},
+ {"sharepoint_upload_file", "credential_ref", "microsoft_oauth", "oauth", "auth"},
+ {"slack_create_channel", "credential_ref", "slack_oauth", "oauth", "auth"},
+ {"slack_send_message", "credential_ref", "slack_oauth", "oauth", "auth"},
+ {"slack_trigger", "credential_ref", "slack_oauth", "oauth", "auth"},
+ {"teams_send_message", "credential_ref", "microsoft_oauth", "oauth", "auth"},
+ {"teams_trigger", "credential_ref", "microsoft_oauth", "oauth", "auth"}
+ ]
+
+ @expected_ui_components [
+ {"google_sheets_append_row", "sheet_name", "hidden"},
+ {"google_sheets_append_row", "spreadsheet_id", "resource_locator"},
+ {"google_sheets_append_row", "table_id", "hidden"},
+ {"google_sheets_append_row", "values", "resource_mapper"},
+ {"google_sheets_read_rows", "range", "string"},
+ {"google_sheets_read_rows", "spreadsheet_id", "string"},
+ {"manual_input", "test_data", "json"}
+ ]
+
+ describe "current catalog surfaces" do
+ test "built-in provider IDs stay explicit" do
+ assert provider_ids() == @expected_provider_ids
+ end
+
+ test "built-in product integration IDs stay explicit" do
+ assert integration_ids() == @expected_integration_ids
+ end
+
+ test "built-in step type IDs stay explicit and executors load" do
+ step_types = Registry.all()
+
+ assert step_types |> Enum.map(& &1.id) |> Enum.sort() == @expected_step_type_ids
+
+ for %StepType{} = step_type <- step_types do
+ assert {:ok, module} = StepType.executor_module(step_type)
+ assert {:module, ^module} = Code.ensure_loaded(module)
+ end
+
+ assert {:ok, append_row} = Registry.get("google_sheets_append_row")
+ assert {:ok, read_rows} = Registry.get("google_sheets_read_rows")
+
+ assert {:ok, Fizz.Integrations.Library.Google.Sheets.Actions.AppendRow} =
+ StepType.executor_module(append_row)
+
+ assert {:ok, Fizz.Integrations.Library.Google.Sheets.Actions.ReadRows} =
+ StepType.executor_module(read_rows)
+ end
+
+ test "manifest modules load and expose required callbacks" do
+ for module <- Manifest.provider_modules() do
+ assert {:module, ^module} = Code.ensure_loaded(module)
+ assert function_exported?(module, :definition, 0)
+ end
+
+ for module <- Manifest.integration_modules() do
+ assert {:module, ^module} = Code.ensure_loaded(module)
+
+ for callback <- [:id, :display_name, :provider_id, :actions, :triggers, :step_modules] do
+ assert function_exported?(module, callback, 0)
+ end
+
+ assert function_exported?(module, :required_scopes, 1)
+ end
+
+ for module <- Manifest.step_executor_modules() do
+ assert {:module, ^module} = Code.ensure_loaded(module)
+ assert function_exported?(module, :__step_definition__, 0)
+ end
+
+ for trigger <- Manifest.trigger_definitions() do
+ assert {:module, trigger.module} == Code.ensure_loaded(trigger.module)
+ assert function_exported?(trigger.module, :source_key, 2)
+ assert function_exported?(trigger.module, :poll, 3)
+ end
+
+ for resolver <- Manifest.resolver_definitions() do
+ assert {:module, resolver.module} == Code.ensure_loaded(resolver.module)
+ assert function_exported?(resolver.module, :resolve, 1)
+ end
+ end
+
+ test "integration actions are backed by owned registered step modules" do
+ for integration <- Fizz.Integrations.Catalog.IntegrationRegistry.all(),
+ action_id <- integration.actions do
+ assert {:ok, step_type} = Registry.get(action_id)
+ assert step_type.integration == integration.id
+
+ assert Enum.any?(integration.step_modules, fn module ->
+ function_exported?(module, :__step_id__, 0) and module.__step_id__() == action_id
+ end)
+ end
+ end
+
+ test "every registered step resolves to an executable module" do
+ for %StepType{} = step_type <- Registry.all() do
+ assert {:ok, module} = StepExecutor.resolve(step_type.id)
+ assert function_exported?(module, :execute, 3)
+ end
+ end
+ end
+
+ describe "credential field shapes" do
+ test "credential fields keep their provider/auth contracts" do
+ assert credential_requirements() == @expected_credential_requirements
+ end
+
+ test "non-credential UI components stay explicit" do
+ components = ui_components()
+
+ for component <- @expected_ui_components do
+ assert component in components
+ end
+
+ refute Enum.any?(components, fn {_step_id, _field, component} -> component == "bespoke" end)
+ end
+
+ test "credential fields have matching default declarations" do
+ for {step_id, field, provider, auth_type, requirement_key} <- credential_requirements() do
+ {:ok, step_type} = Registry.get(step_id)
+ default_config = Registry.get_default_config(step_id)
+ schema = get_in(step_type.config_schema, ["properties", field])
+
+ assert %{
+ "type" => "object",
+ "ui" => %{
+ "component" => "credential",
+ "requirement_key" => ^requirement_key,
+ "provider" => ^provider,
+ "auth_type" => ^auth_type
+ }
+ } = schema
+
+ assert %{
+ "$credential" => true,
+ "requirement_key" => ^requirement_key,
+ "provider" => ^provider,
+ "auth_type" => ^auth_type
+ } = Map.fetch!(default_config, field)
+
+ assert {:ok, %{id: ^provider, type: provider_auth_type}} =
+ ProviderCatalog.provider(provider)
+
+ assert Atom.to_string(provider_auth_type) == auth_type
+ end
+ end
+ end
+
+ defp provider_ids do
+ ProviderCatalog.providers()
+ |> Enum.map(& &1.id)
+ |> Enum.sort()
+ end
+
+ defp integration_ids do
+ Fizz.Integrations.Catalog.IntegrationRegistry.all()
+ |> Enum.map(& &1.id)
+ |> Enum.sort()
+ end
+
+ defp credential_requirements do
+ Registry.all()
+ |> Enum.flat_map(&credential_requirements_for_step/1)
+ |> Enum.sort()
+ end
+
+ defp ui_components do
+ Registry.all()
+ |> Enum.flat_map(&ui_components_for_step/1)
+ |> Enum.sort()
+ end
+
+ defp credential_requirements_for_step(%StepType{} = step_type) do
+ step_type.config_schema
+ |> Map.get("properties", %{})
+ |> Enum.flat_map(fn {field, schema} ->
+ case get_in(schema, ["ui", "component"]) do
+ "credential" -> [credential_requirement_tuple(step_type.id, field, schema)]
+ _component -> []
+ end
+ end)
+ end
+
+ defp credential_requirement_tuple(step_id, field, schema) do
+ ui = Map.fetch!(schema, "ui")
+
+ {
+ step_id,
+ field,
+ Map.fetch!(ui, "provider"),
+ Map.fetch!(ui, "auth_type"),
+ Map.fetch!(ui, "requirement_key")
+ }
+ end
+
+ defp ui_components_for_step(%StepType{} = step_type) do
+ step_type.config_schema
+ |> Map.get("properties", %{})
+ |> Enum.flat_map(fn {field, schema} ->
+ case get_in(schema, ["ui", "component"]) do
+ "credential" -> []
+ component when is_binary(component) -> [{step_type.id, field, component}]
+ _component -> []
+ end
+ end)
+ end
+end
diff --git a/test/fizz/integrations/catalog_test.exs b/test/fizz/integrations/catalog_test.exs
new file mode 100644
index 0000000..013a160
--- /dev/null
+++ b/test/fizz/integrations/catalog_test.exs
@@ -0,0 +1,101 @@
+defmodule Fizz.Integrations.CatalogTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Catalog.{Manifest, Store}
+
+ alias Fizz.Fields
+ alias Fizz.Integrations.Steps.Registry, as: StepRegistry
+ alias Fizz.Workflows.RetryPolicy
+
+ describe "catalog lookups" do
+ test "catalog starts under supervision and exposes provider definitions" do
+ assert {:ok, provider} = Store.provider("google_oauth")
+ assert provider.id == "google_oauth"
+ assert provider.type == :oauth
+ end
+
+ test "catalog exposes provider field, integration, trigger, and resolver metadata" do
+ refute function_exported?(Store, :credential, 1)
+ refute function_exported?(Store, :credentials, 0)
+
+ assert {:ok, openai_provider} = Store.provider("openai_api_key")
+ credential_schema = Fields.to_schema(openai_provider.credential_fields)
+
+ assert credential_schema["required"] == ["secret"]
+ assert get_in(credential_schema, ["properties", "secret", "writeOnly"]) == true
+
+ assert get_in(credential_schema, ["properties", "secret", "ui", "component"]) ==
+ "password"
+
+ assert openai_provider.credential_test["operation"] == "check_connection"
+
+ assert {:ok, integration} = Store.integration("google_sheets")
+ assert integration.module == Fizz.Integrations.Library.Google.Sheets
+ assert integration.actions == ["google_sheets_read_rows", "google_sheets_append_row"]
+
+ assert {:ok, trigger} = Store.trigger("google_sheets.row_change")
+ assert trigger.module == Fizz.Integrations.Library.Google.Sheets.Triggers.RowChange
+
+ assert {:ok, resolver} = Store.resolver("google_sheets.columns")
+ assert resolver.module == Fizz.Integrations.Library.Google.Sheets.ColumnsResolver
+ end
+ end
+
+ describe "manifest definitions" do
+ test "manifest exposes current built-in module lists" do
+ assert Fizz.Integrations.Auth.Providers.GoogleOAuth in Manifest.provider_modules()
+ assert Fizz.Integrations.Library.Fizz in Manifest.integration_modules()
+ assert Fizz.Integrations.Library.Google.Sheets in Manifest.integration_modules()
+
+ assert Fizz.Integrations.Library.Fizz.Builtins.ManualInput in Manifest.step_executor_modules()
+
+ assert Fizz.Integrations.Library.Google.Sheets.Actions.AppendRow in Manifest.step_executor_modules()
+
+ assert Fizz.Integrations.Library.Google.Sheets.Actions.ReadRows in Manifest.step_executor_modules()
+ end
+
+ test "google sheets step definitions validate and include typed fields" do
+ assert {:ok, step_type} = StepRegistry.get("google_sheets_append_row")
+
+ assert step_type.version == 1
+ assert step_type.provider == Fizz.Integrations.Auth.Providers.GoogleOAuth.provider_id()
+ assert step_type.integration == "google_sheets"
+
+ assert step_type.executor ==
+ Atom.to_string(Fizz.Integrations.Library.Google.Sheets.Actions.AppendRow)
+
+ credential_field = Enum.find(step_type.fields, &(&1.key == "credential_ref"))
+
+ assert credential_field.type == :credential
+ assert credential_field.credential.provider == "google_oauth"
+ assert credential_field.credential.auth_type == :oauth
+
+ values_field = Enum.find(step_type.fields, &(&1.key == "values"))
+
+ assert values_field.depends_on == [
+ "credential_ref",
+ "spreadsheet_id",
+ "sheet_name",
+ "table_id"
+ ]
+
+ spreadsheet_field = Enum.find(step_type.fields, &(&1.key == "spreadsheet_id"))
+
+ assert spreadsheet_field.resource_locator["kind"] ==
+ "google_sheets.spreadsheet"
+
+ assert values_field.resource_mapper["kind"] == "google_sheets.row_values"
+
+ assert values_field.resource_mapper["lookups"]["primary_resource"]["mode"] ==
+ "sheets"
+
+ assert values_field.resource_mapper["lookups"]["schema_resource"]["mode"] ==
+ "tables"
+
+ assert %RetryPolicy{} = step_type.retry
+ assert step_type.retry.max_attempts == 3
+ assert step_type.retry.backoff == :exponential
+ assert step_type.retry.retry_on == [:rate_limit, :network, :transient]
+ end
+ end
+end
diff --git a/test/fizz/integrations/catalog_validation_test.exs b/test/fizz/integrations/catalog_validation_test.exs
new file mode 100644
index 0000000..e1d9158
--- /dev/null
+++ b/test/fizz/integrations/catalog_validation_test.exs
@@ -0,0 +1,180 @@
+defmodule Fizz.Integrations.CatalogValidationTest do
+ use ExUnit.Case, async: false
+
+ alias Fizz.Integrations.Auth.ProviderCatalog
+ alias Fizz.Integrations.Catalog.IntegrationRegistry, as: IntegrationRegistry
+ alias Fizz.Integrations.Steps.Registry, as: StepRegistry
+
+ setup do
+ previous_providers = Application.get_env(:fizz, :integration_providers)
+
+ previous_replace_providers =
+ Application.get_env(:fizz, :replace_integration_providers_for_test)
+
+ previous_integrations = Application.get_env(:fizz, :integrations)
+ previous_replace_integrations = Application.get_env(:fizz, :replace_integrations_for_test)
+
+ on_exit(fn ->
+ restore_env(:integration_providers, previous_providers)
+ restore_env(:replace_integration_providers_for_test, previous_replace_providers)
+ restore_env(:integrations, previous_integrations)
+ restore_env(:replace_integrations_for_test, previous_replace_integrations)
+ end)
+
+ :ok
+ end
+
+ describe "provider catalog extension" do
+ test "configured providers append to built-ins by default" do
+ Application.put_env(:fizz, :integration_providers, [acme_provider()])
+
+ provider_ids = ProviderCatalog.providers() |> Enum.map(& &1.id)
+
+ assert "openai_api_key" in provider_ids
+ assert "acme_api_key" in provider_ids
+ end
+
+ test "providers can only replace built-ins with an explicit replacement flag" do
+ Application.put_env(:fizz, :integration_providers, [acme_provider()])
+ Application.put_env(:fizz, :replace_integration_providers_for_test, true)
+
+ assert [%{id: "acme_api_key"}] = ProviderCatalog.providers()
+ assert {:error, :unknown_provider} = ProviderCatalog.provider("openai_api_key")
+ end
+
+ test "duplicate provider IDs raise during catalog load" do
+ Application.put_env(:fizz, :integration_providers, [
+ Map.put(acme_provider(), :id, "openai_api_key")
+ ])
+
+ assert_raise ArgumentError, ~r/duplicate integration provider IDs/, fn ->
+ ProviderCatalog.providers()
+ end
+ end
+
+ test "missing provider icons raise during catalog load" do
+ provider = acme_provider() |> Map.delete(:logo_path)
+ Application.put_env(:fizz, :integration_providers, [provider])
+
+ assert_raise ArgumentError, ~r/missing logo_path/, fn ->
+ ProviderCatalog.providers()
+ end
+ end
+
+ test "malformed provider credential fields raise during catalog load" do
+ provider =
+ Map.put(acme_provider(), :credential_fields, [
+ %{key: "secret", type: :password, component: "bespoke"}
+ ])
+
+ Application.put_env(:fizz, :integration_providers, [provider])
+
+ assert_raise ArgumentError, ~r/unsupported component/, fn ->
+ ProviderCatalog.providers()
+ end
+ end
+ end
+
+ describe "integration registry extension" do
+ test "configured integrations append to built-ins by default" do
+ modules =
+ IntegrationRegistry.modules_for_load(
+ modules: [Fizz.TestSupport.CatalogValidation.AcmeDocs]
+ )
+
+ assert Fizz.Integrations.Library.Google.Sheets in modules
+ assert Fizz.TestSupport.CatalogValidation.AcmeDocs in modules
+ end
+
+ test "integrations can only replace built-ins with an explicit replacement flag" do
+ assert [Fizz.TestSupport.CatalogValidation.AcmeDocs] =
+ IntegrationRegistry.modules_for_load(
+ modules: [Fizz.TestSupport.CatalogValidation.AcmeDocs],
+ replace_modules: true
+ )
+ end
+
+ test "duplicate integration IDs raise during catalog load" do
+ modules =
+ IntegrationRegistry.modules_for_load(modules: [Fizz.Integrations.Library.Google.Sheets])
+
+ entries = IntegrationRegistry.entries_for_modules!(modules)
+
+ assert_raise ArgumentError, ~r/duplicate integration IDs/, fn ->
+ IntegrationRegistry.validate_entries!(entries)
+ end
+ end
+
+ test "unknown integration providers raise during catalog load" do
+ entries =
+ IntegrationRegistry.entries_for_modules!([
+ Fizz.TestSupport.CatalogValidation.UnknownProviderIntegration
+ ])
+
+ assert_raise ArgumentError, ~r/uses unknown provider missing_oauth/, fn ->
+ IntegrationRegistry.validate_entries!(entries)
+ end
+ end
+ end
+
+ describe "step registry validation" do
+ test "duplicate step type IDs raise during catalog load" do
+ assert_raise RuntimeError, ~r/Duplicate step type IDs/, fn ->
+ StepRegistry.types_for_modules!([
+ Fizz.Integrations.Library.Fizz.Builtins.ManualInput,
+ Fizz.Integrations.Library.Fizz.Builtins.ManualInput
+ ])
+ end
+ end
+
+ test "duplicate dynamic step registrations raise before replacing an existing type" do
+ {:ok, step_type} = StepRegistry.get("manual_input")
+
+ assert_raise ArgumentError, ~r/already registered/, fn ->
+ StepRegistry.register(step_type)
+ end
+ end
+
+ test "missing step icons raise during catalog load" do
+ assert_raise ArgumentError, ~r/missing icon/, fn ->
+ StepRegistry.types_for_modules!([Fizz.TestSupport.CatalogValidation.MissingIconStep])
+ end
+ end
+
+ test "unsupported UI components raise during catalog load" do
+ assert_raise ArgumentError, ~r/unsupported component "bespoke"/, fn ->
+ StepRegistry.types_for_modules!([
+ Fizz.TestSupport.CatalogValidation.UnsupportedComponentStep
+ ])
+ end
+ end
+
+ test "malformed resource metadata raises during catalog load" do
+ assert_raise ArgumentError, ~r/resource_locator must be a map/, fn ->
+ StepRegistry.types_for_modules!([
+ Fizz.TestSupport.CatalogValidation.InvalidResourceMetadataStep
+ ])
+ end
+ end
+
+ test "resource mapper references to missing fields raise during catalog load" do
+ assert_raise ArgumentError, ~r/references unknown field "missing_resource"/, fn ->
+ StepRegistry.types_for_modules!([
+ Fizz.TestSupport.CatalogValidation.InvalidResourceMapperReferenceStep
+ ])
+ end
+ end
+ end
+
+ defp acme_provider do
+ %{
+ id: "acme_api_key",
+ label: "Acme",
+ logo_path: "/images/acme.svg",
+ type: :api_key
+ }
+ end
+
+ defp restore_env(key, nil), do: Application.delete_env(:fizz, key)
+ defp restore_env(key, value), do: Application.put_env(:fizz, key, value)
+end
diff --git a/test/fizz/integrations/credential_field_test.exs b/test/fizz/integrations/credential_field_test.exs
new file mode 100644
index 0000000..cd081b2
--- /dev/null
+++ b/test/fizz/integrations/credential_field_test.exs
@@ -0,0 +1,59 @@
+defmodule Fizz.Integrations.CredentialFieldTest do
+ use ExUnit.Case, async: true
+
+ describe "credential fields" do
+ test "credential fields use the credential component in config schema" do
+ {:ok, openai_type} = Fizz.Integrations.Steps.Registry.get("openai_model")
+
+ ui = get_in(openai_type.config_schema, ["properties", "credential_ref", "ui"])
+
+ assert ui["component"] == "credential"
+ assert ui["requirement_key"] == "auth"
+ assert ui["provider"] == "openai_api_key"
+ assert ui["auth_type"] == "api_key"
+ end
+
+ test "credential field default config is a credential declaration" do
+ {:ok, openai_type} = Fizz.Integrations.Steps.Registry.get("openai_model")
+
+ default_config = Fizz.Integrations.Steps.Registry.get_default_config(openai_type.id)
+
+ assert %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => "openai_api_key",
+ "auth_type" => "api_key"
+ } = default_config["credential_ref"]
+ end
+ end
+
+ describe "AI model fields" do
+ test "OpenAI model field uses the catalog-backed search resolver" do
+ {:ok, openai_type} = Fizz.Integrations.Steps.Registry.get("openai_model")
+
+ model_schema = get_in(openai_type.config_schema, ["properties", "model"])
+
+ assert model_schema["type"] == "string"
+ assert get_in(model_schema, ["ui", "component"]) == "search"
+
+ assert get_in(model_schema, ["ui", "resolver"]) ==
+ Fizz.Integrations.Library.OpenAI.ModelResolver
+ end
+
+ test "Anthropic model field uses the catalog-backed search resolver" do
+ {:ok, anthropic_type} = Fizz.Integrations.Steps.Registry.get("anthropic_model")
+
+ model_schema = get_in(anthropic_type.config_schema, ["properties", "model"])
+
+ assert model_schema["type"] == "string"
+ assert get_in(model_schema, ["ui", "component"]) == "search"
+
+ assert get_in(model_schema, ["ui", "resolver"]) ==
+ Fizz.Integrations.Library.Anthropic.ModelResolver
+
+ default_config = Fizz.Integrations.Steps.Registry.get_default_config(anthropic_type.id)
+
+ assert default_config["model"] == "claude-sonnet-4-6"
+ end
+ end
+end
diff --git a/test/fizz/integrations/dynamic_resolver_test.exs b/test/fizz/integrations/dynamic_resolver_test.exs
new file mode 100644
index 0000000..bfc2819
--- /dev/null
+++ b/test/fizz/integrations/dynamic_resolver_test.exs
@@ -0,0 +1,148 @@
+defmodule Fizz.Integrations.Resolvers.DynamicTest do
+ use Fizz.DataCase, async: true
+
+ alias Fizz.Accounts.{ApiCredential, Scope}
+ alias Fizz.Integrations.Resolvers.Dynamic, as: DynamicResolver
+ alias Fizz.Integrations.Library.Google.Sheets.ColumnsResolver
+ alias Fizz.Workflows.Embeds.Step
+ alias Fizz.Workflows.WorkflowDefinitionVersion
+
+ import Fizz.AccountsFixtures
+
+ describe "resolve/4" do
+ test "dispatches schema-declared resource mapper resolvers with metadata" do
+ step = step("google_sheets_append_row")
+ draft = draft_with_step(step)
+
+ assert {:ok, result} =
+ DynamicResolver.resolve(
+ draft,
+ %{
+ "node_id" => step.id,
+ "field_key" => "values",
+ "params" => %{"mode" => "sheets"}
+ },
+ %{},
+ []
+ )
+
+ assert result.resolver == ColumnsResolver
+ assert result.options == []
+
+ assert result.meta.depends_on == [
+ "credential_ref",
+ "spreadsheet_id",
+ "sheet_name",
+ "table_id"
+ ]
+
+ assert result.meta.resource_mapper["kind"] == "google_sheets.row_values"
+ assert result.meta.resource_mapper["lookups"]["primary_resource"]["mode"] == "sheets"
+ assert result.meta.params["mode"] == "sheets"
+ end
+
+ test "keeps schema credential constraints authoritative over client params" do
+ user = user_fixture()
+ organization_id = "org_dynamic_resolver_#{System.unique_integer([:positive])}"
+ scope = Scope.for_user(user) |> Scope.with_organization_id(organization_id)
+
+ _openai_credential =
+ insert_api_credential!(user.id, organization_id, "openai_api_key", "OpenAI Primary")
+
+ _anthropic_credential =
+ insert_api_credential!(
+ user.id,
+ organization_id,
+ "anthropic_api_key",
+ "Anthropic Primary"
+ )
+
+ step = step("openai_model")
+ draft = draft_with_step(step)
+
+ assert {:ok, result} =
+ DynamicResolver.resolve(
+ draft,
+ %{
+ "node_id" => step.id,
+ "field_key" => "credential_ref",
+ "provider_filter" => ["anthropic_api_key"],
+ "auth_types" => ["api_key"],
+ "params" => %{
+ "provider_filter" => ["anthropic_api_key"],
+ "auth_types" => ["api_key"]
+ }
+ },
+ %{current_scope: scope},
+ []
+ )
+
+ assert [%{"display_name" => "OpenAI Primary", "provider" => "openai_api_key"}] =
+ result.options
+
+ assert result.meta.params["provider_filter"] == "openai_api_key"
+ assert result.meta.params["auth_types"] == "api_key"
+ end
+
+ test "returns stable errors for missing step fields" do
+ step = step("google_sheets_append_row")
+ draft = draft_with_step(step)
+
+ assert {:error, :field_not_found} =
+ DynamicResolver.resolve(
+ draft,
+ %{"node_id" => step.id, "field_key" => "missing"},
+ %{},
+ []
+ )
+ end
+
+ test "returns stable errors when draft is unavailable" do
+ assert {:error, :draft_not_loaded} =
+ DynamicResolver.resolve(
+ nil,
+ %{"node_id" => Ecto.UUID.generate(), "field_key" => "values"},
+ %{},
+ []
+ )
+ end
+ end
+
+ defp step(type_id) do
+ %Step{
+ id: Ecto.UUID.generate(),
+ type_id: type_id,
+ name: "Step",
+ config: %{},
+ position: %{}
+ }
+ end
+
+ defp draft_with_step(%Step{} = step) do
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ version: 1,
+ status: :draft,
+ steps: [step],
+ connections: [],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+
+ defp insert_api_credential!(user_id, organization_id, provider, provider_label) do
+ unique = System.unique_integer([:positive])
+
+ %ApiCredential{}
+ |> ApiCredential.changeset(%{
+ user_id: user_id,
+ workos_organization_id: organization_id,
+ provider: provider,
+ provider_label: provider_label,
+ vault_object_id: "vault_obj_#{unique}",
+ vault_object_name: "vault_name_#{unique}"
+ })
+ |> Repo.insert!()
+ end
+end
diff --git a/test/fizz/integrations/external_steps_test.exs b/test/fizz/integrations/external_steps_test.exs
new file mode 100644
index 0000000..ef245dd
--- /dev/null
+++ b/test/fizz/integrations/external_steps_test.exs
@@ -0,0 +1,175 @@
+defmodule Fizz.Integrations.ExternalStepsTest do
+ use Fizz.IntegrationStepCase, async: true
+
+ alias Fizz.Integrations.Catalog.IntegrationRegistry, as: IntegrationRegistry
+ alias Fizz.Integrations.Steps.Registry, as: StepRegistry
+
+ @expected_integrations [
+ {"anthropic", "anthropic_api_key", ["anthropic_vision_analysis"]},
+ {"box", "box_oauth", ["box_upload_file"]},
+ {"github", "github_oauth", ["github_create_issue", "github_create_pr"]},
+ {"gmail", "google_oauth", ["gmail_send_email", "gmail_reply_email"]},
+ {"google_docs", "google_oauth", ["google_docs_create_doc", "google_docs_append_text"]},
+ {"google_drive", "google_oauth", ["google_drive_upload_file"]},
+ {"google_slides", "google_oauth",
+ ["google_slides_create_presentation", "google_slides_add_slide"]},
+ {"notion", "notion_oauth", ["notion_create_page", "notion_update_page"]},
+ {"onedrive", "microsoft_oauth", ["onedrive_upload_file"]},
+ {"openai", "openai_api_key", ["openai_image_generation"]},
+ {"outlook", "microsoft_oauth", ["outlook_send_email"]},
+ {"powerpoint", "microsoft_oauth", ["powerpoint_create_presentation"]},
+ {"sharepoint", "microsoft_oauth", ["sharepoint_upload_file"]},
+ {"slack", "slack_oauth", ["slack_send_message", "slack_create_channel"]},
+ {"teams", "microsoft_oauth", ["teams_send_message"]}
+ ]
+
+ @external_support_steps [
+ {"anthropic_model", "anthropic_api_key", "anthropic"},
+ {"gmail_trigger", "google_oauth", "gmail"},
+ {"github_trigger", "github_oauth", "github"},
+ {"google_docs_trigger", "google_oauth", "google_docs"},
+ {"google_sheets_trigger", "google_oauth", "google_sheets"},
+ {"notion_trigger", "notion_oauth", "notion"},
+ {"openai_model", "openai_api_key", "openai"},
+ {"outlook_trigger", "microsoft_oauth", "outlook"},
+ {"slack_trigger", "slack_oauth", "slack"},
+ {"teams_trigger", "microsoft_oauth", "teams"}
+ ]
+
+ @placeholder_steps [
+ {"anthropic_vision_analysis", "anthropic_api_key", "anthropic"},
+ {"box_upload_file", "box_oauth", "box"},
+ {"github_create_issue", "github_oauth", "github"},
+ {"github_create_pr", "github_oauth", "github"},
+ {"gmail_reply_email", "google_oauth", "gmail"},
+ {"gmail_send_email", "google_oauth", "gmail"},
+ {"gmail_trigger", "google_oauth", "gmail"},
+ {"google_docs_append_text", "google_oauth", "google_docs"},
+ {"google_docs_create_doc", "google_oauth", "google_docs"},
+ {"google_docs_trigger", "google_oauth", "google_docs"},
+ {"google_drive_upload_file", "google_oauth", "google_drive"},
+ {"google_slides_add_slide", "google_oauth", "google_slides"},
+ {"google_slides_create_presentation", "google_oauth", "google_slides"},
+ {"notion_create_page", "notion_oauth", "notion"},
+ {"notion_trigger", "notion_oauth", "notion"},
+ {"notion_update_page", "notion_oauth", "notion"},
+ {"onedrive_upload_file", "microsoft_oauth", "onedrive"},
+ {"openai_image_generation", "openai_api_key", "openai"},
+ {"outlook_send_email", "microsoft_oauth", "outlook"},
+ {"outlook_trigger", "microsoft_oauth", "outlook"},
+ {"powerpoint_create_presentation", "microsoft_oauth", "powerpoint"},
+ {"sharepoint_upload_file", "microsoft_oauth", "sharepoint"},
+ {"slack_create_channel", "slack_oauth", "slack"},
+ {"slack_send_message", "slack_oauth", "slack"},
+ {"slack_trigger", "slack_oauth", "slack"},
+ {"teams_send_message", "microsoft_oauth", "teams"},
+ {"teams_trigger", "microsoft_oauth", "teams"}
+ ]
+
+ @fizz_step_ids [
+ "aggregator",
+ "ai_agent",
+ "ai_structure_schema",
+ "ai_tool_http",
+ "condition",
+ "data_filter",
+ "data_output",
+ "data_transform",
+ "debug",
+ "format",
+ "http_request",
+ "join",
+ "json_parser",
+ "manual_input",
+ "math",
+ "on_chat_trigger",
+ "schedule_trigger",
+ "splitter",
+ "switch",
+ "wait"
+ ]
+
+ describe "product integration catalog entries" do
+ test "static external integrations expose action step type IDs and step modules" do
+ for {integration_id, provider_id, action_ids} <- @expected_integrations do
+ assert {:ok, integration} = IntegrationRegistry.get(integration_id)
+
+ assert integration.provider_id == provider_id
+ assert integration.actions == action_ids
+ assert integration.step_modules != []
+
+ for step_id <- action_ids do
+ step_type = step_type!(step_id)
+
+ assert step_type.provider == provider_id
+ assert step_type.integration == integration_id
+ end
+ end
+ end
+
+ test "the Fizz integration owns providerless built-in step modules" do
+ assert {:ok, integration} = IntegrationRegistry.get("fizz")
+
+ assert integration.provider_id == nil
+
+ assert integration.step_modules |> Enum.map(& &1.__step_id__()) |> Enum.sort() ==
+ @fizz_step_ids
+
+ for step_id <- @fizz_step_ids do
+ step_type = step_type!(step_id)
+ assert {:ok, module} = StepType.executor_module(step_type)
+
+ refute step_type.provider
+ assert step_type.integration == "fizz"
+
+ assert module
+ |> Atom.to_string()
+ |> String.starts_with?("Elixir.Fizz.Integrations.Library.Fizz.Builtins.")
+ end
+ end
+
+ test "provider-owned trigger and subnode steps declare catalog ownership" do
+ for {step_id, provider_id, integration_id} <- @external_support_steps do
+ step_type = step_type!(step_id)
+
+ assert step_type.provider == provider_id
+ assert step_type.integration == integration_id
+ end
+ end
+
+ test "every registered step declares an integration owner" do
+ for step_type <- StepRegistry.all() do
+ assert is_binary(step_type.integration)
+ assert step_type.integration != ""
+ end
+ end
+
+ test "provider-owned step executors live under integration namespaces" do
+ for step_type <- StepRegistry.all(), is_binary(step_type.provider) do
+ assert {:ok, module} = StepType.executor_module(step_type)
+
+ module_name = Atom.to_string(module)
+
+ assert String.starts_with?(module_name, "Elixir.Fizz.Integrations.")
+ refute String.starts_with?(module_name, "Elixir.Fizz.Integrations.Library.Fizz.")
+ end
+ end
+ end
+
+ describe "placeholder external executors" do
+ test "skeleton steps return a typed not-implemented payload" do
+ for {step_id, provider_id, integration_id} <- @placeholder_steps do
+ assert {:ok, result} = execute_step(step_id)
+
+ assert %{
+ "status" => "not_implemented",
+ "step_type_id" => ^step_id,
+ "provider" => ^provider_id,
+ "integration" => ^integration_id
+ } = result
+
+ assert is_binary(result["message"])
+ end
+ end
+ end
+end
diff --git a/test/fizz/integrations/fizz/builtins/aggregator_test.exs b/test/fizz/integrations/fizz/builtins/aggregator_test.exs
new file mode 100644
index 0000000..0f7a193
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/aggregator_test.exs
@@ -0,0 +1,53 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.AggregatorTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.Aggregator
+
+ test "collect returns the list unchanged" do
+ assert {:ok, [1, 2, 3]} = Aggregator.execute(%{"operation" => "collect"}, [1, 2, 3], %{})
+ end
+
+ test "sum adds numeric items" do
+ assert {:ok, 6} = Aggregator.execute(%{"operation" => "sum"}, [1, 2, 3], %{})
+ end
+
+ test "count returns the number of items" do
+ assert {:ok, 3} = Aggregator.execute(%{"operation" => "count"}, ["a", "b", "c"], %{})
+ end
+
+ test "concat joins stringified values" do
+ assert {:ok, "abc"} = Aggregator.execute(%{"operation" => "concat"}, ["a", "b", "c"], %{})
+ end
+
+ test "first returns the first item and last returns the last item" do
+ assert {:ok, 1} = Aggregator.execute(%{"operation" => "first"}, [1, 2, 3], %{})
+ assert {:ok, 3} = Aggregator.execute(%{"operation" => "last"}, [1, 2, 3], %{})
+ end
+
+ test "min and max return the expected bounds" do
+ assert {:ok, 1} = Aggregator.execute(%{"operation" => "min"}, [3, 1, 2], %{})
+ assert {:ok, 3} = Aggregator.execute(%{"operation" => "max"}, [3, 1, 2], %{})
+ end
+
+ test "empty list semantics match each operation's default" do
+ assert {:ok, []} = Aggregator.execute(%{"operation" => "collect"}, [], %{})
+ assert {:ok, 0} = Aggregator.execute(%{"operation" => "sum"}, [], %{})
+ assert {:ok, 0} = Aggregator.execute(%{"operation" => "count"}, [], %{})
+ assert {:ok, ""} = Aggregator.execute(%{"operation" => "concat"}, [], %{})
+ assert {:ok, nil} = Aggregator.execute(%{"operation" => "first"}, [], %{})
+ assert {:ok, nil} = Aggregator.execute(%{"operation" => "last"}, [], %{})
+ assert {:ok, nil} = Aggregator.execute(%{"operation" => "min"}, [], %{})
+ assert {:ok, nil} = Aggregator.execute(%{"operation" => "max"}, [], %{})
+ end
+
+ test "init_for_operation exposes the same defaults used by reductions" do
+ assert [] == Aggregator.init_for_operation("collect")
+ assert 0 == Aggregator.init_for_operation("sum")
+ assert 0 == Aggregator.init_for_operation("count")
+ assert "" == Aggregator.init_for_operation("concat")
+ assert nil == Aggregator.init_for_operation("first")
+ assert nil == Aggregator.init_for_operation("last")
+ assert nil == Aggregator.init_for_operation("min")
+ assert nil == Aggregator.init_for_operation("max")
+ end
+end
diff --git a/test/fizz/integrations/fizz/builtins/ai_agent_test.exs b/test/fizz/integrations/fizz/builtins/ai_agent_test.exs
new file mode 100644
index 0000000..4b8b86d
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/ai_agent_test.exs
@@ -0,0 +1,131 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.AIAgentTest do
+ use ExUnit.Case, async: false
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.AIAgent
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.ChatModelProviders.Registry,
+ as: ChatModelProviderRegistry
+
+ defmodule ReqLLMResponseProvider do
+ @behaviour Fizz.Integrations.Library.Fizz.Builtins.ChatModelProviders.Provider
+
+ @impl true
+ def provider_prefix, do: "test"
+
+ @impl true
+ def generate(_request, _context) do
+ {:ok,
+ %ReqLLM.Response{
+ id: "resp_test",
+ model: "test-model",
+ context: nil,
+ message: %ReqLLM.Message{
+ role: :assistant,
+ content: [ReqLLM.Message.ContentPart.text("done")],
+ tool_calls: [
+ ReqLLM.ToolCall.new("call_test", "lookup_contact", ~s({"email":"ada@example.com"}))
+ ]
+ },
+ object: %{accepted: true},
+ usage: %{input_tokens: 7, output_tokens: 3, total_tokens: 10},
+ finish_reason: :stop
+ }}
+ end
+ end
+
+ test "unwraps nested structured schema dependency output when assembling payload" do
+ json_schema = %{
+ "type" => "object",
+ "additionalProperties" => false,
+ "properties" => %{"result" => %{"type" => "number"}},
+ "required" => ["result"]
+ }
+
+ input = %{
+ "main" => %{"number" => 7},
+ "model" => %{
+ "kind" => "ai.chat_model",
+ "provider" => "openai_api_key",
+ "credential_ref" => %{"id" => "credential-id", "provider" => "openai_api_key"},
+ "model_spec" => "openai:gpt-5.5"
+ },
+ "structured_schema" => %{
+ "kind" => "ai.schema",
+ "name" => "multiply_by_10_result",
+ "strict" => true,
+ "schema" => %{
+ "name" => "multiply_by_10_result",
+ "strict" => true,
+ "json_schema" => json_schema
+ }
+ }
+ }
+
+ assert {:ok, output} =
+ AIAgent.execute(
+ %{"mode" => "assemble_only", "user_message" => "Multiply 7 by 10"},
+ input,
+ %{}
+ )
+
+ assert get_in(output, ["structured_schema", "schema"]) == json_schema
+ assert get_in(output, ["response_format", "json_schema", "schema"]) == json_schema
+ end
+
+ test "provider chat stores a JSON-safe response payload" do
+ previous_registry_config = Application.get_env(:fizz, ChatModelProviderRegistry)
+
+ Application.put_env(:fizz, ChatModelProviderRegistry,
+ provider_modules: [ReqLLMResponseProvider]
+ )
+
+ on_exit(fn ->
+ if is_nil(previous_registry_config) do
+ Application.delete_env(:fizz, ChatModelProviderRegistry)
+ else
+ Application.put_env(:fizz, ChatModelProviderRegistry, previous_registry_config)
+ end
+ end)
+
+ input = %{
+ "main" => %{"email" => [1, 2]},
+ "model" => %{
+ "kind" => "ai.chat_model",
+ "provider" => "test",
+ "credential_ref" => %{"id" => "credential-id", "provider" => "test"},
+ "model_spec" => "test:model"
+ }
+ }
+
+ assert {:ok, output} =
+ AIAgent.execute(
+ %{"mode" => "provider_chat", "user_message" => "{{ json }}"},
+ input,
+ %{}
+ )
+
+ assert output["response"] == %{
+ "finish_reason" => "stop",
+ "id" => "resp_test",
+ "model" => "test-model",
+ "object" => %{"accepted" => true},
+ "ok" => true,
+ "text" => "done",
+ "tool_calls" => [
+ %{
+ "function" => %{
+ "arguments" => ~s({"email":"ada@example.com"}),
+ "name" => "lookup_contact"
+ },
+ "id" => "call_test",
+ "type" => "function"
+ }
+ ],
+ "usage" => %{
+ "input_tokens" => 7,
+ "output_tokens" => 3,
+ "total_tokens" => 10
+ }
+ }
+ end
+end
diff --git a/test/fizz/integrations/fizz/builtins/ai_structure_schema_test.exs b/test/fizz/integrations/fizz/builtins/ai_structure_schema_test.exs
new file mode 100644
index 0000000..017ac71
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/ai_structure_schema_test.exs
@@ -0,0 +1,34 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.AIStructureSchemaTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.AIStructureSchema
+
+ test "unwraps a full structure schema pasted into the json_schema field" do
+ json_schema = %{
+ "type" => "object",
+ "additionalProperties" => false,
+ "properties" => %{
+ "result" => %{
+ "type" => "number",
+ "description" => "The input number multiplied by 10."
+ }
+ },
+ "required" => ["result"]
+ }
+
+ config = %{
+ "name" => "multiply_by_10_result",
+ "strict" => true,
+ "json_schema" => %{
+ "name" => "multiply_by_10_result",
+ "strict" => true,
+ "json_schema" => json_schema
+ }
+ }
+
+ assert {:ok, output} = AIStructureSchema.execute(config, %{}, %{})
+ assert output["kind"] == "ai.schema"
+ assert output["schema"] == json_schema
+ assert get_in(output, ["response_format", "json_schema", "schema"]) == json_schema
+ end
+end
diff --git a/test/fizz/steps/executors/condition_test.exs b/test/fizz/integrations/fizz/builtins/condition_test.exs
similarity index 90%
rename from test/fizz/steps/executors/condition_test.exs
rename to test/fizz/integrations/fizz/builtins/condition_test.exs
index c2ce0b6..082a844 100644
--- a/test/fizz/steps/executors/condition_test.exs
+++ b/test/fizz/integrations/fizz/builtins/condition_test.exs
@@ -1,7 +1,7 @@
-defmodule Fizz.Steps.Executors.ConditionTest do
+defmodule Fizz.Integrations.Library.Fizz.Builtins.ConditionTest do
use ExUnit.Case, async: true
- alias Fizz.Steps.Executors.Condition
+ alias Fizz.Integrations.Library.Fizz.Builtins.Condition
test "passes input through when condition is truthy" do
input = %{"status" => "active"}
diff --git a/test/fizz/integrations/fizz/builtins/join_test.exs b/test/fizz/integrations/fizz/builtins/join_test.exs
new file mode 100644
index 0000000..1bd8e1a
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/join_test.exs
@@ -0,0 +1,48 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.JoinTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.Join
+
+ test "wait_all flattens one value from each branch" do
+ assert {:ok, ["left", "right"]} =
+ Join.execute(%{"mode" => "wait_all"}, [["left"], ["right"]], %{})
+ end
+
+ test "zip_nil pads shorter branches with nil" do
+ assert {:ok, [[1, "a"], [2, nil]]} =
+ Join.execute(%{"mode" => "zip_nil"}, [[1, 2], ["a"]], %{})
+ end
+
+ test "zip_shortest truncates to the shortest branch" do
+ assert {:ok, [[1, "a"]]} =
+ Join.execute(%{"mode" => "zip_shortest"}, [[1, 2], ["a"]], %{})
+ end
+
+ test "zip_cycle cycles shorter branches to match the longest branch" do
+ assert {:ok, [[1, "a"], [2, "a"], [3, "a"]]} =
+ Join.execute(%{"mode" => "zip_cycle"}, [[1, 2, 3], ["a"]], %{})
+ end
+
+ test "cartesian produces all combinations" do
+ assert {:ok, [[1, "a"], [1, "b"], [2, "a"], [2, "b"]]} =
+ Join.execute(%{"mode" => "cartesian"}, [[1, 2], ["a", "b"]], %{})
+ end
+
+ test "flatten flattens one level from the computed result" do
+ assert {:ok, [1, "a", 2, "b"]} =
+ Join.execute(%{"mode" => "zip_nil", "flatten" => true}, [[1, 2], ["a", "b"]], %{})
+ end
+
+ test "normalizes scalar input into a single branch list" do
+ assert {:ok, [["value"]]} = Join.execute(%{"mode" => "zip_nil"}, "value", %{})
+ end
+
+ test "normalizes a plain list into a single branch" do
+ assert {:ok, [[1], [2], [3]]} = Join.execute(%{"mode" => "zip_nil"}, [1, 2, 3], %{})
+ end
+
+ test "treats a list of lists as pre-grouped branch values" do
+ assert {:ok, [[1, "a"], [2, "b"]]} =
+ Join.execute(%{"mode" => "zip_nil"}, [[1, 2], ["a", "b"]], %{})
+ end
+end
diff --git a/test/fizz/integrations/fizz/builtins/manual_input_trigger_test.exs b/test/fizz/integrations/fizz/builtins/manual_input_trigger_test.exs
new file mode 100644
index 0000000..98c256b
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/manual_input_trigger_test.exs
@@ -0,0 +1,26 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.ManualInputTriggerTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.ManualInput
+ alias Fizz.Triggers.RegistrationSpec
+
+ test "registration_spec/2 returns a manual registration spec" do
+ config = %{
+ "input_schema" => %{"type" => "object", "properties" => %{"name" => %{"type" => "string"}}}
+ }
+
+ assert {:ok,
+ %RegistrationSpec{
+ kind: :manual,
+ params: %{"input_schema" => input_schema}
+ }} = ManualInput.registration_spec(config, %{})
+
+ assert input_schema == config["input_schema"]
+ end
+
+ test "normalize_event/2 passes through input" do
+ raw_event = %{"name" => "Ada", "count" => 3}
+
+ assert {:ok, ^raw_event} = ManualInput.normalize_event(%{}, raw_event)
+ end
+end
diff --git a/test/fizz/integrations/fizz/builtins/schedule_trigger_test.exs b/test/fizz/integrations/fizz/builtins/schedule_trigger_test.exs
new file mode 100644
index 0000000..296015c
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/schedule_trigger_test.exs
@@ -0,0 +1,37 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.ScheduleTriggerTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.ScheduleTrigger
+ alias Fizz.Triggers.RegistrationSpec
+
+ test "registration_spec/2 returns a schedule registration spec with cron params" do
+ config = %{"cron_expression" => "0 9 * * MON-FRI", "timezone" => "UTC"}
+
+ assert {:ok,
+ %RegistrationSpec{
+ kind: :schedule,
+ params: %{
+ "cron" => "0 9 * * MON-FRI",
+ "timezone" => "UTC"
+ }
+ }} = ScheduleTrigger.registration_spec(config, %{})
+ end
+
+ test "normalize_event/2 produces a scheduled_at timestamp" do
+ assert {:ok, %{"scheduled_at" => scheduled_at}} = ScheduleTrigger.normalize_event(%{}, %{})
+ assert {:ok, _datetime, 0} = DateTime.from_iso8601(scheduled_at)
+ end
+
+ test "validate_config/1 accepts a cron expression" do
+ assert :ok = ScheduleTrigger.validate_config(%{"cron_expression" => "*/15 * * * *"})
+ end
+
+ test "validate_config/1 accepts an interval" do
+ assert :ok = ScheduleTrigger.validate_config(%{"interval_seconds" => 60})
+ end
+
+ test "validate_config/1 rejects missing cron and interval" do
+ assert {:error, [schedule: "must provide cron_expression or interval_seconds"]} =
+ ScheduleTrigger.validate_config(%{})
+ end
+end
diff --git a/test/fizz/integrations/fizz/builtins/splitter_test.exs b/test/fizz/integrations/fizz/builtins/splitter_test.exs
new file mode 100644
index 0000000..b0566a0
--- /dev/null
+++ b/test/fizz/integrations/fizz/builtins/splitter_test.exs
@@ -0,0 +1,30 @@
+defmodule Fizz.Integrations.Library.Fizz.Builtins.SplitterTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Fizz.Builtins.Splitter
+
+ test "returns an empty list when input is nil" do
+ assert {:ok, []} = Splitter.execute(%{}, nil, %{})
+ end
+
+ test "wraps a scalar input in a single-item list" do
+ assert {:ok, [42]} = Splitter.execute(%{}, 42, %{})
+ end
+
+ test "converts a map input into key value tuples" do
+ assert {:ok, entries} = Splitter.execute(%{}, %{"a" => 1, "b" => 2}, %{})
+
+ assert Enum.sort(entries) == [{"a", 1}, {"b", 2}]
+ end
+
+ test "converts a range input into a list" do
+ assert {:ok, [1, 2, 3]} = Splitter.execute(%{}, 1..3, %{})
+ end
+
+ test "extracts items from a configured field path" do
+ input = %{"payload" => %{"items" => ["a", "b", "c"]}}
+
+ assert {:ok, ["a", "b", "c"]} =
+ Splitter.execute(%{"field" => "payload.items"}, input, %{})
+ end
+end
diff --git a/test/fizz/steps/executors/switch_test.exs b/test/fizz/integrations/fizz/builtins/switch_test.exs
similarity index 94%
rename from test/fizz/steps/executors/switch_test.exs
rename to test/fizz/integrations/fizz/builtins/switch_test.exs
index 393b5e7..6fd6a97 100644
--- a/test/fizz/steps/executors/switch_test.exs
+++ b/test/fizz/integrations/fizz/builtins/switch_test.exs
@@ -1,7 +1,7 @@
-defmodule Fizz.Steps.Executors.SwitchTest do
+defmodule Fizz.Integrations.Library.Fizz.Builtins.SwitchTest do
use ExUnit.Case, async: true
- alias Fizz.Steps.Executors.Switch
+ alias Fizz.Integrations.Library.Fizz.Builtins.Switch
test "routes to matched branch using pre-resolved value" do
config = %{
diff --git a/test/fizz/integrations/google/sheets/actions/append_row_test.exs b/test/fizz/integrations/google/sheets/actions/append_row_test.exs
new file mode 100644
index 0000000..42e9450
--- /dev/null
+++ b/test/fizz/integrations/google/sheets/actions/append_row_test.exs
@@ -0,0 +1,115 @@
+defmodule Fizz.Integrations.Library.Google.Sheets.Actions.AppendRowTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Google.Sheets.Actions.AppendRow
+ alias Fizz.Workflows.StepExecutor, as: Executor
+ alias Fizz.Workflows.StepError
+
+ describe "execute/3" do
+ test "requires a resolved credential ref through the step executor" do
+ config = %{
+ "spreadsheet_id" => "sheet_123",
+ "values" => %{"A" => "1"}
+ }
+
+ assert {:error, %StepError{code: :credential_ref_required, category: :credential}} =
+ Executor.execute("google_sheets_append_row", config, %{}, %{})
+ end
+ end
+
+ describe "build_row/2" do
+ test "passes lists through unchanged" do
+ assert {:ok, ["a", "b", "c"]} = AppendRow.build_row(["a", "b", "c"], ["ignored"])
+ end
+
+ test "projects map values onto the sheet's header order" do
+ values = %{"Email" => "ada@example.com", "Name" => "Ada"}
+ headers = ["Name", "Email", "Joined"]
+
+ assert {:ok, ["Ada", "ada@example.com", ""]} = AppendRow.build_row(values, headers)
+ end
+
+ test "fills missing columns with empty strings" do
+ assert {:ok, ["", "", ""]} = AppendRow.build_row(%{}, ["A", "B", "C"])
+ end
+
+ test "ignores keys that do not match any column header" do
+ values = %{"Name" => "Ada", "Phone" => "555-0100"}
+ headers = ["Name", "Email"]
+
+ assert {:ok, ["Ada", ""]} = AppendRow.build_row(values, headers)
+ end
+
+ test "returns :invalid_row_values for non-map, non-list input" do
+ assert {:error, :invalid_row_values} = AppendRow.build_row("not a row", ["A"])
+ assert {:error, :invalid_row_values} = AppendRow.build_row(nil, ["A"])
+ end
+ end
+
+ describe "build_rows/2" do
+ test "wraps a positional row" do
+ assert {:ok, [["a", "b", "c"]]} = AppendRow.build_rows(["a", "b", "c"], ["ignored"])
+ end
+
+ test "passes multiple positional rows through" do
+ rows = [["Ada", "ada@example.com"], ["Grace", "grace@example.com"]]
+
+ assert {:ok, ^rows} = AppendRow.build_rows(rows, ["ignored"])
+ end
+
+ test "projects multiple mapped rows onto the sheet header order" do
+ rows = [
+ %{"Email" => "ada@example.com", "Name" => "Ada"},
+ %{"Email" => "grace@example.com", "Name" => "Grace"}
+ ]
+
+ assert {:ok, [["Ada", "ada@example.com"], ["Grace", "grace@example.com"]]} =
+ AppendRow.build_rows(rows, ["Name", "Email"])
+ end
+
+ test "wraps a mapped row" do
+ assert {:ok, [["Ada", ""]]} =
+ AppendRow.build_rows(%{"Name" => "Ada"}, ["Name", "Email"])
+ end
+
+ test "rejects mixed row collections" do
+ assert {:error, :invalid_row_values} =
+ AppendRow.build_rows([%{"Name" => "Ada"}, ["Grace"]], ["Name"])
+
+ assert {:error, :invalid_row_values} =
+ AppendRow.build_rows([["Ada"], %{"Name" => "Grace"}], ["Name"])
+ end
+ end
+
+ describe "__step_definition__/0" do
+ test "exposes the resource mapper UI hint on the values field" do
+ schema = AppendRow.__step_definition__().config_schema
+ values_field = get_in(schema, ["properties", "values"])
+
+ assert get_in(values_field, ["ui", "component"]) == "resource_mapper"
+
+ assert values_field["depends_on"] == [
+ "credential_ref",
+ "spreadsheet_id",
+ "sheet_name",
+ "table_id"
+ ]
+
+ assert get_in(values_field, ["resource_mapper", "kind"]) == "google_sheets.row_values"
+
+ assert get_in(values_field, ["ui", "resolver"]) ==
+ Fizz.Integrations.Library.Google.Sheets.ColumnsResolver
+ end
+
+ test "keeps sheet and table selection state hidden because the mapper owns it" do
+ schema = AppendRow.__step_definition__().config_schema
+ sheet_name_field = get_in(schema, ["properties", "sheet_name"])
+ table_id_field = get_in(schema, ["properties", "table_id"])
+
+ assert sheet_name_field["default"] == ""
+ assert get_in(sheet_name_field, ["ui", "component"]) == "hidden"
+ assert table_id_field["default"] == ""
+ assert get_in(table_id_field, ["ui", "component"]) == "hidden"
+ end
+ end
+end
diff --git a/test/fizz/integrations/google/sheets/client_test.exs b/test/fizz/integrations/google/sheets/client_test.exs
new file mode 100644
index 0000000..679a22f
--- /dev/null
+++ b/test/fizz/integrations/google/sheets/client_test.exs
@@ -0,0 +1,285 @@
+defmodule Fizz.Integrations.Library.Google.Sheets.ClientTest do
+ use Fizz.DataCase, async: false
+
+ alias Fizz.Accounts.OauthConnection
+ alias Fizz.Workflows.StepError
+ alias Fizz.Integrations.Library.Google.Sheets.Client
+ alias Fizz.Repo
+ alias Fizz.Workflows.ExecutionContext
+ alias Fizz.WorkOSHTTPMock
+
+ setup context do
+ previous_google_sheets_req_options =
+ Application.get_env(:fizz, :google_sheets_req_options)
+
+ previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
+ previous_workos_http_backoff_ms = Application.get_env(:fizz, :workos_http_backoff_ms)
+ previous_workos_client = Application.get_env(:workos, WorkOS.Client)
+ store_pid = start_supervised!({Agent, fn -> [] end})
+
+ Req.Test.set_req_test_from_context(context)
+ Req.Test.verify_on_exit!(context)
+
+ Application.put_env(:fizz, :google_sheets_req_options, plug: {Req.Test, __MODULE__})
+ :ok = WorkOSHTTPMock.configure(self(), store_pid)
+ Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPMock)
+ Application.put_env(:fizz, :workos_http_backoff_ms, 0)
+
+ Application.put_env(:workos, WorkOS.Client,
+ api_key: "sk_test_123",
+ client_id: "client_test_123",
+ client: Fizz.Accounts.WorkOS.ReqClient
+ )
+
+ on_exit(fn ->
+ restore_env(:fizz, :google_sheets_req_options, previous_google_sheets_req_options)
+ restore_env(:fizz, :workos_http_client_module, previous_http_client)
+ restore_env(:fizz, :workos_http_backoff_ms, previous_workos_http_backoff_ms)
+ restore_env(:workos, WorkOS.Client, previous_workos_client)
+ WorkOSHTTPMock.reset()
+ end)
+
+ :ok
+ end
+
+ describe "get_tables/3" do
+ test "fills omitted native table column names from the visible header row" do
+ scope = Fizz.WorkflowsFixtures.project_scope_fixture()
+ connection = insert_oauth_connection!(scope.user.id, scope.organization_id, "google_oauth")
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ pipes_token_response("sheet-token")
+ ])
+
+ Req.Test.expect(__MODULE__, fn conn ->
+ conn = Plug.Conn.fetch_query_params(conn)
+
+ assert conn.method == "GET"
+ assert conn.request_path == "/v4/spreadsheets/sheet_123"
+ assert conn.query_params["fields"] =~ "sheets.tables"
+
+ Req.Test.json(conn, %{
+ "sheets" => [
+ %{
+ "properties" => %{"sheetId" => 0, "title" => "Sheet1"},
+ "tables" => [
+ %{
+ "tableId" => "tbl_1",
+ "name" => "Table1",
+ "range" => %{
+ "sheetId" => 0,
+ "startRowIndex" => 0,
+ "endRowIndex" => 3,
+ "startColumnIndex" => 0,
+ "endColumnIndex" => 3
+ },
+ "columnProperties" => [
+ %{"columnIndex" => 1, "columnName" => "res"},
+ %{"columnIndex" => 2, "columnName" => "test"}
+ ]
+ }
+ ]
+ }
+ ]
+ })
+ end)
+
+ Req.Test.expect(__MODULE__, fn conn ->
+ conn = Plug.Conn.fetch_query_params(conn)
+
+ ["/v4/spreadsheets/sheet_123", encoded_range] =
+ String.split(conn.request_path, "/values/", parts: 2)
+
+ assert conn.method == "GET"
+ assert URI.decode(encoded_range) == "Sheet1!A1:C1"
+ assert conn.query_params["valueRenderOption"] == "FORMATTED_VALUE"
+
+ Req.Test.json(conn, %{"values" => [["num1", "res", "test"]]})
+ end)
+
+ params = %{
+ "spreadsheet_id" => "sheet_123",
+ "credential_ref" => %{
+ "id" => connection.id,
+ "provider" => "google_oauth",
+ "auth_type" => "oauth",
+ "owner_user_id" => scope.user.id
+ }
+ }
+
+ context = %ExecutionContext{scope: scope, project_id: scope.project.id}
+
+ assert {:ok, [%{"columns" => columns}]} = Client.get_tables(params, context)
+
+ assert Enum.map(columns, &Map.get(&1, "label")) == ["num1", "res", "test"]
+ end
+ end
+
+ describe "error normalization" do
+ test "returns operation errors for missing required params" do
+ assert {:error, %StepError{} = error} = Client.get_sheet_names(%{}, %{})
+
+ assert error.code == :missing_param
+ assert error.category == :validation
+ assert error.details == %{param: "spreadsheet_id"}
+ end
+
+ test "returns operation errors for missing credential refs" do
+ assert {:error, %StepError{} = error} =
+ Client.get_sheet_names(%{"spreadsheet_id" => "sheet_123"}, %{})
+
+ assert error.code == :credential_ref_required
+ assert error.category == :credential
+ end
+
+ test "returns retryable operation errors for rate-limit responses" do
+ scope = Fizz.WorkflowsFixtures.project_scope_fixture()
+ connection = insert_oauth_connection!(scope.user.id, scope.organization_id, "google_oauth")
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ pipes_token_response("sheet-token")
+ ])
+
+ Req.Test.expect(__MODULE__, fn conn ->
+ conn = Plug.Conn.fetch_query_params(conn)
+
+ assert conn.method == "GET"
+ assert conn.request_path == "/v4/spreadsheets/sheet_123"
+
+ conn
+ |> Plug.Conn.put_status(429)
+ |> Plug.Conn.put_resp_header("retry-after", "2")
+ |> Req.Test.json(%{"error" => %{"message" => "quota exceeded"}})
+ end)
+
+ assert {:error, %StepError{} = error} =
+ Client.get_sheet_names(
+ client_params(scope, connection),
+ execution_context(scope)
+ )
+
+ assert error.code == :rate_limited
+ assert error.category == :rate_limit
+ assert error.status == 429
+ assert error.retry_after_ms == 2_000
+ assert error.retryable?
+ assert error.message == "quota exceeded"
+ end
+
+ test "returns retryable operation errors for transient provider responses" do
+ scope = Fizz.WorkflowsFixtures.project_scope_fixture()
+ connection = insert_oauth_connection!(scope.user.id, scope.organization_id, "google_oauth")
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ pipes_token_response("sheet-token")
+ ])
+
+ Req.Test.expect(__MODULE__, fn conn ->
+ conn
+ |> Plug.Conn.put_status(503)
+ |> Req.Test.json(%{"error" => %{"message" => "temporarily unavailable"}})
+ end)
+
+ assert {:error, %StepError{} = error} =
+ Client.get_sheet_names(
+ client_params(scope, connection),
+ execution_context(scope)
+ )
+
+ assert error.code == :provider_unavailable
+ assert error.category == :transient
+ assert error.status == 503
+ assert error.retryable?
+ end
+
+ test "returns retryable operation errors for transport failures" do
+ scope = Fizz.WorkflowsFixtures.project_scope_fixture()
+ connection = insert_oauth_connection!(scope.user.id, scope.organization_id, "google_oauth")
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ pipes_token_response("sheet-token")
+ ])
+
+ Req.Test.expect(__MODULE__, fn conn ->
+ Req.Test.transport_error(conn, :timeout)
+ end)
+
+ assert {:error, %StepError{} = error} =
+ Client.get_sheet_names(
+ client_params(scope, connection),
+ execution_context(scope)
+ )
+
+ assert error.code == :network_error
+ assert error.category == :network
+ assert error.retryable?
+ end
+ end
+
+ defp insert_oauth_connection!(user_id, organization_id, provider) do
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ user_id: user_id,
+ workos_organization_id: organization_id,
+ provider: provider,
+ status: :active
+ })
+ |> Repo.insert!()
+ end
+
+ defp client_params(scope, connection) do
+ %{
+ "spreadsheet_id" => "sheet_123",
+ "credential_ref" => %{
+ "id" => connection.id,
+ "provider" => "google_oauth",
+ "auth_type" => "oauth",
+ "owner_user_id" => scope.user.id
+ }
+ }
+ end
+
+ defp execution_context(scope), do: %ExecutionContext{scope: scope, project_id: scope.project.id}
+
+ defp membership_response(workos_user_id, organization_id) do
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "data" => [
+ %{
+ "id" => "om_#{organization_id}",
+ "status" => "active",
+ "user_id" => workos_user_id,
+ "organization_id" => organization_id,
+ "role" => %{"slug" => "owner"}
+ }
+ ]
+ }
+ }}
+ end
+
+ defp pipes_token_response(token) do
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "active" => true,
+ "access_token" => %{"access_token" => token}
+ }
+ }}
+ end
+
+ defp put_workos_responses(responses), do: WorkOSHTTPMock.put_responses(responses)
+
+ defp restore_env(app, key, nil), do: Application.delete_env(app, key)
+ defp restore_env(app, key, value), do: Application.put_env(app, key, value)
+end
diff --git a/test/fizz/integrations/google/sheets/columns_resolver_test.exs b/test/fizz/integrations/google/sheets/columns_resolver_test.exs
new file mode 100644
index 0000000..928429c
--- /dev/null
+++ b/test/fizz/integrations/google/sheets/columns_resolver_test.exs
@@ -0,0 +1,293 @@
+defmodule Fizz.Integrations.Library.Google.Sheets.ColumnsResolverTest do
+ use Fizz.DataCase, async: false
+
+ alias Fizz.Accounts.OauthConnection
+ alias Fizz.Accounts.Scope
+ alias Fizz.Integrations.Library.Google.Sheets.ColumnsResolver
+ alias Fizz.Repo
+ alias Fizz.WorkOSHTTPMock
+
+ import Fizz.AccountsFixtures
+
+ setup context do
+ previous_google_sheets_req_options =
+ Application.get_env(:fizz, :google_sheets_req_options)
+
+ previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
+ previous_workos_http_backoff_ms = Application.get_env(:fizz, :workos_http_backoff_ms)
+ previous_workos_client = Application.get_env(:workos, WorkOS.Client)
+ store_pid = start_supervised!({Agent, fn -> [] end})
+
+ Req.Test.set_req_test_from_context(context)
+ Req.Test.verify_on_exit!(context)
+
+ Application.put_env(:fizz, :google_sheets_req_options, plug: {Req.Test, __MODULE__})
+ :ok = WorkOSHTTPMock.configure(self(), store_pid)
+ Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPMock)
+ Application.put_env(:fizz, :workos_http_backoff_ms, 0)
+
+ Application.put_env(:workos, WorkOS.Client,
+ api_key: "sk_test_123",
+ client_id: "client_test_123",
+ client: Fizz.Accounts.WorkOS.ReqClient
+ )
+
+ on_exit(fn ->
+ restore_env(:fizz, :google_sheets_req_options, previous_google_sheets_req_options)
+ restore_env(:fizz, :workos_http_client_module, previous_http_client)
+ restore_env(:fizz, :workos_http_backoff_ms, previous_workos_http_backoff_ms)
+ restore_env(:workos, WorkOS.Client, previous_workos_client)
+ WorkOSHTTPMock.reset()
+ end)
+
+ :ok
+ end
+
+ describe "resolve/1" do
+ test "returns empty options when spreadsheet_id is missing" do
+ user = user_fixture()
+ scope = Scope.for_user(user) |> Scope.with_organization_id("org_columns_no_sheet")
+
+ assert {:ok, []} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{"sheet_name" => "Sheet1"},
+ context: %{current_scope: scope, project_id: "proj_1"}
+ })
+ end
+
+ test "returns empty options when sheet_name is missing" do
+ user = user_fixture()
+ scope = Scope.for_user(user) |> Scope.with_organization_id("org_columns_no_sheet_name")
+
+ assert {:ok, []} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{"spreadsheet_id" => "sheet_123"},
+ context: %{current_scope: scope, project_id: "proj_1"}
+ })
+ end
+
+ test "returns empty table options when spreadsheet_id is missing in tables mode" do
+ user = user_fixture()
+ scope = Scope.for_user(user) |> Scope.with_organization_id("org_tables_no_sheet")
+
+ assert {:ok, []} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{"mode" => "tables"},
+ context: %{current_scope: scope, project_id: "proj_1"}
+ })
+ end
+
+ test "returns empty options when current_scope is missing" do
+ assert {:ok, []} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{"spreadsheet_id" => "sheet_123", "sheet_name" => "Sheet1"},
+ context: %{project_id: "proj_1"}
+ })
+ end
+
+ test "returns empty options when project_id is missing" do
+ user = user_fixture()
+ scope = Scope.for_user(user) |> Scope.with_organization_id("org_columns_no_project")
+
+ assert {:ok, []} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{"spreadsheet_id" => "sheet_123", "sheet_name" => "Sheet1"},
+ context: %{current_scope: scope}
+ })
+ end
+
+ test "surfaces :no_google_credential when none is connected for the scope" do
+ user = user_fixture()
+
+ scope =
+ Scope.for_user(user)
+ |> Scope.with_organization_id("org_columns_no_credential")
+
+ assert {:error, :no_google_credential} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{
+ "spreadsheet_id" => "sheet_123",
+ "sheet_name" => "Sheet1"
+ },
+ context: %{current_scope: scope, project_id: "proj_no_creds"}
+ })
+ end
+
+ test "surfaces :no_google_credential for table lookup when none is connected for the scope" do
+ user = user_fixture()
+
+ scope =
+ Scope.for_user(user)
+ |> Scope.with_organization_id("org_tables_no_credential")
+
+ assert {:error, :no_google_credential} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{
+ "mode" => "tables",
+ "spreadsheet_id" => "sheet_123"
+ },
+ context: %{current_scope: scope, project_id: "proj_no_creds"}
+ })
+ end
+
+ test "returns table options with generic parent and schema aliases" do
+ scope = Fizz.WorkflowsFixtures.project_scope_fixture()
+ connection = insert_oauth_connection!(scope.user.id, scope.organization_id, "google_oauth")
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ pipes_token_response("sheet-token")
+ ])
+
+ Req.Test.expect(__MODULE__, fn conn ->
+ conn = Plug.Conn.fetch_query_params(conn)
+
+ assert conn.method == "GET"
+ assert conn.request_path == "/v4/spreadsheets/sheet_123"
+ assert conn.query_params["fields"] =~ "sheets.tables"
+
+ Req.Test.json(conn, %{
+ "sheets" => [
+ %{
+ "properties" => %{"sheetId" => 0, "title" => "Orders"},
+ "tables" => [
+ %{
+ "tableId" => "tbl_orders",
+ "name" => "Orders Table",
+ "range" => %{
+ "sheetId" => 0,
+ "startRowIndex" => 0,
+ "endRowIndex" => 3,
+ "startColumnIndex" => 0,
+ "endColumnIndex" => 2
+ },
+ "columnProperties" => [
+ %{"columnIndex" => 0, "columnName" => "Order ID"},
+ %{"columnIndex" => 1, "columnName" => "Amount"}
+ ]
+ }
+ ]
+ }
+ ]
+ })
+ end)
+
+ credential_ref = %{
+ "id" => connection.id,
+ "provider" => "google_oauth",
+ "auth_type" => "oauth",
+ "owner_user_id" => scope.user.id
+ }
+
+ assert {:ok, [table]} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{
+ "mode" => "tables",
+ "spreadsheet_id" => "sheet_123",
+ "credential_ref" => credential_ref
+ },
+ context: %{current_scope: scope, project_id: scope.project.id}
+ })
+
+ assert table["id"] == "tbl_orders"
+ assert table["label"] == "Orders Table"
+ assert table["parent_id"] == "Orders"
+ assert table["parent_label"] == "Orders"
+
+ assert Enum.map(table["schema"]["columns"], &Map.take(&1, ["id", "label"])) == [
+ %{"id" => "Order ID", "label" => "Order ID"},
+ %{"id" => "Amount", "label" => "Amount"}
+ ]
+ end
+
+ test "does not use another user's Google credential for preview fallback" do
+ scope = Fizz.WorkflowsFixtures.project_scope_fixture()
+ other_user = user_fixture()
+
+ insert_oauth_connection!(other_user.id, scope.organization_id, "google_oauth")
+
+ put_workos_responses([
+ membership_response(scope.user.workos_user_id, scope.organization_id),
+ membership_response(scope.user.workos_user_id, scope.organization_id)
+ ])
+
+ assert {:error, :no_google_credential} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{
+ "spreadsheet_id" => "sheet_123",
+ "sheet_name" => "Sheet1"
+ },
+ context: %{current_scope: scope, project_id: scope.project.id}
+ })
+ end
+
+ test "treats blank string inputs as missing" do
+ user = user_fixture()
+
+ scope =
+ Scope.for_user(user) |> Scope.with_organization_id("org_columns_blank")
+
+ assert {:ok, []} =
+ ColumnsResolver.resolve(%{
+ q: "",
+ params: %{"spreadsheet_id" => " ", "sheet_name" => "Sheet1"},
+ context: %{current_scope: scope, project_id: "proj_blank"}
+ })
+ end
+ end
+
+ defp insert_oauth_connection!(user_id, organization_id, provider) do
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ user_id: user_id,
+ workos_organization_id: organization_id,
+ provider: provider,
+ status: :active
+ })
+ |> Repo.insert!()
+ end
+
+ defp membership_response(workos_user_id, organization_id) do
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "data" => [
+ %{
+ "id" => "om_#{organization_id}",
+ "status" => "active",
+ "user_id" => workos_user_id,
+ "organization_id" => organization_id,
+ "role" => %{"slug" => "owner"}
+ }
+ ]
+ }
+ }}
+ end
+
+ defp put_workos_responses(responses), do: WorkOSHTTPMock.put_responses(responses)
+
+ defp pipes_token_response(token) do
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "active" => true,
+ "access_token" => %{"access_token" => token}
+ }
+ }}
+ end
+
+ defp restore_env(app, key, nil), do: Application.delete_env(app, key)
+ defp restore_env(app, key, value), do: Application.put_env(app, key, value)
+end
diff --git a/test/fizz/integrations/google/sheets/rows_test.exs b/test/fizz/integrations/google/sheets/rows_test.exs
new file mode 100644
index 0000000..4e9201a
--- /dev/null
+++ b/test/fizz/integrations/google/sheets/rows_test.exs
@@ -0,0 +1,95 @@
+defmodule Fizz.Integrations.Library.Google.Sheets.RowsTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Library.Google.Sheets.Rows
+
+ test "snapshots normalize rows with header values and primary key identity" do
+ now = DateTime.utc_now()
+
+ snapshots =
+ Rows.snapshots(
+ [
+ ["id", "name"],
+ ["1", "Ada"],
+ ["2", "Grace"]
+ ],
+ %{"primary_key_column" => "id"},
+ now
+ )
+
+ assert [
+ %{
+ row_key: "pk:1",
+ row_number: 2,
+ values: %{"id" => "1", "name" => "Ada"},
+ raw_values: ["1", "Ada"]
+ },
+ %{
+ row_key: "pk:2",
+ row_number: 3,
+ values: %{"id" => "2", "name" => "Grace"},
+ raw_values: ["2", "Grace"]
+ }
+ ] = snapshots
+ end
+
+ test "change_events emits added and updated rows for row_added_or_updated mode" do
+ now = DateTime.utc_now()
+
+ [existing_snapshot] =
+ Rows.snapshots(
+ [
+ ["id", "name"],
+ ["1", "Ada"]
+ ],
+ %{"primary_key_column" => "id"},
+ now
+ )
+
+ snapshots =
+ Rows.snapshots(
+ [
+ ["id", "name"],
+ ["1", "Ada Lovelace"],
+ ["2", "Grace Hopper"]
+ ],
+ %{"primary_key_column" => "id"},
+ now,
+ %{existing_snapshot.row_key => existing_snapshot}
+ )
+
+ events =
+ Rows.change_events(
+ snapshots,
+ %{existing_snapshot.row_key => existing_snapshot},
+ "row_added_or_updated",
+ %{"spreadsheet_id" => "spreadsheet_1", "sheet_name" => "Sheet1", "range" => "A:B"}
+ )
+
+ assert Enum.map(events, & &1["change_type"]) == ["updated", "added"]
+ assert Enum.map(events, & &1["row_key"]) == ["pk:1", "pk:2"]
+ assert hd(events)["previous_values"] == %{"id" => "1", "name" => "Ada"}
+ end
+
+ test "change_events suppresses added rows in row_updated mode" do
+ now = DateTime.utc_now()
+
+ snapshots =
+ Rows.snapshots(
+ [
+ ["id", "name"],
+ ["1", "Ada"]
+ ],
+ %{"primary_key_column" => "id"},
+ now
+ )
+
+ assert [] =
+ Rows.change_events(
+ snapshots,
+ %{},
+ "row_updated",
+ %{"spreadsheet_id" => "spreadsheet_1", "sheet_name" => "Sheet1"}
+ )
+ end
+end
diff --git a/test/fizz/integrations/manifest_task_test.exs b/test/fizz/integrations/manifest_task_test.exs
new file mode 100644
index 0000000..c64a8bb
--- /dev/null
+++ b/test/fizz/integrations/manifest_task_test.exs
@@ -0,0 +1,31 @@
+defmodule Fizz.Integrations.Catalog.ManifestTaskTest do
+ use ExUnit.Case, async: false
+
+ import ExUnit.CaptureIO
+
+ test "mix task generates a manifest module file" do
+ output_path =
+ Path.join(
+ System.tmp_dir!(),
+ "fizz-integration-manifest-#{System.unique_integer([:positive])}.ex"
+ )
+
+ on_exit(fn -> File.rm(output_path) end)
+
+ assert capture_io(fn ->
+ Mix.Tasks.Fizz.Gen.IntegrationManifest.run(["--output", output_path])
+ end) =~ "Generated"
+
+ assert {:ok, content} = File.read(output_path)
+ assert content =~ "defmodule Fizz.Integrations.Catalog.Manifest"
+ assert content =~ "Fizz.Integrations.Auth.Providers.GoogleOAuth"
+ assert content =~ "Fizz.Integrations.Library.Fizz"
+ assert content =~ "Enum.flat_map(& &1.step_modules())"
+ end
+
+ test "mix task check mode verifies the generated manifest is current" do
+ assert capture_io(fn ->
+ Mix.Tasks.Fizz.Gen.IntegrationManifest.run(["--check"])
+ end) =~ "is up to date"
+ end
+end
diff --git a/test/fizz/integrations/step_type_subnodes_test.exs b/test/fizz/integrations/step_type_subnodes_test.exs
new file mode 100644
index 0000000..db23f03
--- /dev/null
+++ b/test/fizz/integrations/step_type_subnodes_test.exs
@@ -0,0 +1,42 @@
+defmodule Fizz.Integrations.Steps.ConnectionHandleMetadataTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Steps.ConnectionHandles
+ alias Fizz.Integrations.Steps.Registry, as: Registry
+
+ test "ai_agent exposes declared dependency inputs in input schema metadata" do
+ assert {:ok, type} = Registry.get("ai_agent")
+ handles = ConnectionHandles.input_handles(type)
+
+ assert %{kind: :flow} = Enum.find(handles, &(&1.id == "main"))
+
+ assert %{kind: :dependency, required?: true, accepts: %{provides: ["ai.chat_model"]}} =
+ Enum.find(handles, &(&1.id == "model"))
+
+ assert %{kind: :dependency, accepts: %{provides: ["ai.schema"]}} =
+ Enum.find(handles, &(&1.id == "structured_schema"))
+
+ assert %{kind: :dependency, cardinality: :many, accepts: %{provides: ["ai.tool"]}} =
+ Enum.find(handles, &(&1.id == "tools"))
+ end
+
+ test "provider model nodes declare chat model output capabilities" do
+ assert {:ok, openai_type} = Registry.get("openai_model")
+ assert {:ok, openai_outputs} = ConnectionHandles.output_handles(openai_type)
+ assert %{provides: ["ai.chat_model"]} = Enum.find(openai_outputs, &(&1.id == "main"))
+
+ assert {:ok, anthropic_type} = Registry.get("anthropic_model")
+ assert {:ok, anthropic_outputs} = ConnectionHandles.output_handles(anthropic_type)
+ assert %{provides: ["ai.chat_model"]} = Enum.find(anthropic_outputs, &(&1.id == "main"))
+ end
+
+ test "schema and tool nodes declare semantic dependency capabilities" do
+ assert {:ok, schema_type} = Registry.get("ai_structure_schema")
+ assert {:ok, schema_outputs} = ConnectionHandles.output_handles(schema_type)
+ assert %{provides: ["ai.schema"]} = Enum.find(schema_outputs, &(&1.id == "main"))
+
+ assert {:ok, tool_type} = Registry.get("ai_tool_http")
+ assert {:ok, tool_outputs} = ConnectionHandles.output_handles(tool_type)
+ assert %{provides: ["ai.tool"]} = Enum.find(tool_outputs, &(&1.id == "main"))
+ end
+end
diff --git a/test/fizz/integrations/step_type_test.exs b/test/fizz/integrations/step_type_test.exs
new file mode 100644
index 0000000..c38c11a
--- /dev/null
+++ b/test/fizz/integrations/step_type_test.exs
@@ -0,0 +1,19 @@
+defmodule Fizz.Integrations.Steps.TypeTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Integrations.Steps.Type, as: StepType
+
+ describe "default_step_name/1" do
+ test "strips brand for em dash separated names" do
+ assert StepType.default_step_name("Google Sheets — New Row") == "New Row"
+ end
+
+ test "strips brand for double-hyphen separated names" do
+ assert StepType.default_step_name("Slack -- Send Message") == "Send Message"
+ end
+
+ test "keeps names without a brand separator unchanged" do
+ assert StepType.default_step_name("HTTP Request") == "HTTP Request"
+ end
+ end
+end
diff --git a/test/fizz/integrations_test.exs b/test/fizz/integrations_test.exs
index fdfaead..b92ee33 100644
--- a/test/fizz/integrations_test.exs
+++ b/test/fizz/integrations_test.exs
@@ -5,8 +5,8 @@ defmodule Fizz.IntegrationsTest do
alias Fizz.Accounts.ExternalAuth, as: AccountExternalAuth
alias Fizz.Accounts.ApiCredential
alias Fizz.Integrations
- alias Fizz.Integrations.ProviderCatalog
- alias Fizz.Integrations.Providers.OpenAIApiKey
+ alias Fizz.Integrations.Auth.ProviderCatalog
+ alias Fizz.Integrations.Library.OpenAI.Client, as: OpenAIClient
alias Fizz.WorkOSHTTPMock
import Fizz.AccountsFixtures
@@ -44,7 +44,7 @@ defmodule Fizz.IntegrationsTest do
owner_scope =
organization_scope_fixture(user: user, organization_id: org_id, organization_role: :owner)
- _workspace = workspace_fixture(owner_scope, %{name: "Workspace A"})
+ _project = project_fixture(owner_scope, %{name: "Project A"})
scope = Scope.for_user(user)
put_workos_responses([
@@ -102,14 +102,97 @@ defmodule Fizz.IntegrationsTest do
refute Repo.get(ApiCredential, credential.id)
end
+ test "credential lifecycle accepts legacy direct value secret aliases" do
+ user = user_fixture()
+ org_id = "org_value_alias"
+
+ _owner_scope =
+ organization_scope_fixture(user: user, organization_id: org_id, organization_role: :owner)
+
+ scope = Scope.for_user(user)
+
+ put_workos_responses([
+ membership_response(user.workos_user_id, org_id),
+ {:ok,
+ %Req.Response{
+ status: 201,
+ body: %{
+ "id" => "vault_obj_value_alias",
+ "metadata" => %{"version_id" => "version_1"}
+ }
+ }},
+ membership_response(user.workos_user_id, org_id),
+ {:ok, %Req.Response{status: 200, body: %{"metadata" => %{"version_id" => "version_2"}}}}
+ ])
+
+ assert {:ok, credential} =
+ AccountExternalAuth.create_credential(scope, org_id, %{
+ "provider" => "openai_api_key",
+ "provider_label" => "OpenAI Value Alias",
+ "value" => "sk-value-create"
+ })
+
+ assert_receive {:workos_http_request, membership_request}
+ assert membership_request[:url] == "/user_management/organization_memberships"
+
+ assert_receive {:workos_http_request, create_request}
+ assert create_request[:url] == "/vault/v1/kv"
+ assert create_request[:json][:value] == "sk-value-create"
+
+ assert {:ok, rotated} =
+ AccountExternalAuth.rotate_credential(scope, org_id, credential.id, %{
+ value: "sk-value-rotate"
+ })
+
+ assert rotated.vault_version == "version_2"
+
+ assert_receive {:workos_http_request, rotate_membership_request}
+ assert rotate_membership_request[:url] == "/user_management/organization_memberships"
+
+ assert_receive {:workos_http_request, rotate_request}
+ assert rotate_request[:url] == "/vault/v1/kv/vault_obj_value_alias"
+ assert rotate_request[:json][:value] == "sk-value-rotate"
+ end
+
test "provider catalog resolves openai API-key module" do
- assert {:ok, Fizz.Integrations.Providers.OpenAIApiKey} =
+ assert {:ok, Fizz.Integrations.Auth.Providers.OpenAIApiKey} =
ProviderCatalog.api_key_provider_module("openai_api_key")
assert {:ok, ["api.openai.com"]} =
Integrations.network_domains_for_provider("openai_api_key")
end
+ test "provider catalog exposes definition-only API-key providers without runtime modules" do
+ assert {:ok, %{label: "Anthropic", type: :api_key}} =
+ ProviderCatalog.provider("anthropic_api_key")
+
+ assert {:error, :provider_not_implemented} =
+ ProviderCatalog.api_key_provider_module("anthropic_api_key")
+
+ assert {:error, :provider_not_implemented} =
+ ProviderCatalog.api_key_provider_module("custom_api_key")
+ end
+
+ test "provider catalog resolves typed OAuth providers only" do
+ assert {:ok, Fizz.Integrations.Auth.Providers.SlackOAuth} =
+ ProviderCatalog.oauth_provider_module("slack_oauth")
+
+ assert {:ok, Fizz.Integrations.Auth.Providers.GoogleOAuth} =
+ ProviderCatalog.oauth_provider_module("google_oauth")
+
+ assert {:ok, Fizz.Integrations.Auth.Providers.MicrosoftOAuth} =
+ ProviderCatalog.oauth_provider_module("microsoft_oauth")
+
+ assert {:ok, Fizz.Integrations.Auth.Providers.NotionOAuth} =
+ ProviderCatalog.oauth_provider_module("notion_oauth")
+
+ assert {:ok, Fizz.Integrations.Auth.Providers.BoxOAuth} =
+ ProviderCatalog.oauth_provider_module("box_oauth")
+
+ assert {:error, :unknown_provider} = ProviderCatalog.oauth_provider_module("slack")
+ refute ProviderCatalog.provider_supported?("openai")
+ end
+
test "openai API-key provider fetches vault-backed user credential" do
user = user_fixture()
org_id = "org_openai_provider"
@@ -220,23 +303,23 @@ defmodule Fizz.IntegrationsTest do
}
assert {:ok, %{operation: :generate_text}} =
- OpenAIApiKey.generate_text(
+ OpenAIClient.generate_text(
scope,
org_id,
- "openai:gpt-4o-mini",
+ "openai:gpt-5.5",
"Say hello",
temperature: 0.2,
credential_ref: credential_ref
)
- assert_receive {:req_llm_called, :generate_text, "openai:gpt-4o-mini", "Say hello",
+ assert_receive {:req_llm_called, :generate_text, "openai:gpt-5.5", "Say hello",
generate_text_opts}
assert generate_text_opts[:api_key] == "sk-openai-req-llm"
assert generate_text_opts[:temperature] == 0.2
assert {:ok, %{operation: :generate_text}} =
- OpenAIApiKey.generate_text(
+ OpenAIClient.generate_text(
scope,
org_id,
"gpt-5",
@@ -250,32 +333,32 @@ defmodule Fizz.IntegrationsTest do
assert bare_generate_text_opts[:api_key] == "sk-openai-req-llm"
assert {:ok, %{operation: :stream_text}} =
- OpenAIApiKey.stream_text(scope, org_id, "openai:gpt-4o-mini", "Stream hello",
+ OpenAIClient.stream_text(scope, org_id, "openai:gpt-5.5", "Stream hello",
credential_ref: credential_ref
)
- assert_receive {:req_llm_called, :stream_text, "openai:gpt-4o-mini", "Stream hello",
+ assert_receive {:req_llm_called, :stream_text, "openai:gpt-5.5", "Stream hello",
stream_text_opts}
assert stream_text_opts[:api_key] == "sk-openai-req-llm"
assert {:ok, %{operation: :generate_object}} =
- OpenAIApiKey.generate_object(
+ OpenAIClient.generate_object(
scope,
org_id,
- "openai:gpt-4o-mini",
+ "openai:gpt-5.5",
"Generate a person",
[name: [type: :string, required: true]],
credential_ref: credential_ref
)
- assert_receive {:req_llm_called, :generate_object, "openai:gpt-4o-mini", "Generate a person",
+ assert_receive {:req_llm_called, :generate_object, "openai:gpt-5.5", "Generate a person",
[name: [type: :string, required: true]], generate_object_opts}
assert generate_object_opts[:api_key] == "sk-openai-req-llm"
assert {:ok, %{operation: :generate_image}} =
- OpenAIApiKey.generate_image(
+ OpenAIClient.generate_image(
scope,
org_id,
"openai:gpt-image-1",
@@ -294,22 +377,116 @@ defmodule Fizz.IntegrationsTest do
scope = Scope.for_user(user)
assert {:error, :credential_ref_required} =
- OpenAIApiKey.generate_text(
+ OpenAIClient.generate_text(
scope,
"org_missing_ref",
- "openai:gpt-4o-mini",
+ "openai:gpt-5.5",
"Say hello"
)
end
- test "fetch_token_for_sprite/3 resolves api_key auth directly from credentials" do
+ test "resolve_org_auth_for_execution/4 resolves explicit API credential refs" do
+ user = user_fixture()
+ org_id = "org_openai_org_auth"
+ scope = Scope.for_user(user)
+
+ put_workos_responses([
+ membership_response(user.workos_user_id, org_id),
+ {:ok,
+ %Req.Response{
+ status: 201,
+ body: %{
+ "id" => "vault_obj_openai_org_auth",
+ "metadata" => %{"version_id" => "version_1"}
+ }
+ }},
+ membership_response(user.workos_user_id, org_id),
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{"id" => "vault_obj_openai_org_auth", "value" => "sk-org-auth"}
+ }}
+ ])
+
+ assert {:ok, credential} =
+ AccountExternalAuth.create_credential(scope, org_id, %{
+ provider: "openai_api_key",
+ provider_label: "OpenAI Org Auth",
+ credentials: %{"secret" => "sk-org-auth"}
+ })
+
+ credential_ref = %{
+ id: credential.id,
+ provider: "openai_api_key",
+ auth_type: "api_key",
+ owner_user_id: user.id
+ }
+
+ assert {:ok, auth} =
+ Integrations.resolve_org_auth_for_execution(
+ scope,
+ org_id,
+ "openai_api_key",
+ credential_ref
+ )
+
+ assert auth.auth_method == :api_key
+ assert auth.api_key == "sk-org-auth"
+ assert auth.api_credential_id == credential.id
+ end
+
+ test "resolve_org_auth_for_execution/4 validates credential refs before organization lookup" do
+ user = user_fixture()
+ other_user = user_fixture()
+ scope = Scope.for_user(user)
+
+ assert {:error, :credential_ref_required} =
+ Integrations.resolve_org_auth_for_execution(
+ scope,
+ "org_no_lookup",
+ "openai_api_key",
+ nil
+ )
+
+ owner_mismatch_ref = %{
+ id: Ecto.UUID.generate(),
+ provider: "openai_api_key",
+ auth_type: "api_key",
+ owner_user_id: other_user.id
+ }
+
+ assert {:error, :credential_ref_owner_mismatch} =
+ Integrations.resolve_org_auth_for_execution(
+ scope,
+ "org_no_lookup",
+ "openai_api_key",
+ owner_mismatch_ref
+ )
+
+ provider_mismatch_ref = %{
+ id: Ecto.UUID.generate(),
+ provider: "anthropic_api_key",
+ auth_type: "api_key",
+ owner_user_id: user.id
+ }
+
+ assert {:error, :credential_ref_provider_mismatch} =
+ Integrations.resolve_org_auth_for_execution(
+ scope,
+ "org_no_lookup",
+ "openai_api_key",
+ provider_mismatch_ref
+ )
+ end
+
+ test "fetch_token_for_execution/3 resolves api_key auth directly from credentials" do
user = user_fixture()
org_id = "org_234"
owner_scope =
organization_scope_fixture(user: user, organization_id: org_id, organization_role: :owner)
- workspace = workspace_fixture(owner_scope, %{name: "Workspace B"})
+ project = project_fixture(owner_scope, %{name: "Project B"})
scope = Scope.for_user(user)
put_workos_responses([
@@ -334,21 +511,21 @@ defmodule Fizz.IntegrationsTest do
})
assert {:ok, token_result} =
- Integrations.fetch_token_for_sprite(scope, workspace.id, "openai_api_key")
+ Integrations.fetch_token_for_execution(scope, project.id, "openai_api_key")
assert token_result.access_token == "sk-api-key"
assert credential.provider == "openai_api_key"
end
- test "credential usage is organization-scoped across workspaces" do
+ test "credential usage is organization-scoped across projects" do
user = user_fixture()
org_id = "org_345"
owner_scope =
organization_scope_fixture(user: user, organization_id: org_id, organization_role: :owner)
- _workspace_a = workspace_fixture(owner_scope, %{name: "Workspace C"})
- _workspace_b = workspace_fixture(owner_scope, %{name: "Workspace D"})
+ _project_a = project_fixture(owner_scope, %{name: "Project C"})
+ _project_b = project_fixture(owner_scope, %{name: "Project D"})
scope = Scope.for_user(user)
put_workos_responses([
@@ -512,7 +689,7 @@ defmodule Fizz.IntegrationsTest do
assert first_credential.provider_label == second_credential.provider_label
end
- test "credential usage is owner-scoped within workspace" do
+ test "credential usage is owner-scoped within project" do
owner_user = user_fixture()
member_user = user_fixture()
org_id = "org_456"
@@ -524,10 +701,10 @@ defmodule Fizz.IntegrationsTest do
organization_role: :owner
)
- workspace = workspace_fixture(owner_scope, %{name: "Workspace E"})
+ project = project_fixture(owner_scope, %{name: "Project E"})
{:ok, _member_membership} =
- Fizz.Accounts.add_workspace_member(owner_scope, workspace.id, member_user, %{role: :member})
+ Fizz.Accounts.add_project_member(owner_scope, project.id, member_user, %{role: :member})
owner_runtime_scope = Scope.for_user(owner_user)
member_runtime_scope = Scope.for_user(member_user)
diff --git a/test/fizz/runtime/runic_adapter_test.exs b/test/fizz/runtime/runic_adapter_test.exs
deleted file mode 100644
index 0bfeff8..0000000
--- a/test/fizz/runtime/runic_adapter_test.exs
+++ /dev/null
@@ -1,233 +0,0 @@
-defmodule Fizz.Runtime.RunicAdapterTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Accounts.Scope
- alias Runic.Component
- alias Runic.Workflow
- alias Fizz.Runtime.Hooks.Observability
- alias Fizz.Runtime.RunicAdapter
- alias Fizz.Workflows.Embeds.Connection
- alias Fizz.Workflows.Embeds.Step
-
- describe "splitter fan-out execution" do
- test "runs downstream steps per item and aggregates all items" do
- source =
- workflow_source(
- [
- step("splitter", "splitter", %{"field" => "items"}),
- step("debug_item", "debug", %{"label" => "Item", "level" => "info"}),
- step("aggregate", "aggregator", %{"operation" => "collect"})
- ],
- [
- connection("c1", "splitter", "debug_item"),
- connection("c2", "debug_item", "aggregate")
- ]
- )
-
- outputs = run_workflow(source, %{"items" => [1, 2, 3]})
-
- assert Enum.sort(outputs["aggregate"]) == [1, 2, 3]
- assert outputs["debug_item"] in [1, 2, 3]
- end
- end
-
- describe "aggregator mode selection" do
- test "uses reduce only for aggregators in the active fan-out path" do
- source =
- workflow_source(
- [
- step("splitter", "splitter", %{"field" => "items"}),
- step("debug_item", "debug", %{"label" => "Item", "level" => "info"}),
- step("aggregate_in_fanout", "aggregator", %{"operation" => "collect"}),
- step("debug_after_fanout", "debug", %{"label" => "After", "level" => "info"}),
- step("aggregate_after_fanout", "aggregator", %{"operation" => "count"})
- ],
- [
- connection("c1", "splitter", "debug_item"),
- connection("c2", "debug_item", "aggregate_in_fanout"),
- connection("c3", "aggregate_in_fanout", "debug_after_fanout"),
- connection("c4", "debug_after_fanout", "aggregate_after_fanout")
- ]
- )
-
- workflow = RunicAdapter.to_runic_workflow(source, execution_id: "exec_test")
-
- assert match?(
- %Runic.Workflow.Reduce{},
- Workflow.get_component!(workflow, "aggregate_in_fanout")
- )
-
- refute match?(
- %Runic.Workflow.Reduce{},
- Workflow.get_component!(workflow, "aggregate_after_fanout")
- )
-
- outputs = run_workflow(source, %{"items" => [1, 2, 3]})
- assert outputs["aggregate_after_fanout"] == 3
- end
- end
-
- describe "fan-out aware join mapping" do
- test "maps each parent to its nearest splitter in join fan_out_sources" do
- source =
- workflow_source(
- [
- step("splitter_a", "splitter", %{"field" => "items_a"}),
- step("splitter_b", "splitter", %{"field" => "items_b"}),
- step("debug_a", "debug", %{"label" => "A", "level" => "info"}),
- step("debug_b", "debug", %{"label" => "B", "level" => "info"}),
- step("joined_debug", "debug", %{"label" => "Joined", "level" => "info"})
- ],
- [
- connection("c1", "splitter_a", "debug_a"),
- connection("c2", "splitter_b", "debug_b"),
- connection("c3", "debug_a", "joined_debug"),
- connection("c4", "debug_b", "joined_debug")
- ]
- )
-
- workflow = RunicAdapter.to_runic_workflow(source, execution_id: "exec_test")
-
- [join] =
- workflow.graph
- |> Graph.vertices()
- |> Enum.filter(&match?(%Runic.Workflow.Join{}, &1))
-
- splitter_a = Workflow.get_component!(workflow, "splitter_a")
- splitter_b = Workflow.get_component!(workflow, "splitter_b")
- debug_a = Workflow.get_component!(workflow, "debug_a")
- debug_b = Workflow.get_component!(workflow, "debug_b")
-
- assert join.fan_out_sources[Component.hash(debug_a)] == Component.hash(splitter_a)
- assert join.fan_out_sources[Component.hash(debug_b)] == Component.hash(splitter_b)
- end
- end
-
- describe "subnode slot wiring" do
- test "builds and runs ai_agent with prompt/model slot connections" do
- source =
- workflow_source(
- [
- step("ai_prompt_template", "ai_prompt_template", %{
- "system_prompt" => "You are a helpful assistant.",
- "user_prompt" => "Hello from test",
- "context" => %{}
- }),
- step("openai_model", "openai_model", %{
- "model" => "gpt-4.1-mini",
- "temperature" => 0.2,
- "max_tokens" => 400,
- "credential_ref" => %{
- "id" => "cred_openai",
- "provider" => "openai_api_key",
- "auth_type" => "api_key",
- "owner_user_id" => "user_123"
- }
- }),
- step("ai_agent", "ai_agent", %{"mode" => "assemble_only"}),
- step("debug_after", "debug", %{"label" => "After", "level" => "info"})
- ],
- [
- slot_connection("c1", "ai_prompt_template", "ai_agent", "prompt"),
- slot_connection("c2", "openai_model", "ai_agent", "model"),
- connection("c3", "ai_agent", "debug_after")
- ]
- )
-
- outputs = run_workflow(source, %{"name" => "John"})
-
- assert is_map(outputs["ai_agent"])
- assert outputs["ai_agent"]["provider"] == "openai_api_key"
- assert outputs["ai_agent"]["model"] == "gpt-4.1-mini"
- assert is_list(outputs["ai_agent"]["messages"])
- assert outputs["debug_after"] == outputs["ai_agent"]
- end
- end
-
- describe "execution scope propagation" do
- test "passes scope option into step runner opts" do
- source =
- workflow_source(
- [step("debug_step", "debug", %{"label" => "Debug", "level" => "info"})],
- []
- )
-
- scope = %Scope{organization_id: "org_test"}
- workflow = RunicAdapter.to_runic_workflow(source, execution_id: "exec_test", scope: scope)
- component = Workflow.get_component!(workflow, "debug_step")
-
- assert {:env, [_step, opts]} = :erlang.fun_info(component.work, :env)
- assert Keyword.get(opts, :scope) == scope
- end
- end
-
- defp run_workflow(source, input) do
- reset_runtime_process_state()
-
- try do
- source
- |> RunicAdapter.to_runic_workflow(execution_id: "exec_test")
- |> Observability.attach_all_hooks(
- execution_id: "exec_test",
- workflow_id: source.id,
- skip_production_init: true
- )
- |> Workflow.react_until_satisfied(input)
-
- Process.get(:fizz_accumulated_outputs, %{})
- after
- reset_runtime_process_state()
- end
- end
-
- defp reset_runtime_process_state do
- keys = [
- :fizz_accumulated_outputs,
- :fizz_step_outputs,
- :fizz_step_skipped,
- :fizz_fan_out_context,
- :fizz_step_events
- ]
-
- Enum.each(keys, &Process.delete/1)
- end
-
- defp workflow_source(steps, connections) do
- %{
- id: "wf_test",
- steps: steps,
- connections: connections,
- groups: []
- }
- end
-
- defp step(id, type_id, config) do
- %Step{
- id: id,
- type_id: type_id,
- name: id,
- config: config,
- position: %{}
- }
- end
-
- defp connection(id, source_step_id, target_step_id) do
- %Connection{
- id: id,
- source_step_id: source_step_id,
- source_output: "main",
- target_step_id: target_step_id,
- target_input: "main"
- }
- end
-
- defp slot_connection(id, source_step_id, target_step_id, target_input) do
- %Connection{
- id: id,
- source_step_id: source_step_id,
- source_output: "main",
- target_step_id: target_step_id,
- target_input: target_input
- }
- end
-end
diff --git a/test/fizz/runtime/steps/step_runner_test.exs b/test/fizz/runtime/steps/step_runner_test.exs
deleted file mode 100644
index fc491f1..0000000
--- a/test/fizz/runtime/steps/step_runner_test.exs
+++ /dev/null
@@ -1,183 +0,0 @@
-defmodule Fizz.Runtime.Steps.StepRunnerTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Accounts.Scope
- alias Fizz.Runtime.Steps.StepRunner
- alias Fizz.Workflows.Embeds.Step
-
- describe "execute_with_context/3 with subnode slots" do
- test "injects subnode outputs and keeps primary input isolated" do
- step = %Step{
- id: "agent_step",
- type_id: "ai_agent",
- name: "Agent",
- config: %{"mode" => "assemble_only"},
- position: %{}
- }
-
- result =
- StepRunner.execute_with_context(
- step,
- %{"joined" => "raw_input"},
- execution_opts(%{
- "main_input_step" => %{"question" => "What is Elixir?"},
- "model_step" => %{
- "provider" => "openai_api_key",
- "credential_ref" => %{
- "id" => "cred_openai",
- "provider" => "openai_api_key",
- "auth_type" => "api_key",
- "owner_user_id" => "user_123"
- },
- "model" => "gpt-4.1-mini",
- "temperature" => 0.1,
- "max_tokens" => 240
- },
- "prompt_step" => %{
- "messages" => [%{"role" => "user", "content" => "Explain Elixir briefly."}]
- },
- "tool_step_1" => %{"type" => "http", "name" => "docs"}
- })
- )
-
- assert result["_primary"] == %{"question" => "What is Elixir?"}
- assert result["model"] == "gpt-4.1-mini"
- assert result["provider"] == "openai_api_key"
- assert result["tools"] == [%{"type" => "http", "name" => "docs"}]
- assert is_list(result["messages"])
- end
-
- test "sets _primary to nil when no primary input edge exists" do
- step = %Step{
- id: "agent_step",
- type_id: "ai_agent",
- name: "Agent",
- config: %{"mode" => "assemble_only"},
- position: %{}
- }
-
- result =
- StepRunner.execute_with_context(
- step,
- %{"joined" => "slot_only"},
- execution_opts(
- %{
- "model_step" => %{
- "provider" => "openai_api_key",
- "credential_ref" => %{
- "id" => "cred_openai",
- "provider" => "openai_api_key",
- "auth_type" => "api_key",
- "owner_user_id" => "user_123"
- },
- "model" => "gpt-4.1-mini"
- },
- "prompt_step" => %{"messages" => [%{"role" => "user", "content" => "Hi"}]}
- },
- primary_parents: []
- )
- )
-
- assert result["_primary"] == nil
- end
-
- test "propagates scope into ai_agent provider_chat execution context" do
- step = %Step{
- id: "agent_step",
- type_id: "ai_agent",
- name: "Agent",
- config: %{"mode" => "provider_chat"},
- position: %{}
- }
-
- scope = %Scope{organization_id: "org_test"}
-
- assert {:step_error, "agent_step", {:unsupported_provider, "custom_provider"}} =
- catch_throw(
- StepRunner.execute_with_context(
- step,
- %{"joined" => "raw_input"},
- execution_opts(
- %{
- "main_input_step" => %{"question" => "What is Elixir?"},
- "model_step" => %{
- "provider" => "custom_provider",
- "credential_ref" => %{
- "id" => "cred_openai",
- "provider" => "openai_api_key",
- "auth_type" => "api_key",
- "owner_user_id" => "user_123"
- },
- "model" => "custom-model"
- },
- "prompt_step" => %{
- "messages" => [%{"role" => "user", "content" => "Explain Elixir."}]
- }
- },
- scope: scope
- )
- )
- )
- end
- end
-
- describe "execute_with_context/3 primary input resolution" do
- test "uses fact input for one-parent steps without slot bindings" do
- step = %Step{
- id: "debug_step",
- type_id: "debug",
- name: "Debug",
- config: %{"label" => "Debug", "level" => "info"},
- position: %{}
- }
-
- result =
- StepRunner.execute_with_context(
- step,
- %{"item" => 2},
- execution_id: "exec_test",
- workflow_id: "wf_test",
- step_outputs: %{
- "splitter_step" => [%{"item" => 1}, %{"item" => 2}]
- },
- upstream_lookup: %{
- "debug_step" => ["splitter_step"]
- },
- primary_parent_lookup: %{
- "debug_step" => ["splitter_step"]
- },
- slot_bindings: %{
- "debug_step" => %{}
- }
- )
-
- assert result == %{"item" => 2}
- end
- end
-
- defp execution_opts(step_outputs, opts \\ []) do
- primary_parents = Keyword.get(opts, :primary_parents, ["main_input_step"])
- extra_opts = Keyword.drop(opts, [:primary_parents])
-
- base_opts = [
- execution_id: "exec_test",
- workflow_id: "wf_test",
- step_outputs: step_outputs,
- upstream_lookup: %{
- "agent_step" => Map.keys(step_outputs)
- },
- primary_parent_lookup: %{
- "agent_step" => primary_parents
- },
- slot_bindings: %{
- "agent_step" => %{
- "model" => ["model_step"],
- "prompt" => ["prompt_step"],
- "tools" => ["tool_step_1"]
- }
- }
- ]
-
- base_opts ++ extra_opts
- end
-end
diff --git a/test/fizz/steps/resolver_test.exs b/test/fizz/steps/resolver_test.exs
deleted file mode 100644
index 512a7af..0000000
--- a/test/fizz/steps/resolver_test.exs
+++ /dev/null
@@ -1,58 +0,0 @@
-defmodule Fizz.Steps.ResolverTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Steps.Resolver
-
- defmodule FakeResolver do
- @behaviour Resolver
-
- @impl true
- def resolve(%{q: q, params: _params, context: _context}) do
- {:ok, [%{"id" => "1", "label" => "Result for: #{q}"}]}
- end
- end
-
- defmodule FailingResolver do
- @behaviour Resolver
-
- @impl true
- def resolve(_args) do
- {:error, :something_went_wrong}
- end
- end
-
- describe "behaviour contract" do
- test "a resolver returns {:ok, options}" do
- args = %{q: "test", params: %{}, context: %{}}
- assert {:ok, [%{"id" => "1", "label" => "Result for: test"}]} = FakeResolver.resolve(args)
- end
-
- test "a resolver can return {:error, reason}" do
- args = %{q: "", params: %{}, context: %{}}
- assert {:error, :something_went_wrong} = FailingResolver.resolve(args)
- end
- end
-
- describe "schema-based resolver lookup" do
- test "resolver module is stored as an atom in config schema" do
- # Verify the executor schemas reference modules, not strings
- {:ok, openai_type} = Fizz.Steps.Registry.get("openai_model")
-
- resolver =
- get_in(openai_type.config_schema, ["properties", "credential_ref", "ui", "resolver"])
-
- assert is_atom(resolver)
- assert resolver == Fizz.Integrations.CredentialsResolver
- end
-
- test "resolver module implements the behaviour" do
- {:ok, openai_type} = Fizz.Steps.Registry.get("openai_model")
-
- resolver =
- get_in(openai_type.config_schema, ["properties", "credential_ref", "ui", "resolver"])
-
- Code.ensure_loaded!(resolver)
- assert function_exported?(resolver, :resolve, 1)
- end
- end
-end
diff --git a/test/fizz/steps/type_subnodes_test.exs b/test/fizz/steps/type_subnodes_test.exs
deleted file mode 100644
index a10ff65..0000000
--- a/test/fizz/steps/type_subnodes_test.exs
+++ /dev/null
@@ -1,22 +0,0 @@
-defmodule Fizz.Steps.TypeSubnodesTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Steps.Registry
-
- test "ai_agent exposes declared subnode slots in registry metadata" do
- assert {:ok, type} = Registry.get("ai_agent")
- assert type.node_role == :root
- assert is_list(type.subnode_slots)
- assert Enum.any?(type.subnode_slots, fn slot -> slot["id"] == "model" end)
- assert Enum.any?(type.subnode_slots, fn slot -> slot["id"] == "prompt" end)
- assert Enum.any?(type.subnode_slots, fn slot -> slot["id"] == "tools" end)
- end
-
- test "provider model nodes are registered as subnodes" do
- assert {:ok, openai_type} = Registry.get("openai_model")
- assert openai_type.node_role == :subnode
-
- assert {:ok, anthropic_type} = Registry.get("anthropic_model")
- assert anthropic_type.node_role == :subnode
- end
-end
diff --git a/test/fizz/steps_test.exs b/test/fizz/steps_test.exs
deleted file mode 100644
index 3724c88..0000000
--- a/test/fizz/steps_test.exs
+++ /dev/null
@@ -1,19 +0,0 @@
-defmodule Fizz.StepsTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Steps
-
- describe "default_step_name/1" do
- test "strips brand for em dash separated names" do
- assert Steps.default_step_name("Google Sheets — New Row") == "New Row"
- end
-
- test "strips brand for double-hyphen separated names" do
- assert Steps.default_step_name("Slack -- Send Message") == "Send Message"
- end
-
- test "keeps names without a brand separator unchanged" do
- assert Steps.default_step_name("HTTP Request") == "HTTP Request"
- end
- end
-end
diff --git a/test/fizz/triggers/basic_triggers_integration_test.exs b/test/fizz/triggers/basic_triggers_integration_test.exs
new file mode 100644
index 0000000..bfec242
--- /dev/null
+++ b/test/fizz/triggers/basic_triggers_integration_test.exs
@@ -0,0 +1,302 @@
+defmodule Fizz.Triggers.BasicTriggersIntegrationTest do
+ use FizzWeb.ConnCase, async: false
+ use Oban.Testing, repo: Fizz.Repo
+
+ import Ecto.Query
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Accounts.OauthConnection
+ alias Fizz.Repo
+ alias Fizz.Fields.Credential
+ alias Fizz.Triggers.Registry
+ alias Fizz.Triggers.TriggerRegistration
+ alias Fizz.Triggers.Workers.TriggerFireWorker
+ alias Fizz.Workflows
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.WorkflowRun
+
+ test "publish workflow with a schedule trigger enqueues initial Oban job and fires it", %{
+ conn: _conn
+ } do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, schedule_workflow_snapshot_attrs())
+ registration = schedule_registration(version.id)
+
+ assert registration.status == "active"
+ assert registration.next_fire_at != nil
+
+ # An initial TriggerFireWorker should have been enqueued at publish time
+ jobs = all_enqueued(worker: TriggerFireWorker)
+
+ assert Enum.any?(jobs, fn job ->
+ job.args["trigger_registration_id"] == registration.id
+ end)
+
+ # Fire the job directly
+ job =
+ Enum.find(jobs, fn job ->
+ job.args["trigger_registration_id"] == registration.id
+ end)
+
+ assert :ok = perform_job(TriggerFireWorker, job.args)
+
+ run =
+ eventually(fn ->
+ WorkflowRun
+ |> where([workflow_run], workflow_run.workflow_definition_version_id == ^version.id)
+ |> order_by([workflow_run], desc: workflow_run.inserted_at)
+ |> limit(1)
+ |> Repo.one()
+ |> case do
+ %WorkflowRun{} = workflow_run -> {:ok, workflow_run}
+ nil -> :retry
+ end
+ end)
+
+ assert run.triggered_by["trigger_kind"] == "schedule"
+
+ completed_run =
+ eventually(fn ->
+ case Workflows.get_run(scope, run.id) do
+ {:ok, %WorkflowRun{status: :completed} = workflow_run} -> {:ok, workflow_run}
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.status == :completed
+ assert_worker_shutdown(run.id)
+ end
+
+ test "publish workflow with a webhook trigger and fire it through the webhook controller", %{
+ conn: conn
+ } do
+ scope = project_scope_fixture()
+ version = publish_github_workflow!(scope, github_workflow_snapshot_attrs())
+ registration = webhook_registration(version.id)
+
+ start_supervised!({Registry, notifications?: false, refresh_interval_ms: :timer.hours(1)})
+ :ok = Registry.refresh()
+
+ body = github_payload() |> Jason.encode!()
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("x-github-event", "push")
+ |> put_req_header("x-github-delivery", "delivery-webhook")
+ |> put_req_header(
+ "x-hub-signature-256",
+ github_signature(body, registration.webhook_secret)
+ )
+ |> post(~p"/triggers/wh/#{registration.webhook_path}", body)
+
+ assert response(conn, 202) == ""
+
+ [job] = all_enqueued(worker: TriggerFireWorker)
+ assert :ok = perform_job(TriggerFireWorker, job.args)
+
+ run =
+ eventually(fn ->
+ WorkflowRun
+ |> where([workflow_run], workflow_run.workflow_definition_version_id == ^version.id)
+ |> order_by([workflow_run], desc: workflow_run.inserted_at)
+ |> limit(1)
+ |> Repo.one()
+ |> case do
+ %WorkflowRun{} = workflow_run -> {:ok, workflow_run}
+ nil -> :retry
+ end
+ end)
+
+ assert run.triggered_by["trigger_kind"] == "webhook"
+ assert run.triggered_by["event_id"] == "delivery-webhook"
+
+ completed_run =
+ eventually(fn ->
+ case Workflows.get_run(scope, run.id) do
+ {:ok, %WorkflowRun{status: :completed} = workflow_run} -> {:ok, workflow_run}
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.status == :completed
+ assert_worker_shutdown(run.id)
+ end
+
+ test "re-publish preserves the existing webhook_path", %{conn: _conn} do
+ scope = project_scope_fixture()
+ trigger_step_id = Ecto.UUID.generate()
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+
+ assert {:ok, saved_v1} =
+ Workflows.save_draft(scope, draft, github_workflow_snapshot_attrs(trigger_step_id))
+
+ bind_github_trigger!(saved_v1, scope, definition.id, trigger_step_id)
+
+ assert {:ok, version_one} = Workflows.publish_draft(scope, saved_v1)
+
+ first_registration = webhook_registration(version_one.id)
+
+ assert {:ok, draft_two} = Workflows.edit_definition(scope, definition)
+
+ assert {:ok, saved_v2} =
+ Workflows.save_draft(
+ scope,
+ draft_two,
+ github_workflow_snapshot_attrs(trigger_step_id, "acme/other-repo")
+ )
+
+ assert {:ok, version_two} = Workflows.publish_draft(scope, saved_v2)
+
+ second_registration = webhook_registration(version_two.id)
+
+ assert second_registration.webhook_path == first_registration.webhook_path
+ assert second_registration.webhook_secret == first_registration.webhook_secret
+ end
+
+ defp schedule_workflow_snapshot_attrs do
+ trigger =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "schedule_trigger",
+ name: "Schedule Trigger",
+ config: %{"interval_seconds" => 60}
+ })
+
+ debug = step(%{id: Ecto.UUID.generate(), type_id: "debug", name: "Debug"})
+
+ snapshot_attrs(%{
+ steps: [trigger, debug],
+ connections: [connection(%{source_step_id: trigger.id, target_step_id: debug.id})]
+ })
+ end
+
+ defp github_workflow_snapshot_attrs(step_id \\ Ecto.UUID.generate(), repository \\ "acme/site") do
+ trigger =
+ step(%{
+ id: step_id,
+ type_id: "github_trigger",
+ name: "GitHub Trigger",
+ config:
+ "github_trigger"
+ |> Fizz.Integrations.Steps.Registry.get_default_config()
+ |> Map.merge(%{"events" => ["push"], "repository" => repository})
+ })
+
+ debug = step(%{id: Ecto.UUID.generate(), type_id: "debug", name: "Debug"})
+
+ snapshot_attrs(%{
+ steps: [trigger, debug],
+ connections: [connection(%{source_step_id: trigger.id, target_step_id: debug.id})]
+ })
+ end
+
+ defp schedule_registration(version_id) do
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version_id and registration.kind == "schedule"
+ )
+ )
+ end
+
+ defp webhook_registration(version_id) do
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version_id and registration.kind == "webhook"
+ )
+ )
+ end
+
+ defp github_payload do
+ %{
+ "action" => "opened",
+ "ref" => "refs/heads/main",
+ "repository" => %{"full_name" => "acme/site"},
+ "sender" => %{"login" => "monalisa"}
+ }
+ end
+
+ defp github_signature(body, secret) do
+ digest =
+ :crypto.mac(:hmac, :sha256, secret, body)
+ |> Base.encode16(case: :lower)
+
+ "sha256=#{digest}"
+ end
+
+ defp create_definition_fixture(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Trigger Integration #{System.unique_integer([:positive])}",
+ description: "Trigger integration test"
+ })
+
+ %{definition: definition, draft: draft}
+ end
+
+ defp publish_github_workflow!(scope, snapshot_attrs) do
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+ [trigger | _steps] = snapshot_attrs.steps
+
+ {:ok, saved_draft} = Workflows.save_draft(scope, draft, snapshot_attrs)
+ bind_github_trigger!(saved_draft, scope, definition.id, trigger.id)
+
+ assert {:ok, version} = Workflows.publish_draft(scope, saved_draft)
+ version
+ end
+
+ defp bind_github_trigger!(version, scope, workflow_definition_id, trigger_step_id) do
+ connection = insert_oauth_connection!(scope, "github_oauth")
+
+ assert {:ok, _binding} =
+ Credential.upsert_binding(version, scope, %{
+ user_id: scope.user.id,
+ workflow_definition_id: workflow_definition_id,
+ step_id: trigger_step_id,
+ requirement_key: "auth",
+ binding_data: %{"credential_id" => connection.id},
+ workos_organization_id: scope.organization_id
+ })
+ end
+
+ defp insert_oauth_connection!(scope, provider) do
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ workos_organization_id: scope.organization_id,
+ user_id: scope.user.id,
+ provider: provider,
+ status: :active
+ })
+ |> Repo.insert!()
+ end
+
+ defp eventually(fun, attempts \\ 100)
+
+ defp eventually(fun, attempts) when attempts > 0 do
+ case fun.() do
+ {:ok, value} ->
+ value
+
+ :retry ->
+ receive do
+ after
+ 20 -> eventually(fun, attempts - 1)
+ end
+ end
+ end
+
+ defp eventually(_fun, 0), do: flunk("condition was not met in time")
+
+ defp assert_worker_shutdown(run_id) do
+ case Worker.lookup(run_id) do
+ nil ->
+ :ok
+
+ pid ->
+ ref = Process.monitor(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+ end
+ end
+end
diff --git a/test/fizz/triggers/registration_manager_test.exs b/test/fizz/triggers/registration_manager_test.exs
new file mode 100644
index 0000000..dd88074
--- /dev/null
+++ b/test/fizz/triggers/registration_manager_test.exs
@@ -0,0 +1,303 @@
+defmodule Fizz.Triggers.RegistrationManagerTest do
+ use Fizz.DataCase, async: false
+ use Oban.Testing, repo: Fizz.Repo
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Accounts.OauthConnection
+ alias Fizz.Repo
+ alias Fizz.Fields.Credential
+ alias Fizz.Triggers
+ alias Fizz.Triggers.RegistrationManager
+ alias Fizz.Triggers.{TriggerRegistration, TriggerSource}
+ alias Fizz.Triggers.Workers.TriggerFireWorker
+ alias Fizz.Workflows
+
+ test "sync_on_publish creates registrations from trigger manifest" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, multi_trigger_snapshot_attrs())
+
+ Repo.delete_all(TriggerRegistration)
+
+ assert :ok = RegistrationManager.sync_on_publish(version)
+
+ assert {:ok, registrations} =
+ Triggers.list_registrations(scope, definition_id: version.workflow_definition_id)
+
+ assert Enum.sort(Enum.map(registrations, & &1.kind)) == ["manual", "schedule"]
+ assert Enum.all?(registrations, &(&1.definition_version_id == version.id))
+ assert Enum.any?(registrations, &(&1.kind == "schedule" and not is_nil(&1.next_fire_at)))
+ end
+
+ test "sync_on_publish requires bindings for trigger credentials" do
+ scope = project_scope_fixture()
+
+ trigger =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "github_trigger",
+ name: "GitHub Trigger",
+ config:
+ "github_trigger"
+ |> Fizz.Integrations.Steps.Registry.get_default_config()
+ |> Map.put("repository", "acme/site")
+ })
+
+ %{version: version} = published_version_fixture(scope, snapshot_attrs(%{steps: [trigger]}))
+
+ assert {:error, [%{step_id: step_id, reason: {:credential_binding_required, "auth"}}]} =
+ RegistrationManager.sync_on_publish(version)
+
+ assert step_id == trigger.id
+ end
+
+ test "sync_on_publish deactivates previous version registrations" do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = definition_fixture(scope)
+
+ assert {:ok, saved_v1} = Workflows.save_draft(scope, draft, multi_trigger_snapshot_attrs())
+ assert {:ok, version_one} = Workflows.publish_draft(scope, saved_v1)
+
+ assert {:ok, draft_two} = Workflows.edit_definition(scope, definition)
+ assert {:ok, saved_v2} = Workflows.save_draft(scope, draft_two, manual_only_snapshot_attrs())
+ assert {:ok, version_two} = Workflows.publish_draft(scope, saved_v2)
+
+ assert :ok = RegistrationManager.sync_on_publish(version_two)
+
+ version_one_statuses =
+ TriggerRegistration
+ |> where([registration], registration.definition_version_id == ^version_one.id)
+ |> Repo.all()
+ |> Enum.map(& &1.status)
+ |> Enum.uniq()
+
+ version_two_statuses =
+ TriggerRegistration
+ |> where([registration], registration.definition_version_id == ^version_two.id)
+ |> Repo.all()
+ |> Enum.map(& &1.status)
+ |> Enum.uniq()
+
+ assert version_one_statuses == ["inactive"]
+ assert version_two_statuses == ["active"]
+ end
+
+ test "sync_on_publish is idempotent for the same config" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, multi_trigger_snapshot_attrs())
+
+ count_before =
+ TriggerRegistration
+ |> where([registration], registration.definition_version_id == ^version.id)
+ |> Repo.aggregate(:count, :id)
+
+ assert :ok = RegistrationManager.sync_on_publish(version)
+
+ count_after =
+ TriggerRegistration
+ |> where([registration], registration.definition_version_id == ^version.id)
+ |> Repo.aggregate(:count, :id)
+
+ assert count_after == count_before
+ end
+
+ test "sync_on_publish creates a shared polling source for Google Sheets triggers" do
+ scope = project_scope_fixture()
+
+ trigger =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "google_sheets_trigger",
+ name: "Google Sheets Trigger",
+ config: %{
+ "credential_ref" => credential_declaration("google_oauth", "oauth"),
+ "spreadsheet_id" => "spreadsheet_1",
+ "sheet_name" => "Sheet1",
+ "event_mode" => "row_added_or_updated",
+ "range" => "A:ZZZ",
+ "poll_interval_ms" => 60_000
+ }
+ })
+
+ %{definition: definition, draft: draft} = definition_fixture(scope)
+ {:ok, saved_draft} = Workflows.save_draft(scope, draft, snapshot_attrs(%{steps: [trigger]}))
+ connection = insert_oauth_connection!(scope, "google_oauth")
+
+ assert {:ok, _binding} =
+ Credential.upsert_binding(saved_draft, scope, %{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ step_id: trigger.id,
+ requirement_key: "auth",
+ binding_data: %{"credential_id" => connection.id},
+ workos_organization_id: scope.organization_id
+ })
+
+ assert {:ok, version} = Workflows.publish_draft(scope, saved_draft)
+
+ assert :ok = RegistrationManager.sync_on_publish(version)
+
+ registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version.id and
+ registration.step_id == ^trigger.id
+ )
+ )
+
+ source = Repo.get!(TriggerSource, registration.trigger_source_id)
+
+ assert registration.kind == "polling"
+ assert source.provider == "google_oauth"
+ assert source.source_module == "Fizz.Integrations.Library.Google.Sheets.Triggers.RowChange"
+ assert source.params["spreadsheet_id"] == "spreadsheet_1"
+ assert source.cursor == %{"initialized" => false}
+ end
+
+ test "sync_on_publish enqueues initial TriggerFireWorker for schedule triggers" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, multi_trigger_snapshot_attrs())
+
+ schedule_registration =
+ TriggerRegistration
+ |> where(
+ [registration],
+ registration.definition_version_id == ^version.id and registration.kind == "schedule"
+ )
+ |> Repo.one!()
+
+ assert schedule_registration.next_fire_at != nil
+
+ jobs =
+ all_enqueued(worker: TriggerFireWorker)
+ |> Enum.filter(fn job ->
+ job.args["trigger_registration_id"] == schedule_registration.id
+ end)
+
+ assert length(jobs) == 1
+ [job] = jobs
+ assert job.args["event_id"] =~ "sched_#{schedule_registration.id}_"
+ end
+
+ test "sync_on_publish preserves existing webhook credentials across re-publish" do
+ scope = project_scope_fixture()
+ trigger_step_id = Ecto.UUID.generate()
+ %{definition: definition, draft: draft} = definition_fixture(scope)
+
+ assert {:ok, saved_v1} =
+ Workflows.save_draft(scope, draft, webhook_snapshot_attrs(trigger_step_id))
+
+ connection = insert_oauth_connection!(scope, "github_oauth")
+
+ assert {:ok, _binding} =
+ Credential.upsert_binding(saved_v1, scope, %{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ step_id: trigger_step_id,
+ requirement_key: "auth",
+ binding_data: %{"credential_id" => connection.id},
+ workos_organization_id: scope.organization_id
+ })
+
+ assert {:ok, version_one} = Workflows.publish_draft(scope, saved_v1)
+
+ first_registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version_one.id and
+ registration.kind == "webhook"
+ )
+ )
+
+ assert {:ok, draft_two} = Workflows.edit_definition(scope, definition)
+
+ assert {:ok, saved_v2} =
+ Workflows.save_draft(
+ scope,
+ draft_two,
+ webhook_snapshot_attrs(trigger_step_id, "acme/other-repo")
+ )
+
+ assert {:ok, version_two} = Workflows.publish_draft(scope, saved_v2)
+
+ second_registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version_two.id and
+ registration.kind == "webhook"
+ )
+ )
+
+ assert second_registration.webhook_path == first_registration.webhook_path
+ assert second_registration.webhook_secret == first_registration.webhook_secret
+ end
+
+ defp definition_fixture(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Registration Manager #{System.unique_integer([:positive])}",
+ description: "Manager test"
+ })
+
+ %{definition: definition, draft: draft}
+ end
+
+ defp multi_trigger_snapshot_attrs do
+ manual = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Manual"})
+
+ schedule =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "schedule_trigger",
+ name: "Schedule",
+ config: %{"interval_seconds" => 60}
+ })
+
+ snapshot_attrs(%{steps: [manual, schedule]})
+ end
+
+ defp manual_only_snapshot_attrs do
+ snapshot_attrs(%{
+ steps: [step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Manual"})]
+ })
+ end
+
+ defp webhook_snapshot_attrs(step_id, repository \\ "acme/site") do
+ snapshot_attrs(%{
+ steps: [
+ step(%{
+ id: step_id,
+ type_id: "github_trigger",
+ name: "GitHub Trigger",
+ config:
+ "github_trigger"
+ |> Fizz.Integrations.Steps.Registry.get_default_config()
+ |> Map.merge(%{"events" => ["push"], "repository" => repository})
+ })
+ ]
+ })
+ end
+
+ defp credential_declaration(provider, auth_type) do
+ %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => provider,
+ "auth_type" => auth_type
+ }
+ end
+
+ defp insert_oauth_connection!(scope, provider) do
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ workos_organization_id: scope.organization_id,
+ user_id: scope.user.id,
+ provider: provider,
+ status: :active
+ })
+ |> Repo.insert!()
+ end
+end
diff --git a/test/fizz/triggers/registry_test.exs b/test/fizz/triggers/registry_test.exs
new file mode 100644
index 0000000..582e35d
--- /dev/null
+++ b/test/fizz/triggers/registry_test.exs
@@ -0,0 +1,106 @@
+defmodule Fizz.Triggers.RegistryTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Repo
+ alias Fizz.Triggers.Registry
+ alias Fizz.Triggers.TriggerRegistration
+ alias Fizz.Workflows
+
+ setup do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+
+ first =
+ insert_registration(scope, definition, draft, %{
+ step_id: "manual-root",
+ kind: "manual"
+ })
+
+ second =
+ insert_registration(scope, definition, draft, %{
+ step_id: "webhook-root",
+ kind: "webhook",
+ webhook_path: "wh_#{System.unique_integer([:positive])}",
+ webhook_secret: "secret"
+ })
+
+ third =
+ insert_registration(scope, definition, draft, %{
+ step_id: "schedule-root",
+ kind: "schedule"
+ })
+
+ pid =
+ start_supervised!({Registry, notifications?: false, refresh_interval_ms: :timer.hours(1)})
+
+ :ok = Registry.refresh()
+
+ %{scope: scope, registrations: [first, second, third], registry_pid: pid}
+ end
+
+ test "registry loads registrations into ETS on init", %{scope: scope, registrations: regs} do
+ cached = Registry.list_by_project(scope.project.id)
+
+ assert Enum.sort(Enum.map(cached, & &1.id)) == Enum.sort(Enum.map(regs, & &1.id))
+ end
+
+ test "get_by_webhook_path returns matching registration", %{registrations: regs} do
+ registration = Enum.find(regs, &(&1.kind == "webhook"))
+
+ assert {:ok, cached} = Registry.get_by_webhook_path(registration.webhook_path)
+ assert cached.id == registration.id
+ end
+
+ test "get_by_webhook_path returns error for unknown path" do
+ assert :error = Registry.get_by_webhook_path("missing")
+ end
+
+ test "list_by_kind filters by project and kind", %{scope: scope, registrations: regs} do
+ expected_ids =
+ regs
+ |> Enum.filter(&(&1.kind == "schedule"))
+ |> Enum.map(& &1.id)
+
+ cached_ids =
+ scope.project.id
+ |> Registry.list_by_kind(:schedule)
+ |> Enum.map(& &1.id)
+
+ assert cached_ids == expected_ids
+ end
+
+ defp create_definition_fixture(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Registry #{System.unique_integer([:positive])}",
+ description: "Registry test"
+ })
+
+ %{definition: definition, draft: draft}
+ end
+
+ defp insert_registration(scope, definition, draft, overrides) do
+ attrs =
+ Map.merge(
+ %{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ definition_version_id: draft.id,
+ step_id: "step-#{System.unique_integer([:positive])}",
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ kind: "manual",
+ status: "active",
+ registration_params: %{},
+ config_digest: "digest-#{System.unique_integer([:positive])}"
+ },
+ overrides
+ )
+
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(attrs)
+ |> Repo.insert!()
+ end
+end
diff --git a/test/fizz/triggers/trigger_event_test.exs b/test/fizz/triggers/trigger_event_test.exs
new file mode 100644
index 0000000..125b8b3
--- /dev/null
+++ b/test/fizz/triggers/trigger_event_test.exs
@@ -0,0 +1,61 @@
+defmodule Fizz.Triggers.TriggerEventTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Repo
+ alias Fizz.Triggers.{TriggerEvent, TriggerRegistration}
+ alias Fizz.Workflows
+
+ test "event dedup constraint prevents duplicate registration event ids" do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+
+ registration =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ definition_version_id: draft.id,
+ step_id: "manual-root",
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ kind: "manual",
+ status: "active",
+ registration_params: %{},
+ config_digest: "digest-a"
+ })
+ |> Repo.insert!()
+
+ attrs = %{
+ trigger_registration_id: registration.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ event_id: "evt-1",
+ event_data: %{"hello" => "world"},
+ status: "pending"
+ }
+
+ assert {:ok, _event} =
+ %TriggerEvent{}
+ |> TriggerEvent.changeset(attrs)
+ |> Repo.insert()
+
+ assert {:error, changeset} =
+ %TriggerEvent{}
+ |> TriggerEvent.changeset(attrs)
+ |> Repo.insert()
+
+ assert "has already been taken" in errors_on(changeset).trigger_registration_id
+ end
+
+ defp create_definition_fixture(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Trigger Event #{System.unique_integer([:positive])}",
+ description: "Event test"
+ })
+
+ %{definition: definition, draft: draft}
+ end
+end
diff --git a/test/fizz/triggers/trigger_registration_test.exs b/test/fizz/triggers/trigger_registration_test.exs
new file mode 100644
index 0000000..67dffbb
--- /dev/null
+++ b/test/fizz/triggers/trigger_registration_test.exs
@@ -0,0 +1,143 @@
+defmodule Fizz.Triggers.TriggerRegistrationTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Repo
+ alias Fizz.Triggers.TriggerRegistration
+ alias Fizz.Workflows
+ alias Fizz.Workflows.WorkflowRun
+
+ test "changeset validates required fields" do
+ changeset = TriggerRegistration.changeset(%TriggerRegistration{}, %{})
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).workflow_definition_id
+ assert "can't be blank" in errors_on(changeset).definition_version_id
+ assert "can't be blank" in errors_on(changeset).project_id
+ assert "can't be blank" in errors_on(changeset).step_id
+ assert "can't be blank" in errors_on(changeset).kind
+ assert "can't be blank" in errors_on(changeset).config_digest
+ end
+
+ test "definition-level dedup constraint prevents duplicate registrations" do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+
+ attrs = registration_attrs(scope, definition, draft, %{step_id: "manual-root"})
+
+ assert {:ok, _registration} =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(attrs)
+ |> Repo.insert()
+
+ assert {:error, changeset} =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(Map.put(attrs, :config_digest, "changed"))
+ |> Repo.insert()
+
+ assert "has already been taken" in errors_on(changeset).definition_version_id
+ end
+
+ test "run-level dedup constraint prevents duplicate registrations" do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+ run = insert_run(scope, definition, draft)
+
+ attrs =
+ scope
+ |> registration_attrs(definition, draft, %{step_id: "wait-for-signal", run_id: run.id})
+ |> Map.delete(:definition_version_id)
+
+ attrs = Map.put(attrs, :definition_version_id, draft.id)
+
+ assert {:ok, _registration} =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(attrs)
+ |> Repo.insert()
+
+ assert {:error, changeset} =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(Map.put(attrs, :config_digest, "changed"))
+ |> Repo.insert()
+
+ assert "has already been taken" in errors_on(changeset).run_id
+ end
+
+ test "webhook path unique constraint enforced for active registrations" do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = create_definition_fixture(scope)
+ webhook_path = "wh_test_#{System.unique_integer([:positive])}"
+
+ first_attrs =
+ registration_attrs(scope, definition, draft, %{
+ step_id: "webhook-a",
+ kind: "webhook",
+ webhook_path: webhook_path,
+ webhook_secret: "secret-a"
+ })
+
+ second_attrs =
+ registration_attrs(scope, definition, draft, %{
+ step_id: "webhook-b",
+ kind: "webhook",
+ webhook_path: webhook_path,
+ webhook_secret: "secret-b"
+ })
+
+ assert {:ok, _registration} =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(first_attrs)
+ |> Repo.insert()
+
+ assert {:error, changeset} =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(second_attrs)
+ |> Repo.insert()
+
+ assert "has already been taken" in errors_on(changeset).webhook_path
+ end
+
+ defp create_definition_fixture(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Trigger Test #{System.unique_integer([:positive])}",
+ description: "Registration test"
+ })
+
+ %{definition: definition, draft: draft}
+ end
+
+ defp insert_run(scope, definition, draft) do
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ workflow_definition_version_id: draft.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :pending,
+ input: %{},
+ last_active_at: DateTime.utc_now()
+ })
+ |> Repo.insert!()
+ end
+
+ defp registration_attrs(scope, definition, draft, overrides) do
+ Map.merge(
+ %{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ definition_version_id: draft.id,
+ step_id: "step-#{System.unique_integer([:positive])}",
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ kind: "manual",
+ status: "active",
+ registration_params: %{},
+ config_digest: "digest-#{System.unique_integer([:positive])}"
+ },
+ overrides
+ )
+ end
+end
diff --git a/test/fizz/triggers/trigger_source_test.exs b/test/fizz/triggers/trigger_source_test.exs
new file mode 100644
index 0000000..198d3b5
--- /dev/null
+++ b/test/fizz/triggers/trigger_source_test.exs
@@ -0,0 +1,124 @@
+defmodule Fizz.Triggers.TriggerSourceTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Triggers
+ alias Fizz.Triggers.{SourcePoller, TriggerSource}
+
+ test "upsert_source creates and preserves runtime cursor on later syncs" do
+ scope = project_scope_fixture()
+
+ attrs = source_attrs(scope, "google:sheet:1")
+
+ assert {:ok, source} =
+ Triggers.upsert_source(Map.put(attrs, :cursor, %{"initialized" => false}))
+
+ assert source.cursor == %{"initialized" => false}
+
+ assert {:ok, updated} =
+ Triggers.upsert_source(Map.put(attrs, :cursor, %{"initialized" => true}))
+
+ assert updated.id == source.id
+ assert updated.cursor == %{"initialized" => false}
+ end
+
+ test "claim_source_for_poll enforces a durable lease" do
+ scope = project_scope_fixture()
+ now = DateTime.utc_now()
+
+ assert {:ok, source} = Triggers.upsert_source(source_attrs(scope, "google:sheet:lease"))
+
+ assert :ok = Triggers.claim_source_for_poll(source.id, "owner-a", 60_000, now)
+ assert {:error, :busy} = Triggers.claim_source_for_poll(source.id, "owner-b", 60_000, now)
+
+ later = DateTime.add(now, 61, :second)
+ assert :ok = Triggers.claim_source_for_poll(source.id, "owner-b", 60_000, later)
+ end
+
+ test "record_source_poll_error stores bounded messages and clears leases" do
+ scope = project_scope_fixture()
+ now = DateTime.utc_now()
+
+ assert {:ok, source} = Triggers.upsert_source(source_attrs(scope, "google:sheet:error"))
+ assert :ok = Triggers.claim_source_for_poll(source.id, "owner-a", 60_000, now)
+
+ source = Repo.get!(TriggerSource, source.id)
+ assert source.lease_owner == "owner-a"
+
+ reason = %{status: 403, body: %{"error" => String.duplicate("permission denied ", 100)}}
+
+ assert {:ok, updated} = Triggers.record_source_poll_error(source, reason)
+
+ assert updated.lease_owner == nil
+ assert updated.lease_expires_at == nil
+ assert updated.consecutive_errors == 1
+ assert String.length(updated.error_message) <= 255
+ assert updated.error_message =~ "permission denied"
+ end
+
+ test "source poller records raised polling errors and releases lease" do
+ scope = project_scope_fixture()
+ Process.register(self(), Fizz.Triggers.RaisingSourceTest)
+
+ attrs =
+ scope
+ |> source_attrs("test:raising-source")
+ |> Map.merge(%{
+ source_module: "Fizz.Triggers.RaisingSource",
+ next_poll_at: DateTime.add(DateTime.utc_now(), -1, :second)
+ })
+
+ assert {:ok, source} = Triggers.upsert_source(attrs)
+
+ start_supervised!({Registry, keys: :unique, name: Fizz.Triggers.SourceRegistry})
+
+ pid =
+ start_supervised!(
+ {SourcePoller,
+ source_id: source.id, lease_owner: "test-owner", lease_ttl_ms: :timer.minutes(5)}
+ )
+
+ send(pid, :poll)
+ assert_receive {:raising_source_polled, ^pid}
+
+ updated = wait_for_source(source.id, &is_nil(&1.lease_owner))
+ assert updated.lease_owner == nil
+ assert updated.lease_expires_at == nil
+ assert updated.consecutive_errors == 1
+ assert updated.error_message =~ "raising source poll failed"
+ end
+
+ defp source_attrs(scope, source_key) do
+ %{
+ project_id: scope.project.id,
+ workos_organization_id: scope.organization_id,
+ user_id: scope.user.id,
+ kind: "polling",
+ provider: "google_oauth",
+ source_module: "Fizz.Integrations.Library.Google.Sheets.Triggers.RowChange",
+ source_key: source_key,
+ status: "active",
+ params: %{"spreadsheet_id" => "spreadsheet_1", "sheet_name" => "Sheet1"},
+ poll_interval_ms: 60_000,
+ next_poll_at: DateTime.utc_now()
+ }
+ end
+
+ defp wait_for_source(source_id, predicate, attempts \\ 25)
+
+ defp wait_for_source(source_id, predicate, attempts) when attempts > 0 do
+ source = Repo.get!(TriggerSource, source_id)
+
+ if predicate.(source) do
+ source
+ else
+ ref = make_ref()
+ Process.send_after(self(), {:retry_source, ref}, 10)
+ assert_receive {:retry_source, ^ref}
+ wait_for_source(source_id, predicate, attempts - 1)
+ end
+ end
+
+ defp wait_for_source(source_id, _predicate, 0), do: Repo.get!(TriggerSource, source_id)
+end
diff --git a/test/fizz/triggers/webhook_test.exs b/test/fizz/triggers/webhook_test.exs
new file mode 100644
index 0000000..c608abf
--- /dev/null
+++ b/test/fizz/triggers/webhook_test.exs
@@ -0,0 +1,57 @@
+defmodule Fizz.Triggers.WebhookTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Triggers.Webhook
+
+ test "generate_path/0 produces unique random tokens" do
+ path_one = Webhook.generate_path()
+ path_two = Webhook.generate_path()
+
+ assert path_one != path_two
+ assert String.starts_with?(path_one, "wh_")
+ assert String.starts_with?(path_two, "wh_")
+ end
+
+ test "generate_secret/0 produces strong random values" do
+ secret_one = Webhook.generate_secret()
+ secret_two = Webhook.generate_secret()
+
+ assert secret_one != secret_two
+ assert byte_size(secret_one) >= 44
+ assert byte_size(secret_two) >= 44
+ end
+
+ test "verify_signature/4 succeeds with the correct secret" do
+ payload = ~s({"event":"push"})
+ secret = "top-secret"
+
+ signature =
+ :crypto.mac(:hmac, :sha256, secret, payload)
+ |> Base.encode16(case: :lower)
+ |> then(&"sha256=#{&1}")
+
+ assert Webhook.verify_signature(payload, secret, signature, "hmac-sha256")
+ end
+
+ test "verify_signature/4 fails with the wrong secret" do
+ payload = ~s({"event":"push"})
+
+ signature =
+ :crypto.mac(:hmac, :sha256, "correct-secret", payload)
+ |> Base.encode16(case: :lower)
+ |> then(&"sha256=#{&1}")
+
+ refute Webhook.verify_signature(payload, "wrong-secret", signature, "hmac-sha256")
+ end
+
+ test "verify_signature/4 handles direct hex signatures with timing-safe comparison" do
+ payload = ~s({"event":"push"})
+ secret = "another-secret"
+
+ signature =
+ :crypto.mac(:hmac, :sha256, secret, payload)
+ |> Base.encode16(case: :lower)
+
+ assert Webhook.verify_signature(payload, secret, signature, "sha256")
+ end
+end
diff --git a/test/fizz/triggers/workers/registration_sync_worker_test.exs b/test/fizz/triggers/workers/registration_sync_worker_test.exs
new file mode 100644
index 0000000..0606ffa
--- /dev/null
+++ b/test/fizz/triggers/workers/registration_sync_worker_test.exs
@@ -0,0 +1,216 @@
+defmodule Fizz.Triggers.Workers.RegistrationSyncWorkerTest do
+ use Fizz.DataCase, async: false
+ use Oban.Testing, repo: Fizz.Repo
+
+ import Ecto.Query
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Repo
+ alias Fizz.Triggers.TriggerRegistration
+ alias Fizz.Triggers.Workers.RegistrationSyncWorker
+ alias Fizz.Triggers.Workers.TriggerFireWorker
+ alias Fizz.Workflows
+ alias Fizz.Workflows.WorkflowDefinitionVersion
+
+ test "creates missing registrations for published versions" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, manual_trigger_snapshot_attrs())
+
+ Repo.delete_all(
+ from(registration in TriggerRegistration,
+ where: registration.definition_version_id == ^version.id
+ )
+ )
+
+ assert :ok = perform_job(RegistrationSyncWorker, %{})
+
+ assert Repo.aggregate(
+ from(registration in TriggerRegistration,
+ where: registration.definition_version_id == ^version.id
+ ),
+ :count,
+ :id
+ ) == 1
+ end
+
+ test "deactivates registrations for unpublished versions" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, manual_trigger_snapshot_attrs())
+
+ update_result =
+ version
+ |> WorkflowDefinitionVersion.changeset(%{status: :archived})
+ |> Repo.update()
+
+ assert :ok ==
+ (case update_result do
+ {:ok, _version} -> :ok
+ {:error, changeset} -> raise inspect(changeset.errors)
+ end)
+
+ assert :ok = perform_job(RegistrationSyncWorker, %{})
+
+ statuses =
+ TriggerRegistration
+ |> where([registration], registration.definition_version_id == ^version.id)
+ |> Repo.all()
+ |> Enum.map(& &1.status)
+ |> Enum.uniq()
+
+ assert statuses == ["inactive"]
+ end
+
+ test "keeps only the latest published version registration active per workflow" do
+ scope = project_scope_fixture()
+ trigger_step_id = Ecto.UUID.generate()
+
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Registration worker #{System.unique_integer([:positive])}",
+ description: "Latest published version test"
+ })
+
+ assert {:ok, saved_v1} =
+ Workflows.save_draft(
+ scope,
+ draft,
+ manual_trigger_snapshot_attrs(trigger_step_id, "Manual v1")
+ )
+
+ assert {:ok, version_one} = Workflows.publish_draft(scope, saved_v1)
+
+ assert {:ok, draft_two} = Workflows.edit_definition(scope, definition)
+
+ assert {:ok, saved_v2} =
+ Workflows.save_draft(
+ scope,
+ draft_two,
+ manual_trigger_snapshot_attrs(trigger_step_id, "Manual v2")
+ )
+
+ assert {:ok, version_two} = Workflows.publish_draft(scope, saved_v2)
+
+ version_one_registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where: registration.definition_version_id == ^version_one.id
+ )
+ )
+
+ version_two_registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where: registration.definition_version_id == ^version_two.id
+ )
+ )
+
+ {:ok, _registration} =
+ version_one_registration
+ |> TriggerRegistration.changeset(%{status: "active"})
+ |> Repo.update()
+
+ {:ok, _registration} =
+ version_two_registration
+ |> TriggerRegistration.changeset(%{status: "inactive"})
+ |> Repo.update()
+
+ assert :ok = perform_job(RegistrationSyncWorker, %{})
+
+ assert Repo.get!(TriggerRegistration, version_one_registration.id).status == "inactive"
+ assert Repo.get!(TriggerRegistration, version_two_registration.id).status == "active"
+ end
+
+ test "resets errored registrations past cooldown" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, manual_trigger_snapshot_attrs())
+
+ registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where: registration.definition_version_id == ^version.id
+ )
+ )
+
+ cutoff = DateTime.add(DateTime.utc_now(), -10, :minute)
+
+ assert {:ok, _registration} =
+ registration
+ |> TriggerRegistration.changeset(%{
+ status: "errored",
+ error_message: "boom",
+ consecutive_errors: 3,
+ last_error_at: cutoff
+ })
+ |> Repo.update()
+
+ assert :ok = perform_job(RegistrationSyncWorker, %{})
+
+ recovered = Repo.get!(TriggerRegistration, registration.id)
+
+ assert recovered.status == "active"
+ assert recovered.consecutive_errors == 0
+ assert is_nil(recovered.last_error_at)
+ assert is_nil(recovered.error_message)
+ end
+
+ test "recovers broken schedule chains by enqueuing a TriggerFireWorker" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, schedule_trigger_snapshot_attrs())
+
+ registration =
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version.id and
+ registration.kind == "schedule"
+ )
+ )
+
+ # Set next_fire_at to the past to simulate an overdue registration
+ overdue_at = DateTime.add(DateTime.utc_now(), -5, :minute)
+
+ assert {:ok, _registration} =
+ registration
+ |> TriggerRegistration.changeset(%{next_fire_at: overdue_at})
+ |> Repo.update()
+
+ # Cancel any existing enqueued jobs for this registration to simulate a broken chain
+ Repo.delete_all(
+ from(job in Oban.Job,
+ where:
+ job.worker == "Fizz.Triggers.Workers.TriggerFireWorker" and
+ fragment("?->>'trigger_registration_id' = ?", job.args, ^registration.id)
+ )
+ )
+
+ assert :ok = perform_job(RegistrationSyncWorker, %{})
+
+ # A recovery job should be enqueued
+ recovery_jobs =
+ all_enqueued(worker: TriggerFireWorker)
+ |> Enum.filter(fn job ->
+ job.args["trigger_registration_id"] == registration.id
+ end)
+
+ assert length(recovery_jobs) >= 1
+ end
+
+ defp schedule_trigger_snapshot_attrs do
+ snapshot_attrs(%{
+ steps: [
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "schedule_trigger",
+ name: "Schedule",
+ config: %{"interval_seconds" => 60}
+ })
+ ]
+ })
+ end
+
+ defp manual_trigger_snapshot_attrs(step_id \\ Ecto.UUID.generate(), name \\ "Manual") do
+ snapshot_attrs(%{
+ steps: [step(%{id: step_id, type_id: "manual_input", name: name})]
+ })
+ end
+end
diff --git a/test/fizz/triggers/workers/trigger_fire_worker_test.exs b/test/fizz/triggers/workers/trigger_fire_worker_test.exs
new file mode 100644
index 0000000..a4c13a5
--- /dev/null
+++ b/test/fizz/triggers/workers/trigger_fire_worker_test.exs
@@ -0,0 +1,260 @@
+defmodule Fizz.Triggers.Workers.TriggerFireWorkerTest do
+ use Fizz.DataCase, async: false
+ use Oban.Testing, repo: Fizz.Repo
+
+ import Ecto.Query
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Repo
+ alias Fizz.Triggers.TriggerEvent
+ alias Fizz.Triggers.TriggerRegistration
+ alias Fizz.Triggers.Workers.TriggerFireWorker
+ alias Fizz.Workflows
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.SignalInbox
+ alias Fizz.Workflows.WorkflowRun
+
+ test "definition-level registration creates a workflow run with triggered_by metadata" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, manual_trigger_snapshot_attrs())
+
+ registration =
+ Repo.one!(from(reg in TriggerRegistration, where: reg.definition_version_id == ^version.id))
+
+ assert :ok =
+ perform_job(TriggerFireWorker, %{
+ trigger_registration_id: registration.id,
+ event_id: "evt-definition",
+ normalized_data: %{"payload" => "hello"}
+ })
+
+ created_run =
+ eventually(fn ->
+ WorkflowRun
+ |> where([run], run.workflow_definition_version_id == ^version.id)
+ |> order_by([run], desc: run.inserted_at)
+ |> limit(1)
+ |> Repo.one()
+ |> case do
+ %WorkflowRun{triggered_by: triggered_by} = run when is_map(triggered_by) ->
+ {:ok, run}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ assert created_run.input == %{"payload" => "hello"}
+ assert created_run.triggered_by["trigger_registration_id"] == registration.id
+ assert created_run.triggered_by["trigger_step_id"] == registration.step_id
+ assert created_run.triggered_by["trigger_kind"] == registration.kind
+ assert created_run.triggered_by["event_id"] == "evt-definition"
+
+ assert %TriggerEvent{status: "fired", run_id: run_id} =
+ Repo.one!(
+ from(event in TriggerEvent,
+ where:
+ event.trigger_registration_id == ^registration.id and
+ event.event_id == ^"evt-definition"
+ )
+ )
+
+ assert run_id == created_run.id
+
+ completed_run =
+ eventually(fn ->
+ case Workflows.get_run(scope, created_run.id) do
+ {:ok, %WorkflowRun{status: :completed} = workflow_run} -> {:ok, workflow_run}
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.status == :completed
+ assert_worker_shutdown(created_run.id)
+ end
+
+ test "run-level registration delivers a signal to the existing run" do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope, long_running_snapshot_attrs(100))
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"kind" => "initial"})
+
+ _sleeping_run =
+ eventually(fn ->
+ case Workflows.get_run(scope, run.id) do
+ {:ok, %WorkflowRun{status: status} = workflow_run}
+ when status in [:sleeping, :running, :passivated] ->
+ {:ok, workflow_run}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ registration =
+ %TriggerRegistration{}
+ |> TriggerRegistration.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ definition_version_id: version.id,
+ step_id: "external-signal",
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ run_id: run.id,
+ kind: "manual",
+ status: "active",
+ registration_params: %{},
+ config_digest: "run-level-digest"
+ })
+ |> Repo.insert!()
+
+ assert :ok =
+ perform_job(TriggerFireWorker, %{
+ trigger_registration_id: registration.id,
+ event_id: "evt-run",
+ normalized_data: %{"payload" => "resume"}
+ })
+
+ delivered_signal =
+ eventually(fn ->
+ SignalInbox
+ |> where([signal], signal.run_id == ^run.id and signal.signal_id == ^"evt-run")
+ |> Repo.one()
+ |> case do
+ %SignalInbox{status: status} = signal when status in [:delivered, :pending] ->
+ {:ok, signal}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ assert delivered_signal.signal_name == registration.step_id
+
+ assert Repo.aggregate(
+ from(workflow_run in WorkflowRun,
+ where: workflow_run.workflow_definition_version_id == ^version.id
+ ),
+ :count,
+ :id
+ ) == 1
+
+ assert %TriggerEvent{status: "fired", run_id: event_run_id} =
+ Repo.one!(
+ from(event in TriggerEvent,
+ where:
+ event.trigger_registration_id == ^registration.id and
+ event.event_id == ^"evt-run"
+ )
+ )
+
+ assert event_run_id == run.id
+
+ completed_run =
+ eventually(fn ->
+ case Workflows.get_run(scope, run.id) do
+ {:ok, %WorkflowRun{status: :completed} = workflow_run} -> {:ok, workflow_run}
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.status == :completed
+ assert_worker_shutdown(run.id)
+ end
+
+ test "schedule trigger self-chains by enqueuing next TriggerFireWorker after firing" do
+ scope = project_scope_fixture()
+
+ %{version: version} =
+ published_version_fixture(scope, schedule_trigger_snapshot_attrs())
+
+ registration =
+ Repo.one!(
+ from(reg in TriggerRegistration,
+ where: reg.definition_version_id == ^version.id and reg.kind == "schedule"
+ )
+ )
+
+ due_at = DateTime.add(DateTime.utc_now(), -1, :second)
+
+ registration =
+ registration
+ |> TriggerRegistration.changeset(%{next_fire_at: due_at})
+ |> Repo.update!()
+
+ event_id = "sched_#{registration.id}_#{DateTime.to_unix(due_at)}"
+
+ assert :ok =
+ perform_job(TriggerFireWorker, %{
+ trigger_registration_id: registration.id,
+ event_id: event_id,
+ normalized_data: %{"scheduled_at" => DateTime.to_iso8601(due_at)}
+ })
+
+ # Verify self-chaining: a new job should be enqueued with a future scheduled_at
+ next_jobs =
+ all_enqueued(worker: TriggerFireWorker)
+ |> Enum.filter(fn job ->
+ job.args["trigger_registration_id"] == registration.id and
+ job.args["event_id"] != event_id
+ end)
+ |> Enum.sort_by(& &1.scheduled_at, {:desc, DateTime})
+
+ assert length(next_jobs) >= 1
+ [next_job | _] = next_jobs
+ assert next_job.scheduled_at != nil
+
+ # The registration's next_fire_at should be updated
+ updated_registration = Repo.get!(TriggerRegistration, registration.id)
+ assert DateTime.compare(updated_registration.next_fire_at, due_at) == :gt
+ end
+
+ defp schedule_trigger_snapshot_attrs do
+ trigger =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "schedule_trigger",
+ name: "Schedule Trigger",
+ config: %{"interval_seconds" => 60}
+ })
+
+ debug = step(%{id: Ecto.UUID.generate(), type_id: "debug", name: "Debug"})
+
+ snapshot_attrs(%{
+ steps: [trigger, debug],
+ connections: [connection(%{source_step_id: trigger.id, target_step_id: debug.id})]
+ })
+ end
+
+ defp manual_trigger_snapshot_attrs do
+ snapshot_attrs(%{
+ steps: [step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Manual"})]
+ })
+ end
+
+ defp eventually(fun, attempts \\ 50)
+
+ defp eventually(fun, attempts) when attempts > 0 do
+ case fun.() do
+ {:ok, value} ->
+ value
+
+ :retry ->
+ receive do
+ after
+ 20 -> eventually(fun, attempts - 1)
+ end
+ end
+ end
+
+ defp eventually(_fun, 0), do: flunk("condition not met")
+
+ defp assert_worker_shutdown(run_id) do
+ eventually(fn ->
+ case Worker.lookup(run_id) do
+ nil -> {:ok, :stopped}
+ _pid -> :retry
+ end
+ end)
+ end
+end
diff --git a/test/fizz/workflows/compiler/assembler_context_test.exs b/test/fizz/workflows/compiler/assembler_context_test.exs
new file mode 100644
index 0000000..19892da
--- /dev/null
+++ b/test/fizz/workflows/compiler/assembler_context_test.exs
@@ -0,0 +1,55 @@
+defmodule Fizz.Workflows.Compiler.AssemblerContextTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Accounts.Scope
+ alias Fizz.Workflows.Compiler.Assembler
+
+ test "executor context carries runtime identity and scope values" do
+ scope = %Scope{user: %{id: "user_123"}, organization_id: "org_123"}
+
+ resolver = fn _requirement_key, _step_id, _provider, _auth_type ->
+ {:ok, %{"id" => "credential_123"}}
+ end
+
+ resolution_context =
+ Assembler.resolution_context(
+ %{"input" => true},
+ %{
+ workflow: %{
+ user_id: "user_123",
+ project_id: "project_123",
+ workos_organization_id: "org_123"
+ },
+ metadata: %{
+ user_id: "user_123",
+ project_id: "project_123",
+ workos_organization_id: "org_123"
+ },
+ current_scope: scope,
+ scope: scope,
+ env: %{"mode" => "test"},
+ _credential_resolver: resolver
+ },
+ %{step_ids: MapSet.new()}
+ )
+
+ context =
+ Assembler.executor_context(
+ %{"input" => true},
+ resolution_context,
+ %{step_id: "step_123", step_name: "Step", type_id: "debug"}
+ )
+
+ assert context.current_scope == scope
+ assert context.scope == scope
+ assert context.user_id == "user_123"
+ assert context.project_id == "project_123"
+ assert context.workos_organization_id == "org_123"
+ assert context.workflow.project_id == "project_123"
+ assert context.metadata.user_id == "user_123"
+ assert %Fizz.Workflows.ExecutionContext{} = context.execution_context
+ assert context.execution_context.scope == scope
+ assert context.execution_context.project_id == "project_123"
+ assert context.execution_context.credential_resolver == resolver
+ end
+end
diff --git a/test/fizz/workflows/compiler/trigger_manifest_test.exs b/test/fizz/workflows/compiler/trigger_manifest_test.exs
new file mode 100644
index 0000000..0e09684
--- /dev/null
+++ b/test/fizz/workflows/compiler/trigger_manifest_test.exs
@@ -0,0 +1,116 @@
+defmodule Fizz.Workflows.Compiler.TriggerManifestTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.Compiler
+ alias Fizz.Workflows.WorkflowDefinitionVersion
+ alias Fizz.Workflows.Embeds.{Connection, Step}
+
+ test "compiler extracts trigger_manifest for workflows with trigger steps" do
+ version = trigger_manifest_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ assert workflow.fizz_metadata.trigger_manifest == [
+ %{
+ step_id: hd(version.steps).id,
+ type_id: "manual_input",
+ config: %{}
+ },
+ %{
+ step_id: List.last(version.steps).id,
+ type_id: "schedule_trigger",
+ config: %{"interval_seconds" => 60}
+ }
+ ]
+ end
+
+ test "compiler rejects trigger steps with incoming connections" do
+ version = invalid_trigger_root_version()
+
+ assert {:error, [%{step_id: step_id, message: message}]} = Compiler.compile(version)
+ assert step_id == List.last(version.steps).id
+ assert message == "trigger steps must be graph roots with no incoming connections"
+ end
+
+ test "trigger manifest includes step_id, type_id, and config" do
+ version = trigger_manifest_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ assert Enum.all?(workflow.fizz_metadata.trigger_manifest, fn entry ->
+ Map.has_key?(entry, :step_id) and
+ Map.has_key?(entry, :type_id) and
+ Map.has_key?(entry, :config)
+ end)
+ end
+
+ defp trigger_manifest_version do
+ manual_id = "00000000-0000-0000-0000-000000000001"
+ schedule_id = "00000000-0000-0000-0000-000000000002"
+
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: manual_id,
+ type_id: "manual_input",
+ name: "Manual",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: schedule_id,
+ type_id: "schedule_trigger",
+ name: "Schedule",
+ config: %{"interval_seconds" => 60},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+
+ defp invalid_trigger_root_version do
+ manual_id = "00000000-0000-0000-0000-000000000011"
+ schedule_id = "00000000-0000-0000-0000-000000000012"
+
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: manual_id,
+ type_id: "manual_input",
+ name: "Manual",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: schedule_id,
+ type_id: "schedule_trigger",
+ name: "Schedule",
+ config: %{"interval_seconds" => 60},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: manual_id,
+ source_output: "main",
+ target_step_id: schedule_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+end
diff --git a/test/fizz/workflows/compiler_semantics_test.exs b/test/fizz/workflows/compiler_semantics_test.exs
new file mode 100644
index 0000000..0c2f513
--- /dev/null
+++ b/test/fizz/workflows/compiler_semantics_test.exs
@@ -0,0 +1,525 @@
+defmodule Fizz.Workflows.CompilerSemanticsTest do
+ use ExUnit.Case, async: true
+
+ import Fizz.Workflows.CompilerScenarioHelper
+
+ alias Fizz.Workflows.Compiler
+
+ test "plain diamond outside split waits for both parents in authored order" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+ sink = step(%{id: Ecto.UUID.generate(), type_id: "data_output", name: "Out"})
+
+ workflow =
+ [root, add, multiply, sink]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: add.id}),
+ connection(%{source_step_id: root.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: sink.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: sink.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(2)
+
+ assert productions(workflow, sink.id) == [[3, 20]]
+ end
+
+ test "explicit join outside split defaults to wait_all semantics" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+ join = step(%{id: Ecto.UUID.generate(), type_id: "join", name: "Join", config: %{}})
+
+ workflow =
+ [root, add, multiply, join]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: add.id}),
+ connection(%{source_step_id: root.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: join.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: join.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(2)
+
+ assert productions(workflow, join.id) == [[3, 20]]
+ end
+
+ test "splitter to aggregator preserves order across map-reduce fan-in" do
+ {workflow, aggregator_id} = split_collect_workflow()
+ workflow = react(workflow, %{"items" => [3, 1, 2]})
+
+ assert productions(workflow, aggregator_id) == [[6, 2, 4]]
+ end
+
+ test "splitter supports json aliases when selecting the collection to fan out" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "{{ json.items }}"}
+ })
+
+ multiply = math_step("Times Two", "multiply", "{{ input }}", 2)
+
+ aggregator =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "aggregator",
+ name: "Collect",
+ config: %{"operation" => "collect"}
+ })
+
+ workflow =
+ [root, splitter, multiply, aggregator]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: aggregator.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"items" => [1, 2, 3]})
+
+ assert productions(workflow, aggregator.id) == [[2, 4, 6]]
+ end
+
+ test "splitter to aggregator emits one result for a single split item" do
+ {workflow, aggregator_id} = split_collect_workflow()
+ workflow = react(workflow, %{"items" => [4]})
+
+ assert productions(workflow, aggregator_id) == [[8]]
+ end
+
+ test "splitter to aggregator emits the operation default for an empty split" do
+ {workflow, aggregator_id} = split_collect_workflow()
+ workflow = react(workflow, %{"items" => []})
+
+ assert productions(workflow, aggregator_id) == [[]]
+ end
+
+ test "same-lineage split branches require an explicit join and zip together deterministically" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "items"}
+ })
+
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+
+ join =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "join",
+ name: "Join",
+ config: %{"mode" => "zip_nil"}
+ })
+
+ workflow =
+ [root, splitter, add, multiply, join]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: add.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: join.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: join.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"items" => [1, 2, 3]})
+
+ assert productions(workflow, join.id) == [[[2, 10], [3, 20], [4, 30]]]
+ end
+
+ test "mixing split and non-split parents defaults to zip_nil semantics" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "items"}
+ })
+
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ whole = step(%{id: Ecto.UUID.generate(), type_id: "data_output", name: "Whole"})
+
+ join =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "join",
+ name: "Join",
+ config: %{}
+ })
+
+ input = %{"items" => [1, 2], "label" => "Ada"}
+
+ workflow =
+ [root, splitter, add, whole, join]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: root.id, target_step_id: whole.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: add.id}),
+ connection(%{source_step_id: add.id, target_step_id: join.id}),
+ connection(%{source_step_id: whole.id, target_step_id: join.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(input)
+
+ assert productions(workflow, join.id) == [[[2, input], [3, nil]]]
+ end
+
+ test "joining two different splitters uses explicit cartesian semantics" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ left_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Left",
+ config: %{"field" => "left"}
+ })
+
+ right_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Right",
+ config: %{"field" => "right"}
+ })
+
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+
+ join =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "join",
+ name: "Join",
+ config: %{"mode" => "cartesian"}
+ })
+
+ workflow =
+ [root, left_splitter, right_splitter, add, multiply, join]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: left_splitter.id}),
+ connection(%{source_step_id: root.id, target_step_id: right_splitter.id}),
+ connection(%{source_step_id: left_splitter.id, target_step_id: add.id}),
+ connection(%{source_step_id: right_splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: join.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: join.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"left" => [1, 2], "right" => [3, 4]})
+
+ assert productions(workflow, join.id) == [[[2, 30], [2, 40], [3, 30], [3, 40]]]
+ end
+
+ test "joining split branches with different sizes defaults to zip_nil padding" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ left_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Left",
+ config: %{"field" => "left"}
+ })
+
+ right_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Right",
+ config: %{"field" => "right"}
+ })
+
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+ join = step(%{id: Ecto.UUID.generate(), type_id: "join", name: "Join", config: %{}})
+
+ workflow =
+ [root, left_splitter, right_splitter, add, multiply, join]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: left_splitter.id}),
+ connection(%{source_step_id: root.id, target_step_id: right_splitter.id}),
+ connection(%{source_step_id: left_splitter.id, target_step_id: add.id}),
+ connection(%{source_step_id: right_splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: join.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: join.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"left" => [1, 2, 3], "right" => [10, 20, 30, 40, 50]})
+
+ assert productions(workflow, join.id) == [
+ [[2, 100], [3, 200], [4, 300], [nil, 400], [nil, 500]]
+ ]
+ end
+
+ test "compiler rejects implicit split convergence without an explicit join" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "items"}
+ })
+
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+ sink = step(%{id: Ecto.UUID.generate(), type_id: "data_output", name: "Out"})
+
+ version =
+ version(
+ [root, splitter, add, multiply, sink],
+ [
+ connection(%{source_step_id: root.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: add.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: sink.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: sink.id})
+ ]
+ )
+
+ assert_compile_error(version, "insert an explicit `join` step")
+ end
+
+ test "aggregator implicitly zips same-depth split branches for row-safe operations" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ left_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Left",
+ config: %{"field" => "left"}
+ })
+
+ right_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Right",
+ config: %{"field" => "right"}
+ })
+
+ add = math_step("Add One", "add", "{{ input }}", 1)
+ multiply = math_step("Times Ten", "multiply", "{{ input }}", 10)
+
+ aggregator =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "aggregator",
+ name: "Collect",
+ config: %{"operation" => "collect"}
+ })
+
+ workflow =
+ [root, left_splitter, right_splitter, add, multiply, aggregator]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: left_splitter.id}),
+ connection(%{source_step_id: root.id, target_step_id: right_splitter.id}),
+ connection(%{source_step_id: left_splitter.id, target_step_id: add.id}),
+ connection(%{source_step_id: right_splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: add.id, target_step_id: aggregator.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: aggregator.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"left" => [1, 2], "right" => [3]})
+
+ assert productions(workflow, aggregator.id) == [[[2, 30], [3, nil]]]
+ end
+
+ test "joining split branches across different depths defaults to zip_nil after collection" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ outer_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Outer Split",
+ config: %{"field" => "outer"}
+ })
+
+ inner_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Inner Split",
+ config: %{}
+ })
+
+ inner = math_step("Times Ten", "multiply", "{{ input }}", 10)
+ outer = step(%{id: Ecto.UUID.generate(), type_id: "data_output", name: "Outer"})
+ join = step(%{id: Ecto.UUID.generate(), type_id: "join", name: "Join", config: %{}})
+
+ workflow =
+ [root, outer_splitter, inner_splitter, inner, outer, join]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: outer_splitter.id}),
+ connection(%{source_step_id: outer_splitter.id, target_step_id: inner_splitter.id}),
+ connection(%{source_step_id: inner_splitter.id, target_step_id: inner.id}),
+ connection(%{source_step_id: outer_splitter.id, target_step_id: outer.id}),
+ connection(%{source_step_id: inner.id, target_step_id: join.id}),
+ connection(%{source_step_id: outer.id, target_step_id: join.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"outer" => [[1, 2], [3]]})
+
+ assert productions(workflow, join.id) == [[[10, [1, 2]], [20, [3]], [30, nil]]]
+ end
+
+ test "aggregator implicitly zips different split depths after collecting branch outputs" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ outer_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Outer Split",
+ config: %{"field" => "outer"}
+ })
+
+ inner_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Inner Split",
+ config: %{}
+ })
+
+ inner = math_step("Times Ten", "multiply", "{{ input }}", 10)
+ outer = step(%{id: Ecto.UUID.generate(), type_id: "data_output", name: "Outer"})
+
+ aggregator =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "aggregator",
+ name: "Collect",
+ config: %{"operation" => "collect"}
+ })
+
+ workflow =
+ [root, outer_splitter, inner_splitter, inner, outer, aggregator]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: outer_splitter.id}),
+ connection(%{source_step_id: outer_splitter.id, target_step_id: inner_splitter.id}),
+ connection(%{source_step_id: inner_splitter.id, target_step_id: inner.id}),
+ connection(%{source_step_id: outer_splitter.id, target_step_id: outer.id}),
+ connection(%{source_step_id: inner.id, target_step_id: aggregator.id}),
+ connection(%{source_step_id: outer.id, target_step_id: aggregator.id})
+ ])
+ |> compile!()
+ |> elem(0)
+ |> react(%{"outer" => [[1, 2], [3]]})
+
+ assert productions(workflow, aggregator.id) == [[[10, [1, 2]], [20, [3]], [30, nil]]]
+ end
+
+ test "implicit multi-branch aggregators reject unsupported operations" do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ left_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Left",
+ config: %{"field" => "left"}
+ })
+
+ right_splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split Right",
+ config: %{"field" => "right"}
+ })
+
+ aggregator =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "aggregator",
+ name: "Sum",
+ config: %{"operation" => "sum"}
+ })
+
+ version =
+ version(
+ [root, left_splitter, right_splitter, aggregator],
+ [
+ connection(%{source_step_id: root.id, target_step_id: left_splitter.id}),
+ connection(%{source_step_id: root.id, target_step_id: right_splitter.id}),
+ connection(%{source_step_id: left_splitter.id, target_step_id: aggregator.id}),
+ connection(%{source_step_id: right_splitter.id, target_step_id: aggregator.id})
+ ]
+ )
+
+ assert_compile_error(version, "only supports operations: collect, count, first, last")
+ end
+
+ defp split_collect_workflow do
+ root = step(%{id: Ecto.UUID.generate(), type_id: "manual_input", name: "Entry"})
+
+ splitter =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "items"}
+ })
+
+ multiply = math_step("Times Two", "multiply", "{{ input }}", 2)
+
+ aggregator =
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "aggregator",
+ name: "Collect",
+ config: %{"operation" => "collect"}
+ })
+
+ workflow =
+ [root, splitter, multiply, aggregator]
+ |> version([
+ connection(%{source_step_id: root.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: multiply.id}),
+ connection(%{source_step_id: multiply.id, target_step_id: aggregator.id})
+ ])
+ |> compile!()
+ |> elem(0)
+
+ {workflow, aggregator.id}
+ end
+
+ defp math_step(name, operation, value, operand) do
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "math",
+ name: name,
+ config: %{"operation" => operation, "value" => value, "operand" => operand}
+ })
+ end
+
+ defp assert_compile_error(version, message_fragment) do
+ assert {:error, errors} = Compiler.compile(version)
+ assert Enum.any?(errors, &String.contains?(&1.message, message_fragment))
+ end
+end
diff --git a/test/fizz/workflows/compiler_test.exs b/test/fizz/workflows/compiler_test.exs
new file mode 100644
index 0000000..3b00e93
--- /dev/null
+++ b/test/fizz/workflows/compiler_test.exs
@@ -0,0 +1,932 @@
+defmodule Fizz.Workflows.CompilerTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.Compiler
+ alias Fizz.Workflows.Compiler.Normalizer
+ alias Fizz.Workflows.Expressions.AccessPlan
+ alias Fizz.Workflows.RetryPolicy
+ alias Fizz.Workflows.WorkflowDefinitionVersion
+ alias Fizz.Workflows.Embeds.{Connection, Step, StepGroup}
+ alias Runic.Workflow
+
+ test "compile builds a valid Runic workflow for a simple two-step definition" do
+ version = simple_version()
+
+ assert {:ok, %Runic.Workflow{} = workflow, compiled_hash} = Compiler.compile(version)
+ assert is_binary(compiled_hash)
+ assert Map.has_key?(workflow.components, hd(version.steps).id)
+ assert Map.has_key?(workflow.components, List.last(version.steps).id)
+ assert workflow.fizz_metadata.compiler_version == Compiler.compiler_version()
+
+ productions =
+ workflow
+ |> Workflow.react_until_satisfied(%{"name" => "Ada"})
+ |> Workflow.raw_productions()
+
+ assert Enum.member?(productions, %{"name" => "Ada"})
+ end
+
+ test "ui-only edits produce the same compiled hash" do
+ version = simple_version()
+
+ ui_only_edited =
+ %{
+ version
+ | viewport: %{"x" => 999, "y" => 555, "zoom" => 2.0},
+ settings: %{"grid" => false}
+ }
+ |> put_in([Access.key!(:steps), Access.at(0), Access.key!(:position)], %{
+ "x" => 999,
+ "y" => 888
+ })
+ |> put_in([Access.key!(:steps), Access.at(0), Access.key!(:notes)], "editor note")
+ |> Map.put(:step_groups, [
+ %StepGroup{
+ id: Ecto.UUID.generate(),
+ name: "Group",
+ color: "#fff",
+ step_ids: Enum.map(version.steps, & &1.id)
+ }
+ ])
+
+ assert {:ok, _workflow, first_hash} = Compiler.compile(version)
+ assert {:ok, _workflow, second_hash} = Compiler.compile(ui_only_edited)
+ assert first_hash == second_hash
+ end
+
+ test "execution-relevant edits produce different compiled hashes" do
+ version = simple_version()
+
+ changed =
+ put_in(
+ version,
+ [Access.key!(:steps), Access.at(1), Access.key!(:config), Access.key("label")],
+ "Changed"
+ )
+
+ assert {:ok, _workflow, first_hash} = Compiler.compile(version)
+ assert {:ok, _workflow, second_hash} = Compiler.compile(changed)
+ refute first_hash == second_hash
+ end
+
+ test "step groups are excluded from the normalized ir" do
+ version =
+ %{
+ simple_version()
+ | step_groups: [
+ %StepGroup{
+ id: Ecto.UUID.generate(),
+ name: "Ignored",
+ color: "#000",
+ step_ids: Enum.map(simple_version().steps, & &1.id)
+ }
+ ]
+ }
+
+ assert {:ok, ir} = Normalizer.normalize(version)
+ refute Map.has_key?(ir, :step_groups)
+ refute Map.has_key?(ir, :viewport)
+ refute Map.has_key?(ir, :settings)
+ end
+
+ test "expressions are precompiled into access plans" do
+ version = expression_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ compiled_step = Workflow.get_component(workflow, List.last(version.steps).id)
+ compiled_config = compiled_step.closure.bindings[:compiled_config]
+
+ assert match?(%AccessPlan.ValueExpression{}, compiled_config["label"])
+ end
+
+ test "credential steps request runtime auth context" do
+ append_id = Ecto.UUID.generate()
+
+ version = %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: append_id,
+ type_id: "google_sheets_append_row",
+ name: "Append Row",
+ config: %{
+ "credential_ref" => credential_declaration("google_oauth", "oauth"),
+ "spreadsheet_id" => "sheet_123",
+ "values" => %{"A" => "1"}
+ },
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+
+ assert {:ok, ir} = Normalizer.normalize(version)
+ assert %RetryPolicy{max_attempts: 3} = ir.steps[append_id].retry
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ context_keys =
+ workflow
+ |> Workflow.get_component(append_id)
+ |> Map.fetch!(:meta_refs)
+ |> Enum.filter(&(&1.kind == :context))
+ |> Enum.map(& &1.context_key)
+
+ assert :current_scope in context_keys
+ assert :_credential_resolver in context_keys
+ assert :user_id in context_keys
+ assert :project_id in context_keys
+ assert :workos_organization_id in context_keys
+ end
+
+ test "splitter fan-out and aggregator fan-in compile into runnable map/reduce semantics" do
+ {version, ids} = map_reduce_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+ assert %Runic.Workflow.Map{} = Workflow.get_component(workflow, ids.splitter)
+ assert Workflow.get_component(workflow, ids.aggregator)
+
+ workflow =
+ workflow
+ |> Workflow.plan_eagerly(%{"items" => [1, 2, 3]})
+ |> Workflow.react_until_satisfied()
+
+ assert 12 in Workflow.raw_productions(workflow, ids.aggregator)
+ end
+
+ test "condition connections route by authored output handle" do
+ {version, ids} = condition_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ workflow =
+ workflow
+ |> Workflow.plan_eagerly(%{"flag" => true})
+ |> Workflow.react_until_satisfied()
+
+ assert [%{"flag" => true}] = Workflow.raw_productions(workflow, ids.true_step)
+ assert [] = Workflow.raw_productions(workflow, ids.false_step)
+ end
+
+ test "switch connections route by authored output handle" do
+ {version, ids} = switch_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ workflow =
+ workflow
+ |> Workflow.plan_eagerly(%{"status" => "active"})
+ |> Workflow.react_until_satisfied()
+
+ assert [%{"status" => "active"}] = Workflow.raw_productions(workflow, ids.active_step)
+ assert [] = Workflow.raw_productions(workflow, ids.pending_step)
+ assert [] = Workflow.raw_productions(workflow, ids.default_step)
+ end
+
+ test "multiple switch cases targeting the same output handle behave as a union, not a join" do
+ {version, ids} = switch_union_version()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+
+ workflow =
+ workflow
+ |> Workflow.plan_eagerly(%{"status" => "pending"})
+ |> Workflow.react_until_satisfied()
+
+ assert [%{"status" => "pending"}] = Workflow.raw_productions(workflow, ids.matched_step)
+ end
+
+ test "steps assemble connected dependencies into runnable input payloads" do
+ {version, ids} = ai_agent_version()
+ expected_schema = ai_agent_response_schema()
+
+ assert {:ok, workflow, _compiled_hash} = Compiler.compile(version)
+ assert {:ok, context_keys} = Map.fetch(Workflow.required_context_keys(workflow), ids.agent)
+ assert {:current_scope, :required} in context_keys
+
+ workflow =
+ workflow
+ |> Workflow.plan_eagerly(%{"name" => "Ada Lovelace", "topic" => "algebra"})
+ |> Workflow.react_until_satisfied()
+
+ [output] = Workflow.raw_productions(workflow, ids.agent)
+
+ assert output["main"] == %{"name" => "Ada Lovelace", "topic" => "algebra"}
+
+ assert %{
+ "kind" => "ai.chat_model",
+ "provider" => "openai_api_key",
+ "credential_ref" => %{"provider" => "openai_api_key"},
+ "model_spec" => "openai:gpt-5.5",
+ "temperature" => 0.3,
+ "max_tokens" => 300,
+ "capabilities" => ["chat", "structured_output", "tools"]
+ } = output["model"]
+
+ assert output["messages"] == [
+ %{"role" => "system", "content" => "Solve carefully."},
+ %{"role" => "user", "content" => "Hello Ada Lovelace"}
+ ]
+
+ assert output["structured_schema"] == %{
+ "kind" => "ai.schema",
+ "name" => "agent_response",
+ "schema" => expected_schema,
+ "strict" => true,
+ "response_format" => %{
+ "type" => "json_schema",
+ "json_schema" => %{
+ "name" => "agent_response",
+ "schema" => expected_schema,
+ "strict" => true
+ }
+ }
+ }
+
+ assert output["response_format"] == %{
+ "type" => "json_schema",
+ "json_schema" => %{
+ "name" => "agent_response",
+ "schema" => expected_schema,
+ "strict" => true
+ }
+ }
+
+ assert output["tools"] == [
+ %{
+ "kind" => "ai.tool",
+ "type" => "http",
+ "name" => "lookup_user",
+ "description" => "",
+ "request" => %{
+ "method" => "GET",
+ "url" => "https://example.com/users/ada-lovelace",
+ "headers" => %{}
+ }
+ }
+ ]
+ end
+
+ test "compile rejects steps missing required dependency inputs" do
+ version = missing_required_dependency_input_version()
+
+ assert {:error, [%{message: message}]} = Compiler.compile(version)
+ assert message =~ "missing required dependency input `model`"
+ end
+
+ test "compile rejects dependencies whose provides metadata does not match the target input" do
+ version = invalid_dependency_input_capability_version()
+
+ assert {:error, [%{message: message}]} = Compiler.compile(version)
+ assert message =~ "dependency input `model`"
+ assert message =~ "got `ai_tool_http` with provides [ai.tool]"
+ end
+
+ test "dependency-capable nodes can run as regular standalone nodes" do
+ version = unattached_subnode_version()
+
+ assert {:ok, %Runic.Workflow{} = workflow, _compiled_hash} = Compiler.compile(version)
+ assert Workflow.get_component(workflow, Enum.at(version.steps, 2).id)
+ end
+
+ defp simple_version do
+ entry_id = Ecto.UUID.generate()
+ debug_id = Ecto.UUID.generate()
+
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: debug_id,
+ type_id: "debug",
+ name: "Debug",
+ config: %{"label" => "Hello"},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: debug_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{"x" => 0, "y" => 0, "zoom" => 1.0},
+ settings: %{}
+ }
+ end
+
+ defp expression_version do
+ version = simple_version()
+
+ put_in(
+ version,
+ [Access.key!(:steps), Access.at(1), Access.key!(:config), Access.key("label")],
+ "{{ input.name }}"
+ )
+ end
+
+ defp map_reduce_version do
+ entry_id = Ecto.UUID.generate()
+ splitter_id = Ecto.UUID.generate()
+ math_id = Ecto.UUID.generate()
+ aggregator_id = Ecto.UUID.generate()
+
+ version = %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: splitter_id,
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "items"},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: math_id,
+ type_id: "math",
+ name: "Multiply",
+ config: %{"operation" => "multiply", "value" => "{{ input }}", "operand" => 2},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: aggregator_id,
+ type_id: "aggregator",
+ name: "Sum",
+ config: %{"operation" => "sum"},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: splitter_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: splitter_id,
+ source_output: "main",
+ target_step_id: math_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: math_id,
+ source_output: "main",
+ target_step_id: aggregator_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+
+ {version, %{splitter: splitter_id, aggregator: aggregator_id}}
+ end
+
+ defp condition_version do
+ entry_id = Ecto.UUID.generate()
+ condition_id = Ecto.UUID.generate()
+ true_id = Ecto.UUID.generate()
+ false_id = Ecto.UUID.generate()
+
+ version = %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: condition_id,
+ type_id: "condition",
+ name: "Check Flag",
+ config: %{
+ "condition" => "{{ input.flag }}",
+ "true_output" => "yes",
+ "false_output" => "no"
+ },
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: true_id,
+ type_id: "debug",
+ name: "True Branch",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: false_id,
+ type_id: "debug",
+ name: "False Branch",
+ config: %{},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: condition_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: condition_id,
+ source_output: "yes",
+ target_step_id: true_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: condition_id,
+ source_output: "no",
+ target_step_id: false_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+
+ {version, %{true_step: true_id, false_step: false_id}}
+ end
+
+ defp switch_version do
+ entry_id = Ecto.UUID.generate()
+ switch_id = Ecto.UUID.generate()
+ active_id = Ecto.UUID.generate()
+ pending_id = Ecto.UUID.generate()
+ default_id = Ecto.UUID.generate()
+
+ version = %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: switch_id,
+ type_id: "switch",
+ name: "Route Status",
+ config: %{
+ "value" => "{{ input.status }}",
+ "cases" => [
+ %{"match" => "active", "output" => "active"},
+ %{"match" => "pending", "output" => "pending"}
+ ],
+ "default_output" => "other"
+ },
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: active_id,
+ type_id: "debug",
+ name: "Active",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: pending_id,
+ type_id: "debug",
+ name: "Pending",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: default_id,
+ type_id: "debug",
+ name: "Default",
+ config: %{},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: switch_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: switch_id,
+ source_output: "active",
+ target_step_id: active_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: switch_id,
+ source_output: "pending",
+ target_step_id: pending_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: switch_id,
+ source_output: "other",
+ target_step_id: default_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+
+ {version, %{active_step: active_id, pending_step: pending_id, default_step: default_id}}
+ end
+
+ defp switch_union_version do
+ entry_id = Ecto.UUID.generate()
+ switch_id = Ecto.UUID.generate()
+ matched_id = Ecto.UUID.generate()
+
+ version = %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: switch_id,
+ type_id: "switch",
+ name: "Route Status",
+ config: %{
+ "value" => "{{ input.status }}",
+ "cases" => [
+ %{"match" => "active", "output" => "matched"},
+ %{"match" => "pending", "output" => "matched"}
+ ],
+ "default_output" => "other"
+ },
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: matched_id,
+ type_id: "debug",
+ name: "Matched",
+ config: %{},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: switch_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: switch_id,
+ source_output: "matched",
+ target_step_id: matched_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+
+ {version, %{matched_step: matched_id}}
+ end
+
+ defp ai_agent_version do
+ entry_id = Ecto.UUID.generate()
+ agent_id = Ecto.UUID.generate()
+ model_id = Ecto.UUID.generate()
+ schema_id = Ecto.UUID.generate()
+ tool_id = Ecto.UUID.generate()
+
+ version = %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: agent_id,
+ type_id: "ai_agent",
+ name: "Agent",
+ config: %{
+ "mode" => "assemble_only",
+ "system_prompt" => "Solve carefully.",
+ "user_message" => "Hello {{ input.name }}"
+ },
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: model_id,
+ type_id: "openai_model",
+ name: "Model",
+ config: %{
+ "credential_ref" => credential_ref("openai_api_key"),
+ "model" => "gpt-5.5",
+ "temperature" => 0.3,
+ "max_tokens" => 300
+ },
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: schema_id,
+ type_id: "ai_structure_schema",
+ name: "Structure Schema",
+ config: %{
+ "name" => "agent_response",
+ "json_schema" => ai_agent_response_schema(),
+ "strict" => true
+ },
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: tool_id,
+ type_id: "ai_tool_http",
+ name: "Tool",
+ config: %{
+ "name" => "lookup_user",
+ "method" => "GET",
+ "url" => "https://example.com/users/{{ input.name | slugify }}"
+ },
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: model_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "model"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: schema_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "structured_schema"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: tool_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "tools"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+
+ {version, %{agent: agent_id}}
+ end
+
+ defp missing_required_dependency_input_version do
+ entry_id = Ecto.UUID.generate()
+ agent_id = Ecto.UUID.generate()
+
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: agent_id,
+ type_id: "ai_agent",
+ name: "Agent",
+ config: %{"user_message" => "{{ json }}"},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+
+ defp invalid_dependency_input_capability_version do
+ entry_id = Ecto.UUID.generate()
+ agent_id = Ecto.UUID.generate()
+ tool_id = Ecto.UUID.generate()
+
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: agent_id,
+ type_id: "ai_agent",
+ name: "Agent",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: tool_id,
+ type_id: "ai_tool_http",
+ name: "Tool",
+ config: %{"name" => "wrong_slot", "url" => "https://example.com"},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "main"
+ },
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: tool_id,
+ source_output: "main",
+ target_step_id: agent_id,
+ target_input: "model"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+
+ defp unattached_subnode_version do
+ entry_id = Ecto.UUID.generate()
+ model_id = Ecto.UUID.generate()
+ debug_id = Ecto.UUID.generate()
+
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: [
+ %Step{
+ id: entry_id,
+ type_id: "manual_input",
+ name: "Entry",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: debug_id,
+ type_id: "debug",
+ name: "Debug",
+ config: %{},
+ position: %{},
+ notes: nil
+ },
+ %Step{
+ id: model_id,
+ type_id: "openai_model",
+ name: "Model",
+ config: %{"credential_ref" => credential_ref("openai_api_key")},
+ position: %{},
+ notes: nil
+ }
+ ],
+ connections: [
+ %Connection{
+ id: Ecto.UUID.generate(),
+ source_step_id: entry_id,
+ source_output: "main",
+ target_step_id: debug_id,
+ target_input: "main"
+ }
+ ],
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+
+ defp credential_ref(provider) do
+ %{
+ "id" => Ecto.UUID.generate(),
+ "provider" => provider,
+ "auth_type" => "api_key",
+ "owner_user_id" => "user_123"
+ }
+ end
+
+ defp credential_declaration(provider, auth_type) do
+ %{
+ "$credential" => true,
+ "requirement_key" => "auth",
+ "provider" => provider,
+ "auth_type" => auth_type
+ }
+ end
+
+ defp ai_agent_response_schema do
+ %{
+ "type" => "object",
+ "additionalProperties" => false,
+ "properties" => %{
+ "answer" => %{"type" => "string"}
+ },
+ "required" => ["answer"]
+ }
+ end
+end
diff --git a/test/fizz/workflows/draft_session_test.exs b/test/fizz/workflows/draft_session_test.exs
new file mode 100644
index 0000000..1395c92
--- /dev/null
+++ b/test/fizz/workflows/draft_session_test.exs
@@ -0,0 +1,1126 @@
+defmodule Fizz.Workflows.DraftSessionTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.AccountsFixtures
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Accounts.Scope
+ alias Fizz.Workflows
+ alias Fizz.Workflows.DraftSession
+
+ setup do
+ previous_env = Application.get_env(:fizz, DraftSession, [])
+ Application.put_env(:fizz, DraftSession, persist_debounce_ms: 25, idle_timeout_ms: 75)
+
+ on_exit(fn ->
+ Application.put_env(:fizz, DraftSession, previous_env)
+ end)
+
+ :ok
+ end
+
+ test "starting a session loads draft from DB" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, base_snapshot_attrs())
+
+ subscribe_draft(draft.id)
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert Enum.map(joined_draft.steps, & &1.id) == Enum.map(draft.steps, & &1.id)
+ assert Enum.map(joined_draft.connections, & &1.id) == Enum.map(draft.connections, & &1.id)
+ assert Enum.map(joined_draft.step_groups, & &1.id) == Enum.map(draft.step_groups, & &1.id)
+
+ assert undo_state == %{
+ canUndo: false,
+ canRedo: false,
+ undoLabel: nil,
+ redoLabel: nil,
+ undoStack: [],
+ redoStack: []
+ }
+
+ assert session_pid(draft.id)
+ refute_received {:draft_updated, _seq, _summary}
+ end
+
+ test "step operations return updated draft" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, connected_snapshot_attrs())
+ original_step_ids = MapSet.new(Enum.map(draft.steps, & &1.id))
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, draft_after_add, 1, undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 420, y: 180}}
+ })
+
+ added_step = Enum.find(draft_after_add.steps, &(not MapSet.member?(original_step_ids, &1.id)))
+ assert length(draft_after_add.steps) == 3
+ assert added_step.position == %{"x" => 420, "y" => 180}
+ assert undo_state_after_add.undoLabel == "Add Step"
+
+ assert {:ok, draft_after_remove, 2, _undo_state_after_remove} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :remove_step,
+ params: %{step_id: added_step.id}
+ })
+
+ assert length(draft_after_remove.steps) == 2
+ refute Enum.any?(draft_after_remove.steps, &(&1.id == added_step.id))
+
+ first_step = Enum.at(draft_after_remove.steps, 0)
+ second_step = Enum.at(draft_after_remove.steps, 1)
+
+ assert {:ok, draft_after_update, 3, _undo_state_after_update} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :update_step,
+ params: %{
+ step_id: first_step.id,
+ changes: %{name: "Renamed Entry", notes: "Updated"}
+ }
+ })
+
+ updated_first_step = Enum.find(draft_after_update.steps, &(&1.id == first_step.id))
+ assert updated_first_step.name == "Renamed Entry"
+ assert updated_first_step.notes == "Updated"
+
+ assert {:ok, draft_after_move, 4, _undo_state_after_move} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :move_step,
+ params: %{step_id: first_step.id, position: %{x: 300, y: 240}}
+ })
+
+ moved_first_step = Enum.find(draft_after_move.steps, &(&1.id == first_step.id))
+ assert moved_first_step.position == %{"x" => 300, "y" => 240}
+
+ assert {:ok, draft_after_batch_move, 5, _undo_state_after_batch_move} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :move_steps,
+ params: %{
+ step_positions: %{
+ first_step.id => %{x: 30, y: 40},
+ second_step.id => %{x: 90, y: 110}
+ }
+ }
+ })
+
+ assert Enum.find(draft_after_batch_move.steps, &(&1.id == first_step.id)).position ==
+ %{"x" => 30, "y" => 40}
+
+ assert Enum.find(draft_after_batch_move.steps, &(&1.id == second_step.id)).position ==
+ %{"x" => 90, "y" => 110}
+ end
+
+ test "connection operations return updated draft" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, base_snapshot_attrs())
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ source_step = Enum.at(joined_draft.steps, 0)
+ target_step = Enum.at(joined_draft.steps, 1)
+
+ assert {:error, {:unknown_target_input, "secondary"}} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_connection,
+ params: %{
+ source_step_id: source_step.id,
+ target_step_id: target_step.id,
+ source_output: "main",
+ target_input: "secondary"
+ }
+ })
+
+ assert {:ok, draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_connection,
+ params: %{
+ source_step_id: target_step.id,
+ target_step_id: source_step.id,
+ source_output: "main",
+ target_input: "main"
+ }
+ })
+
+ assert length(draft_after_add.connections) == 2
+
+ added_connection =
+ Enum.find(draft_after_add.connections, fn connection ->
+ connection.id not in Enum.map(joined_draft.connections, & &1.id)
+ end)
+
+ assert added_connection.target_input == "main"
+
+ assert {:ok, draft_after_remove, 2, _undo_state_after_remove} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :remove_connection,
+ params: %{connection_id: added_connection.id}
+ })
+
+ assert length(draft_after_remove.connections) == 1
+ refute Enum.any?(draft_after_remove.connections, &(&1.id == added_connection.id))
+ end
+
+ test "group operations return updated draft" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, base_snapshot_attrs())
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ first_step = Enum.at(joined_draft.steps, 0)
+ second_step = Enum.at(joined_draft.steps, 1)
+
+ assert {:ok, draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_group,
+ params: %{
+ name: "Primary Group",
+ step_ids: [first_step.id, second_step.id],
+ position: %{x: 50, y: 60, width: 300, height: 200},
+ color: "#0f172a",
+ font_size: 16,
+ step_positions: %{
+ first_step.id => %{x: 10, y: 20},
+ second_step.id => %{x: 120, y: 90}
+ }
+ }
+ })
+
+ assert length(draft_after_add.step_groups) == 1
+ group = hd(draft_after_add.step_groups)
+ assert Enum.sort(group.step_ids) == Enum.sort([first_step.id, second_step.id])
+ assert group.position == %{"x" => 50, "y" => 60, "width" => 300, "height" => 200}
+
+ assert Enum.find(draft_after_add.steps, &(&1.id == first_step.id)).position == %{
+ "x" => 10,
+ "y" => 20
+ }
+
+ assert {:ok, draft_after_update, 2, _undo_state_after_update} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :update_group,
+ params: %{
+ group_id: group.id,
+ changes: %{
+ name: "Renamed Group",
+ collapsed: true,
+ color: "#1d4ed8",
+ font_size: 18,
+ position: %{x: 80, y: 90}
+ }
+ }
+ })
+
+ updated_group = hd(draft_after_update.step_groups)
+ assert updated_group.name == "Renamed Group"
+ assert updated_group.collapsed
+ assert updated_group.color == "#1d4ed8"
+ assert updated_group.font_size == 18
+ assert updated_group.position == %{"x" => 80, "y" => 90, "width" => 300, "height" => 200}
+
+ assert {:ok, draft_after_remove, 3, _undo_state_after_remove} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :remove_group,
+ params: %{group_id: group.id}
+ })
+
+ assert draft_after_remove.step_groups == []
+
+ assert Enum.find(draft_after_remove.steps, &(&1.id == first_step.id)).position ==
+ %{"x" => 90, "y" => 110}
+
+ assert Enum.find(draft_after_remove.steps, &(&1.id == second_step.id)).position ==
+ %{"x" => 200, "y" => 180}
+ end
+
+ test "membership and layout operations return updated draft" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, grouped_snapshot_attrs())
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ group_one = Enum.at(joined_draft.step_groups, 0)
+ group_two = Enum.at(joined_draft.step_groups, 1)
+ first_step = Enum.at(joined_draft.steps, 0)
+ second_step = Enum.at(joined_draft.steps, 1)
+ third_step = Enum.at(joined_draft.steps, 2)
+
+ assert {:ok, draft_after_membership, 1, _undo_state_after_membership} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :set_group_membership,
+ params: %{
+ step_ids: [third_step.id],
+ group_id: group_one.id,
+ step_positions: %{third_step.id => %{x: 45, y: 55}}
+ }
+ })
+
+ updated_group_one = Enum.find(draft_after_membership.step_groups, &(&1.id == group_one.id))
+ assert Enum.sort(updated_group_one.step_ids) == Enum.sort([first_step.id, third_step.id])
+
+ assert Enum.find(draft_after_membership.steps, &(&1.id == third_step.id)).position ==
+ %{"x" => 45, "y" => 55}
+
+ assert {:ok, draft_after_commit, 2, _undo_state_after_commit} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :commit_drag_layout,
+ params: %{
+ txn_id: "txn_commit",
+ groups: [
+ %{
+ group_id: group_one.id,
+ position: %{x: 120, y: 130, width: 210, height: 220}
+ }
+ ],
+ step_positions: %{
+ first_step.id => %{x: 15, y: 25},
+ second_step.id => %{x: 75, y: 65}
+ },
+ group_id_by_step_id: %{second_step.id => group_one.id}
+ }
+ })
+
+ committed_group_one = Enum.find(draft_after_commit.step_groups, &(&1.id == group_one.id))
+ committed_group_two = Enum.find(draft_after_commit.step_groups, &(&1.id == group_two.id))
+
+ assert committed_group_one.position == %{
+ "x" => 120,
+ "y" => 130,
+ "width" => 210,
+ "height" => 220
+ }
+
+ assert Enum.sort(committed_group_one.step_ids) ==
+ Enum.sort([first_step.id, second_step.id, third_step.id])
+
+ assert committed_group_two.step_ids == []
+
+ assert Enum.find(draft_after_commit.steps, &(&1.id == second_step.id)).position ==
+ %{"x" => 75, "y" => 65}
+
+ assert {:ok, draft_after_tidy, 3, undo_state_after_tidy} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :tidy_layout,
+ params: %{
+ steps: [%{step_id: first_step.id, position: %{x: 5, y: 5}}],
+ groups: [
+ %{
+ group_id: group_one.id,
+ position: %{x: 150, y: 160, width: 240, height: 250}
+ }
+ ],
+ label: "Tidy Up Workflow"
+ }
+ })
+
+ assert Enum.find(draft_after_tidy.steps, &(&1.id == first_step.id)).position ==
+ %{"x" => 5, "y" => 5}
+
+ assert Enum.find(draft_after_tidy.step_groups, &(&1.id == group_one.id)).position ==
+ %{"x" => 150, "y" => 160, "width" => 240, "height" => 250}
+
+ assert undo_state_after_tidy.undoLabel == "Tidy Up Workflow"
+ end
+
+ test "duplicate_steps returns updated draft" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, connected_grouped_snapshot_attrs())
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ first_step = Enum.at(joined_draft.steps, 0)
+ second_step = Enum.at(joined_draft.steps, 1)
+ group = hd(joined_draft.step_groups)
+
+ assert {:ok, draft_after_duplicate, 1, undo_state_after_duplicate} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :duplicate_steps,
+ params: %{
+ step_ids: [first_step.id, second_step.id],
+ position_by_step_id: %{
+ first_step.id => %{x: 150, y: 25},
+ second_step.id => %{x: 260, y: 110}
+ },
+ group_id_by_step_id: %{
+ first_step.id => group.id,
+ second_step.id => group.id
+ }
+ }
+ })
+
+ assert length(draft_after_duplicate.steps) == 4
+ assert length(draft_after_duplicate.connections) == 2
+ assert undo_state_after_duplicate.undoLabel == "Duplicate Steps"
+
+ new_steps =
+ Enum.reject(draft_after_duplicate.steps, fn step ->
+ step.id in [first_step.id, second_step.id]
+ end)
+
+ new_step_ids = Enum.map(new_steps, & &1.id)
+ assert Enum.sort(hd(draft_after_duplicate.step_groups).step_ids) |> length() == 4
+ assert Enum.find(new_steps, &(&1.position == %{"x" => 150, "y" => 25}))
+ assert Enum.find(new_steps, &(&1.position == %{"x" => 260, "y" => 110}))
+
+ duplicated_connection =
+ Enum.find(draft_after_duplicate.connections, fn connection ->
+ connection.id not in Enum.map(joined_draft.connections, & &1.id)
+ end)
+
+ assert duplicated_connection.source_step_id in new_step_ids
+ assert duplicated_connection.target_step_id in new_step_ids
+ end
+
+ test "undo reverses the last operation" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ added_step = hd(draft_after_add.steps)
+
+ assert {:ok, draft_after_undo, 2, undo_state_after_undo} =
+ DraftSession.undo(draft.id, scope.user.id)
+
+ assert draft_after_undo.steps == []
+ refute Enum.any?(draft_after_undo.steps, &(&1.id == added_step.id))
+ assert undo_state_after_undo.canUndo == false
+ assert undo_state_after_undo.canRedo == true
+ assert undo_state_after_undo.redoLabel == "Add Step"
+ end
+
+ test "redo reapplies after undo" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, _draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ assert {:ok, _draft_after_undo, 2, _undo_state_after_undo} =
+ DraftSession.undo(draft.id, scope.user.id)
+
+ assert {:ok, draft_after_redo, 3, undo_state_after_redo} =
+ DraftSession.redo(draft.id, scope.user.id)
+
+ assert length(draft_after_redo.steps) == 1
+ assert undo_state_after_redo.canUndo == true
+ assert undo_state_after_redo.canRedo == false
+ assert undo_state_after_redo.undoLabel == "Add Step"
+ end
+
+ test "undo state exposes revision summaries and preview_revision replays undo depth" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, first_draft, 1, _undo_state_after_first_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ first_added_step = hd(first_draft.steps)
+
+ assert {:ok, second_draft, 2, undo_state_after_second_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 40, y: 50}}
+ })
+
+ second_added_step =
+ Enum.find(second_draft.steps, fn step -> step.id != first_added_step.id end)
+
+ [latest_revision, previous_revision] = undo_state_after_second_add.undoStack
+
+ assert latest_revision.depth == 1
+ assert latest_revision.label == "Add Step"
+ assert is_binary(latest_revision.id)
+ assert is_binary(latest_revision.timestamp)
+
+ assert previous_revision.depth == 2
+ assert previous_revision.label == "Add Step"
+
+ assert {:ok, preview_after_one_undo} =
+ DraftSession.preview_revision(draft.id, scope.user.id, {:undo, 1})
+
+ assert length(preview_after_one_undo.steps) == 1
+ refute Enum.any?(preview_after_one_undo.steps, &(&1.id == second_added_step.id))
+
+ assert {:ok, preview_after_two_undos} =
+ DraftSession.preview_revision(draft.id, scope.user.id, {:undo, 2})
+
+ assert preview_after_two_undos.steps == []
+ refute Enum.any?(preview_after_two_undos.steps, &(&1.id == first_added_step.id))
+ end
+
+ test "undo history is capped by configured depth" do
+ previous_env = Application.get_env(:fizz, DraftSession, [])
+ Application.put_env(:fizz, DraftSession, Keyword.merge(previous_env, history_limit: 2))
+
+ on_exit(fn ->
+ Application.put_env(:fizz, DraftSession, previous_env)
+ end)
+
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ for index <- 1..3 do
+ assert {:ok, _draft, ^index, _undo_state} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: index * 20, y: index * 20}}
+ })
+ end
+
+ assert {:ok, undo_state} = DraftSession.get_undo_state(draft.id, scope.user.id)
+ assert Enum.map(undo_state.undoStack, & &1.depth) == [1, 2]
+
+ assert {:error, :revision_not_found} =
+ DraftSession.preview_revision(draft.id, scope.user.id, {:undo, 3})
+ end
+
+ test "restore_snapshot applies a revision as a single undoable operation" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ snapshot = %{
+ steps: [
+ step(%{
+ name: "Restored Step",
+ position: %{"x" => 200, "y" => 220}
+ })
+ ],
+ connections: [],
+ step_groups: [],
+ viewport: %{"x" => 12, "y" => 24, "zoom" => 1.25},
+ settings: %{"mode" => "restored"}
+ }
+
+ assert {:ok, restored_draft, 1, undo_state_after_restore} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :restore_snapshot,
+ params: %{snapshot: snapshot, label: "Apply v1"}
+ })
+
+ assert Enum.map(restored_draft.steps, & &1.name) == ["Restored Step"]
+ assert restored_draft.viewport == %{"x" => 12, "y" => 24, "zoom" => 1.25}
+ assert restored_draft.settings == %{"mode" => "restored"}
+ assert undo_state_after_restore.undoLabel == "Apply v1"
+
+ assert {:ok, reverted_draft, 2, undo_state_after_undo} =
+ DraftSession.undo(draft.id, scope.user.id)
+
+ assert reverted_draft.steps == []
+ assert reverted_draft.viewport == %{"x" => 0, "y" => 0, "zoom" => 1.0}
+ assert reverted_draft.settings == %{}
+ assert undo_state_after_undo.redoLabel == "Apply v1"
+ end
+
+ test "undo conflict pops stack and rejects" do
+ scope = project_scope_fixture()
+ second_scope = secondary_scope(scope)
+ %{draft: draft} = draft_fixture(scope)
+ first_user_id = scope.user.id
+
+ subscribe_draft(draft.id)
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, second_scope, second_scope.user.id)
+
+ assert {:ok, draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ added_step = hd(draft_after_add.steps)
+
+ assert {:ok, _draft_after_remove, 2, _undo_state_after_remove} =
+ DraftSession.apply_operation(draft.id, second_scope.user.id, %{
+ type: :remove_step,
+ params: %{step_id: added_step.id}
+ })
+
+ assert {:error, :step_not_found} = DraftSession.undo(draft.id, first_user_id)
+ assert_receive {:undo_rejected, ^first_user_id, :step_not_found}
+
+ assert {:ok, undo_state} = DraftSession.get_undo_state(draft.id, first_user_id)
+
+ assert undo_state == %{
+ canUndo: false,
+ canRedo: false,
+ undoLabel: nil,
+ redoLabel: nil,
+ undoStack: [],
+ redoStack: []
+ }
+ end
+
+ test "new operation clears redo stack" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, _draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ assert {:ok, _draft_after_undo, 2, undo_state_after_undo} =
+ DraftSession.undo(draft.id, scope.user.id)
+
+ assert undo_state_after_undo.canRedo
+
+ assert {:ok, _draft_after_second_add, 3, undo_state_after_second_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 40, y: 50}}
+ })
+
+ assert undo_state_after_second_add.canUndo
+ assert undo_state_after_second_add.canRedo == false
+ assert undo_state_after_second_add.redoLabel == nil
+ end
+
+ test "periodic persist writes to DB when dirty" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+ user_id = scope.user.id
+
+ subscribe_draft(draft.id)
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, user_id)
+
+ assert {:ok, _draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, user_id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ assert_receive {:save_status, %{status: :saving, error: nil}}
+ assert_receive {:draft_updated, 1, %{type: :add_step, user_id: ^user_id}}
+ assert_receive {:draft_persisted, 1, %DateTime{}}
+ assert_receive {:save_status, %{status: :saved, error: nil}}
+
+ assert {:ok, persisted_draft} = Workflows.get_version(scope, draft.id)
+ assert length(persisted_draft.steps) == 1
+ end
+
+ test "persistence state reports saving while debounce is pending" do
+ previous_env = Application.get_env(:fizz, DraftSession, [])
+
+ Application.put_env(:fizz, DraftSession,
+ persist_debounce_ms: 500,
+ idle_timeout_ms: 75,
+ persist_retry_base_ms: 25,
+ persist_retry_max_ms: 50
+ )
+
+ on_exit(fn ->
+ Application.put_env(:fizz, DraftSession, previous_env)
+ end)
+
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, _draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ assert {:ok, %{status: :saving, error: nil}} = DraftSession.get_persistence_state(draft.id)
+ end
+
+ test "idle timeout shuts down after last user leaves" do
+ previous_env = Application.get_env(:fizz, DraftSession, [])
+ Application.put_env(:fizz, DraftSession, persist_debounce_ms: 500, idle_timeout_ms: 60)
+
+ on_exit(fn ->
+ Application.put_env(:fizz, DraftSession, previous_env)
+ end)
+
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope)
+
+ subscribe_draft(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ pid = session_pid(draft.id)
+ ref = Process.monitor(pid)
+
+ assert {:ok, _draft_after_add, 1, _undo_state_after_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ assert :ok = DraftSession.leave(draft.id, scope.user.id)
+ assert_receive {:draft_persisted, 1, %DateTime{}}
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
+ assert {:ok, persisted_draft} = Workflows.get_version(scope, draft.id)
+ assert length(persisted_draft.steps) == 1
+ end
+
+ test "multiple users have independent undo stacks" do
+ scope = project_scope_fixture()
+ second_scope = secondary_scope(scope)
+ %{draft: draft} = draft_fixture(scope)
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, second_scope, second_scope.user.id)
+
+ assert {:ok, draft_after_first_add, 1, _undo_state_after_first_add} =
+ DraftSession.apply_operation(draft.id, scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 10, y: 20}}
+ })
+
+ first_step = hd(draft_after_first_add.steps)
+
+ assert {:ok, _draft_after_second_add, 2, _undo_state_after_second_add} =
+ DraftSession.apply_operation(draft.id, second_scope.user.id, %{
+ type: :add_step,
+ params: %{type_id: "debug", position: %{x: 80, y: 90}}
+ })
+
+ assert {:ok, first_user_undo_state} = DraftSession.get_undo_state(draft.id, scope.user.id)
+
+ assert {:ok, second_user_undo_state} =
+ DraftSession.get_undo_state(draft.id, second_scope.user.id)
+
+ assert first_user_undo_state.canUndo
+ assert second_user_undo_state.canUndo
+
+ assert {:ok, draft_after_first_undo, 3, _undo_state_after_first_undo} =
+ DraftSession.undo(draft.id, scope.user.id)
+
+ refute Enum.any?(draft_after_first_undo.steps, &(&1.id == first_step.id))
+ assert length(draft_after_first_undo.steps) == 1
+
+ assert {:ok, second_user_undo_state_after_first_undo} =
+ DraftSession.get_undo_state(draft.id, second_scope.user.id)
+
+ assert second_user_undo_state_after_first_undo.canUndo
+ end
+
+ test "overlapping commit_drag_layout operations converge by applied seq order" do
+ scope = project_scope_fixture()
+ second_scope = secondary_scope(scope)
+ %{draft: draft} = draft_fixture(scope, grouped_snapshot_attrs())
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, second_scope, second_scope.user.id)
+
+ group = Enum.at(joined_draft.step_groups, 0)
+ step = Enum.at(joined_draft.steps, 0)
+
+ first_payload = %{
+ type: :commit_drag_layout,
+ params: %{
+ txn_id: "txn_commit_one",
+ groups: [
+ %{
+ group_id: group.id,
+ position: %{x: 120, y: 140, width: 420, height: 300}
+ }
+ ],
+ step_positions: %{
+ step.id => %{x: 20, y: 25}
+ },
+ group_id_by_step_id: %{}
+ }
+ }
+
+ second_payload = %{
+ type: :commit_drag_layout,
+ params: %{
+ txn_id: "txn_commit_two",
+ groups: [
+ %{
+ group_id: group.id,
+ position: %{x: 180, y: 220, width: 460, height: 340}
+ }
+ ],
+ step_positions: %{
+ step.id => %{x: 65, y: 70}
+ },
+ group_id_by_step_id: %{}
+ }
+ }
+
+ task_one =
+ Task.async(fn ->
+ DraftSession.apply_operation(draft.id, scope.user.id, first_payload)
+ end)
+
+ task_two =
+ Task.async(fn ->
+ DraftSession.apply_operation(draft.id, second_scope.user.id, second_payload)
+ end)
+
+ assert {:ok, _draft_after_first, seq_one, _undo_state_after_first} = Task.await(task_one)
+ assert {:ok, _draft_after_second, seq_two, _undo_state_after_second} = Task.await(task_two)
+ assert seq_one != seq_two
+
+ {expected_group_position, expected_step_position} =
+ if seq_one > seq_two do
+ {%{"x" => 120, "y" => 140, "width" => 420, "height" => 300}, %{"x" => 20, "y" => 25}}
+ else
+ {%{"x" => 180, "y" => 220, "width" => 460, "height" => 340}, %{"x" => 65, "y" => 70}}
+ end
+
+ assert {:ok, current_draft, current_seq, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ current_group = Enum.find(current_draft.step_groups, &(&1.id == group.id))
+ current_step = Enum.find(current_draft.steps, &(&1.id == step.id))
+
+ assert current_seq == max(seq_one, seq_two)
+ assert current_group.position == expected_group_position
+ assert current_step.position == expected_step_position
+ end
+
+ test "operation rejection does not modify state or increment seq" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, base_snapshot_attrs())
+ user_id = scope.user.id
+
+ subscribe_draft(draft.id)
+ register_session_cleanup(draft.id)
+
+ assert {:ok, joined_draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, user_id)
+
+ first_step = hd(joined_draft.steps)
+
+ assert {:error, :self_connection} =
+ DraftSession.apply_operation(draft.id, user_id, %{
+ type: :add_connection,
+ params: %{
+ source_step_id: first_step.id,
+ target_step_id: first_step.id
+ }
+ })
+
+ assert_receive {:operation_rejected, ^user_id, :self_connection}
+ refute_receive {:draft_updated, _seq, _summary}
+
+ assert {:ok, current_draft, current_seq, undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, user_id)
+
+ assert current_seq == 0
+ assert current_draft.steps == joined_draft.steps
+ assert current_draft.connections == joined_draft.connections
+
+ assert undo_state == %{
+ canUndo: false,
+ canRedo: false,
+ undoLabel: nil,
+ redoLabel: nil,
+ undoStack: [],
+ redoStack: []
+ }
+ end
+
+ test "editor_state is shared across users and survives reconnection" do
+ scope = project_scope_fixture()
+ second_scope = secondary_scope(scope)
+ %{draft: draft} = draft_fixture(scope, base_snapshot_attrs())
+ first_step_id = hd(draft.steps).id
+
+ subscribe_draft(draft.id)
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert editor_state == %{pinned_outputs: %{}, disabled_steps: [], step_locks: %{}}
+
+ # Pin an output
+ assert {:ok, editor_state} =
+ DraftSession.pin_output(draft.id, first_step_id, %{"result" => 42})
+
+ assert editor_state.pinned_outputs == %{first_step_id => %{"result" => 42}}
+ assert_receive {:editor_state_changed, ^editor_state}
+
+ # Disable a step
+ assert {:ok, editor_state} = DraftSession.disable_step(draft.id, first_step_id)
+ assert first_step_id in editor_state.disabled_steps
+ assert_receive {:editor_state_changed, ^editor_state}
+
+ # Second user sees shared state on join
+ assert {:ok, _draft, 0, _undo_state, shared_editor_state} =
+ DraftSession.join(draft.id, second_scope, second_scope.user.id)
+
+ assert shared_editor_state.pinned_outputs == %{first_step_id => %{"result" => 42}}
+ assert first_step_id in shared_editor_state.disabled_steps
+
+ # First user leaves and rejoins — state persists
+ :ok = DraftSession.leave(draft.id, scope.user.id)
+
+ assert {:ok, _draft, _seq, _undo_state, reconnected_editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert reconnected_editor_state.pinned_outputs == %{first_step_id => %{"result" => 42}}
+ assert first_step_id in reconnected_editor_state.disabled_steps
+
+ # Unpin and enable
+ assert {:ok, editor_state} = DraftSession.unpin_output(draft.id, first_step_id)
+ assert editor_state.pinned_outputs == %{}
+
+ assert {:ok, editor_state} = DraftSession.enable_step(draft.id, first_step_id)
+ assert editor_state.disabled_steps == []
+ end
+
+ test "disable_step is idempotent" do
+ scope = project_scope_fixture()
+ %{draft: draft} = draft_fixture(scope, base_snapshot_attrs())
+ first_step_id = hd(draft.steps).id
+
+ register_session_cleanup(draft.id)
+
+ assert {:ok, _draft, 0, _undo_state, _editor_state} =
+ DraftSession.join(draft.id, scope, scope.user.id)
+
+ assert {:ok, editor_state} = DraftSession.disable_step(draft.id, first_step_id)
+ assert {:ok, editor_state2} = DraftSession.disable_step(draft.id, first_step_id)
+ assert editor_state.disabled_steps == editor_state2.disabled_steps
+ assert length(editor_state2.disabled_steps) == 1
+ end
+
+ defp draft_fixture(scope, snapshot_attrs \\ nil) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Workflow #{System.unique_integer([:positive])}",
+ description: "Draft session"
+ })
+
+ case snapshot_attrs do
+ nil ->
+ %{definition: definition, draft: draft}
+
+ attrs ->
+ {:ok, saved_draft} = Workflows.save_draft(scope, draft, attrs)
+ %{definition: definition, draft: saved_draft}
+ end
+ end
+
+ defp base_snapshot_attrs do
+ entry_step =
+ step(%{
+ name: "Entry",
+ position: %{"x" => 10, "y" => 20}
+ })
+
+ debug_step =
+ step(%{
+ name: "Debug",
+ position: %{"x" => 200, "y" => 150}
+ })
+
+ snapshot_attrs(%{
+ steps: [entry_step, debug_step],
+ connections: [
+ connection(%{
+ source_step_id: entry_step.id,
+ target_step_id: debug_step.id
+ })
+ ]
+ })
+ end
+
+ defp connected_snapshot_attrs do
+ base_snapshot_attrs()
+ end
+
+ defp grouped_snapshot_attrs do
+ first_step =
+ step(%{
+ name: "First",
+ position: %{"x" => 10, "y" => 20}
+ })
+
+ second_step =
+ step(%{
+ name: "Second",
+ position: %{"x" => 30, "y" => 40}
+ })
+
+ third_step =
+ step(%{
+ name: "Third",
+ position: %{"x" => 220, "y" => 180}
+ })
+
+ group_one = %{
+ id: Ecto.UUID.generate(),
+ name: "Group One",
+ step_ids: [first_step.id],
+ position: %{"x" => 100, "y" => 100, "width" => 200, "height" => 200},
+ color: "#0f172a",
+ font_size: 14,
+ collapsed: false
+ }
+
+ group_two = %{
+ id: Ecto.UUID.generate(),
+ name: "Group Two",
+ step_ids: [second_step.id],
+ position: %{"x" => 300, "y" => 100, "width" => 200, "height" => 200},
+ color: "#1d4ed8",
+ font_size: 14,
+ collapsed: false
+ }
+
+ snapshot_attrs(%{
+ steps: [first_step, second_step, third_step],
+ connections: [],
+ step_groups: [group_one, group_two]
+ })
+ end
+
+ defp connected_grouped_snapshot_attrs do
+ first_step =
+ step(%{
+ name: "First",
+ position: %{"x" => 10, "y" => 20}
+ })
+
+ second_step =
+ step(%{
+ name: "Second",
+ position: %{"x" => 110, "y" => 80}
+ })
+
+ group = %{
+ id: Ecto.UUID.generate(),
+ name: "Group",
+ step_ids: [first_step.id, second_step.id],
+ position: %{"x" => 100, "y" => 100, "width" => 250, "height" => 220},
+ color: "#0f172a",
+ font_size: 14,
+ collapsed: false
+ }
+
+ snapshot_attrs(%{
+ steps: [first_step, second_step],
+ connections: [
+ connection(%{
+ source_step_id: first_step.id,
+ target_step_id: second_step.id
+ })
+ ],
+ step_groups: [group]
+ })
+ end
+
+ defp secondary_scope(scope) do
+ second_user = user_fixture()
+
+ Scope.for_user(second_user)
+ |> Scope.with_organization_id(scope.organization_id)
+ |> Scope.with_organization_role(scope.organization_role)
+ |> Scope.with_project(scope.project)
+ |> Scope.with_project_role(scope.project_role)
+ end
+
+ defp subscribe_draft(version_id) do
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "draft:#{version_id}")
+ end
+
+ defp register_session_cleanup(version_id) do
+ on_exit(fn ->
+ case Registry.lookup(Fizz.Workflows.DraftSessionRegistry, version_id) do
+ [{pid, _value}] -> GenServer.stop(pid, :normal)
+ [] -> :ok
+ end
+ end)
+ end
+
+ defp session_pid(version_id) do
+ case Registry.lookup(Fizz.Workflows.DraftSessionRegistry, version_id) do
+ [{pid, _value}] -> pid
+ [] -> nil
+ end
+ end
+end
diff --git a/test/fizz/workflows/draft_validator_test.exs b/test/fizz/workflows/draft_validator_test.exs
new file mode 100644
index 0000000..362835c
--- /dev/null
+++ b/test/fizz/workflows/draft_validator_test.exs
@@ -0,0 +1,199 @@
+defmodule Fizz.Workflows.DraftValidatorTest do
+ use Fizz.DataCase, async: false
+
+ alias Fizz.Workflows.DraftValidator
+ alias Fizz.Workflows.DraftValidator.ValidationError
+ alias Fizz.Workflows.Embeds.{Connection, Step, StepGroup}
+ alias Fizz.Workflows.WorkflowDefinitionVersion
+ alias Fizz.WorkflowsFixtures
+
+ test "validate_for_publish catches missing required fields" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ http_step = WorkflowsFixtures.step(%{type_id: "http_request", config: %{}})
+
+ version =
+ version_from_snapshot(
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [http_step]
+ })
+ )
+
+ assert {:error, errors} = DraftValidator.validate_for_publish(version, scope)
+
+ assert Enum.any?(errors, fn
+ %ValidationError{
+ code: :missing_required_field,
+ step_id: step_id,
+ field: "url",
+ message: "is required"
+ } ->
+ step_id == http_step.id
+
+ _ ->
+ false
+ end)
+ end
+
+ test "validate_for_publish requires credential declarations for credential fields" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ append_step =
+ WorkflowsFixtures.step(%{
+ type_id: "google_sheets_append_row",
+ config: %{
+ "credential_ref" => nil,
+ "spreadsheet_id" => "sheet_123",
+ "values" => %{"A" => "1"}
+ }
+ })
+
+ version =
+ version_from_snapshot(
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [append_step]
+ })
+ )
+
+ assert {:error, errors} = DraftValidator.validate_for_publish(version, scope)
+
+ assert Enum.any?(errors, fn
+ %ValidationError{
+ code: :invalid_credential_declaration,
+ step_id: step_id,
+ field: "credential_ref",
+ message: "is required"
+ } ->
+ step_id == append_step.id
+
+ _ ->
+ false
+ end)
+ end
+
+ test "validate_for_publish rejects legacy concrete credential refs in authored config" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ append_step =
+ WorkflowsFixtures.step(%{
+ type_id: "google_sheets_append_row",
+ config: %{
+ "credential_ref" => %{
+ "id" => Ecto.UUID.generate(),
+ "provider" => "google_oauth",
+ "auth_type" => "oauth",
+ "owner_user_id" => scope.user.id
+ },
+ "spreadsheet_id" => "sheet_123",
+ "values" => %{"A" => "1"}
+ }
+ })
+
+ version =
+ version_from_snapshot(
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [append_step]
+ })
+ )
+
+ assert {:error, errors} = DraftValidator.validate_for_publish(version, scope)
+
+ assert Enum.any?(errors, fn
+ %ValidationError{
+ code: :invalid_credential_declaration,
+ step_id: step_id,
+ field: "credential_ref",
+ message: "must be a credential declaration"
+ } ->
+ step_id == append_step.id
+
+ _ ->
+ false
+ end)
+ end
+
+ test "validate_for_publish catches invalid expressions" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ debug_step =
+ WorkflowsFixtures.step(%{
+ type_id: "debug",
+ config: %{"label" => "{{ input.name | concat: \"!\" }}"}
+ })
+
+ version =
+ version_from_snapshot(
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [debug_step]
+ })
+ )
+
+ assert {:error, errors} = DraftValidator.validate_for_publish(version, scope)
+
+ assert Enum.any?(errors, fn
+ %ValidationError{
+ code: :invalid_expression,
+ step_id: step_id,
+ field: "label",
+ message: message
+ } ->
+ step_id == debug_step.id and String.contains?(message, "unsupported filter")
+
+ _ ->
+ false
+ end)
+ end
+
+ test "validate_for_publish catches cycle" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ first_step = WorkflowsFixtures.step(%{type_id: "debug", name: "First"})
+ second_step = WorkflowsFixtures.step(%{type_id: "debug", name: "Second"})
+
+ version =
+ version_from_snapshot(
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [first_step, second_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: first_step.id,
+ target_step_id: second_step.id
+ }),
+ WorkflowsFixtures.connection(%{
+ source_step_id: second_step.id,
+ target_step_id: first_step.id
+ })
+ ]
+ })
+ )
+
+ assert {:error, errors} = DraftValidator.validate_for_publish(version, scope)
+
+ assert Enum.any?(errors, fn
+ %ValidationError{code: :cycle_detected, step_id: nil, message: message} ->
+ String.contains?(message, "creates a cycle")
+
+ _ ->
+ false
+ end)
+ end
+
+ test "validate_for_publish returns :ok for a valid draft" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ version = version_from_snapshot(WorkflowsFixtures.valid_snapshot_attrs())
+
+ assert :ok = DraftValidator.validate_for_publish(version, scope)
+ end
+
+ defp version_from_snapshot(snapshot_attrs) do
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ workflow_definition_id: Ecto.UUID.generate(),
+ version: 1,
+ status: :draft,
+ steps: Enum.map(snapshot_attrs.steps || [], &struct(Step, &1)),
+ connections: Enum.map(snapshot_attrs.connections || [], &struct(Connection, &1)),
+ step_groups: Enum.map(snapshot_attrs.step_groups || [], &struct(StepGroup, &1)),
+ viewport: snapshot_attrs.viewport || %{},
+ settings: snapshot_attrs.settings || %{}
+ }
+ end
+end
diff --git a/test/fizz/workflows/execution_context_test.exs b/test/fizz/workflows/execution_context_test.exs
new file mode 100644
index 0000000..5e04795
--- /dev/null
+++ b/test/fizz/workflows/execution_context_test.exs
@@ -0,0 +1,16 @@
+defmodule Fizz.Workflows.ExecutionContextTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.ExecutionContext
+
+ describe "put_legacy_aliases/1" do
+ test "preserves falsey input values in legacy and typed context" do
+ context = ExecutionContext.put_legacy_aliases(%{input: false, type_id: "debug"})
+
+ assert context.input == false
+ assert %ExecutionContext{} = context.execution_context
+ assert context.execution_context.input == false
+ assert context.execution_context.type_id == "debug"
+ end
+ end
+end
diff --git a/test/fizz/workflows/expressions_test.exs b/test/fizz/workflows/expressions_test.exs
new file mode 100644
index 0000000..46f124f
--- /dev/null
+++ b/test/fizz/workflows/expressions_test.exs
@@ -0,0 +1,151 @@
+defmodule Fizz.Workflows.ExpressionsTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.Expressions
+ alias Fizz.Workflows.Expressions.Filters
+
+ test "classifies literal, value, template, and predicate expressions" do
+ assert Expressions.classify("plain text") == :literal
+ assert Expressions.classify("{{ input.orders }}") == :value
+ assert Expressions.classify("Hello {{ input.name }}") == :template
+ assert Expressions.classify("{{ input.total | gt: 100 }}") == :predicate
+ end
+
+ test "resolves value expressions while preserving native types" do
+ orders = [%{"id" => 1}, %{"id" => 2}]
+ plan = access_plan!("{{ input.orders }}")
+
+ assert Expressions.resolve(plan, context(%{"orders" => orders})) == orders
+ end
+
+ test "resolves json aliases against the current input" do
+ orders = [%{"id" => 1}, %{"id" => 2}]
+ plan = access_plan!("{{ json.orders }}")
+
+ assert Expressions.resolve(plan, context(%{"orders" => orders})) == orders
+ end
+
+ test "resolves template expressions to strings" do
+ plan = access_plan!("Hello {{ input.name }}")
+
+ assert Expressions.resolve(plan, context(%{"name" => "Ada"})) == "Hello Ada"
+ end
+
+ test "preview renders value expressions with native types" do
+ orders = [%{"id" => 1}, %{"id" => 2}]
+
+ assert Expressions.preview("{{ input.orders }}", context(%{"orders" => orders})) ==
+ {:ok, orders}
+ end
+
+ test "preview returns parse errors for invalid expressions" do
+ assert {:error, message} =
+ Expressions.preview("{% if input.ok %}", context(%{"ok" => true}))
+
+ assert String.starts_with?(message, "Parse error: ")
+ end
+
+ test "validate rejects unsupported filters in strict mode" do
+ assert {:error, errors} =
+ Expressions.validate("{{ input.name | concat: \"!\" }}",
+ strict_filters: true,
+ known_step_ids: []
+ )
+
+ assert Enum.any?(errors, &String.contains?(&1, "unsupported filter `concat`"))
+ end
+
+ test "custom filters produce the expected values" do
+ assert Filters.json(%{"ok" => true}) == "{\"ok\":true}"
+ assert Filters.parse_json("{\"ok\":true}") == %{"ok" => true}
+ assert Filters.to_int("12.8") == 12
+ assert Filters.to_float("12") == 12.0
+ assert Filters.to_bool("true")
+
+ assert Filters.dig(%{"customer" => %{"email" => "ada@example.com"}}, "customer.email") ==
+ "ada@example.com"
+
+ assert Filters.pluck([%{"id" => 1}, %{"id" => 2}], "id") == [1, 2]
+ assert Filters.sort_by([%{"id" => 2}, %{"id" => 1}], "id") == [%{"id" => 1}, %{"id" => 2}]
+
+ assert Filters.sort_by_desc([%{"id" => 1}, %{"id" => 2}], "id") == [
+ %{"id" => 2},
+ %{"id" => 1}
+ ]
+
+ assert Filters.where_eq([%{"status" => "paid"}, %{"status" => "draft"}], "status", "paid") ==
+ [%{"status" => "paid"}]
+
+ assert Filters.where_ne([%{"status" => "paid"}, %{"status" => "draft"}], "status", "paid") ==
+ [%{"status" => "draft"}]
+
+ assert Filters.eq("10", 10)
+ assert Filters.ne("paid", "draft")
+ assert Filters.gt("11", 10)
+ assert Filters.gte(10, "10")
+ assert Filters.lt(9, "10")
+ assert Filters.lte("10", 10)
+ assert Filters.blank(" ")
+ assert Filters.present("Ada")
+ assert Filters.slugify("Hello, Ada Lovelace!") == "hello-ada-lovelace"
+ end
+
+ test "step reference validation rejects non-existent step ids" do
+ step_id = Ecto.UUID.generate()
+
+ assert {:error, errors} =
+ Expressions.validate("{{ steps.#{step_id}.body }}",
+ strict_filters: true,
+ known_step_ids: []
+ )
+
+ assert Enum.any?(errors, &String.contains?(&1, step_id))
+ end
+
+ test "validate resolves named step references to stable ids when a mapping is provided" do
+ step_id = Ecto.UUID.generate()
+
+ assert {:ok, parsed} =
+ Expressions.validate(~s({{ steps["Manual Trigger"].body }}),
+ strict_filters: true,
+ known_step_ids: [step_id],
+ step_name_to_id: %{"Manual Trigger" => step_id}
+ )
+
+ assert Expressions.dependencies(parsed).step_ids == MapSet.new([step_id])
+ end
+
+ test "preview resolves named step references when context provides a step name mapping" do
+ step_id = Ecto.UUID.generate()
+
+ assert Expressions.preview(
+ ~s({{ steps["Manual Trigger"].body }}),
+ %{
+ input: %{},
+ steps: %{step_id => %{"body" => %{"ok" => true}}},
+ workflow: %{},
+ env: %{},
+ _step_name_to_id: %{"Manual Trigger" => step_id}
+ }
+ ) == {:ok, %{"ok" => true}}
+ end
+
+ defp access_plan!(expression) do
+ {:ok, plan} =
+ Expressions.to_access_plan(expression,
+ strict_filters: true,
+ known_step_ids: []
+ )
+
+ plan
+ end
+
+ defp context(input) do
+ %{
+ input: input,
+ steps: %{},
+ workflow: %{},
+ env: %{}
+ }
+ end
+end
diff --git a/test/fizz/workflows/lease_manager_test.exs b/test/fizz/workflows/lease_manager_test.exs
new file mode 100644
index 0000000..c553f2f
--- /dev/null
+++ b/test/fizz/workflows/lease_manager_test.exs
@@ -0,0 +1,135 @@
+defmodule Fizz.Workflows.LeaseManagerTest do
+ use Fizz.DataCase, async: false
+
+ alias Fizz.Workflows.LeaseManager
+
+ setup do
+ manager_a = unique_name(:manager_a)
+ manager_b = unique_name(:manager_b)
+
+ start_supervised!({LeaseManager, name: manager_a, owner_node: "node-a", repo: Repo})
+ start_supervised!({LeaseManager, name: manager_b, owner_node: "node-b", repo: Repo})
+
+ %{manager_a: manager_a, manager_b: manager_b}
+ end
+
+ test "acquire lease returns an incremented fence token", %{manager_a: manager_a} do
+ run_id = Ecto.UUID.generate()
+ insert_lease(run_id, "old-node", 0, "NOW() - interval '1 second'")
+
+ assert {:ok, 1} = LeaseManager.acquire(run_id, server: manager_a)
+ assert %{owner_node: "node-a", fence_token: 1} = lease_row(run_id)
+ end
+
+ test "renewal extends lease expiry", %{manager_a: manager_a} do
+ run_id = Ecto.UUID.generate()
+ insert_lease(run_id, "old-node", 0, "NOW() - interval '1 second'")
+
+ assert {:ok, 1} = LeaseManager.acquire(run_id, server: manager_a)
+
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ UPDATE workflow_run_leases
+ SET lease_expiry = NOW() + interval '1 second'
+ WHERE run_id = $1
+ """,
+ [dump_uuid(run_id)]
+ )
+
+ old_expiry = lease_row(run_id).lease_expiry
+
+ assert {:ok, [^run_id]} = LeaseManager.renew(server: manager_a)
+
+ renewed_expiry = lease_row(run_id).lease_expiry
+ assert NaiveDateTime.compare(renewed_expiry, old_expiry) == :gt
+ end
+
+ test "expired lease can be claimed by another node", %{
+ manager_a: manager_a,
+ manager_b: manager_b
+ } do
+ run_id = Ecto.UUID.generate()
+ insert_lease(run_id, "old-node", 0, "NOW() - interval '1 second'")
+
+ assert {:ok, 1} = LeaseManager.acquire(run_id, server: manager_a)
+
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ UPDATE workflow_run_leases
+ SET lease_expiry = NOW() - interval '1 second'
+ WHERE run_id = $1
+ """,
+ [dump_uuid(run_id)]
+ )
+
+ assert {:ok, 2} = LeaseManager.acquire(run_id, server: manager_b)
+ assert %{owner_node: "node-b", fence_token: 2} = lease_row(run_id)
+ end
+
+ test "concurrent acquisition allows only one winner", %{
+ manager_a: manager_a,
+ manager_b: manager_b
+ } do
+ run_id = Ecto.UUID.generate()
+ insert_lease(run_id, "old-node", 0, "NOW() - interval '1 second'")
+
+ task_a =
+ Task.async(fn ->
+ receive do
+ :go -> LeaseManager.acquire(run_id, server: manager_a)
+ end
+ end)
+
+ task_b =
+ Task.async(fn ->
+ receive do
+ :go -> LeaseManager.acquire(run_id, server: manager_b)
+ end
+ end)
+
+ send(task_a.pid, :go)
+ send(task_b.pid, :go)
+
+ results = [Task.await(task_a), Task.await(task_b)]
+
+ assert Enum.count(results, &match?({:ok, _}, &1)) == 1
+ assert Enum.count(results, &match?({:error, :lease_unavailable}, &1)) == 1
+ end
+
+ defp insert_lease(run_id, owner_node, fence_token, lease_expiry_sql) do
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ INSERT INTO workflow_run_leases (run_id, owner_node, fence_token, checkpoint_seq, lease_expiry)
+ VALUES ($1, $2, $3, 0, #{lease_expiry_sql})
+ """,
+ [dump_uuid(run_id), owner_node, fence_token]
+ )
+ end
+
+ defp lease_row(run_id) do
+ assert {:ok, %{rows: [[owner_node, fence_token, lease_expiry]]}} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ SELECT owner_node, fence_token, lease_expiry
+ FROM workflow_run_leases
+ WHERE run_id = $1
+ """,
+ [dump_uuid(run_id)]
+ )
+
+ %{owner_node: owner_node, fence_token: fence_token, lease_expiry: lease_expiry}
+ end
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+
+ defp dump_uuid(run_id), do: Ecto.UUID.dump!(run_id)
+end
diff --git a/test/fizz/workflows/passivation_sweeper_test.exs b/test/fizz/workflows/passivation_sweeper_test.exs
new file mode 100644
index 0000000..b7b1868
--- /dev/null
+++ b/test/fizz/workflows/passivation_sweeper_test.exs
@@ -0,0 +1,309 @@
+defmodule Fizz.Workflows.PassivationSweeperTest.FakeLitestream do
+ use GenServer
+
+ @impl true
+ def init(_), do: {:ok, :running}
+
+ @impl true
+ def handle_call(:status, _from, state), do: {:reply, :running, state}
+end
+
+defmodule Fizz.Workflows.PassivationSweeperTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Workflows.PassivationSweeper
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.Runtime.ContextBuilder
+ alias Fizz.Workflows.Store.{Paths, SqliteStore}
+ alias Fizz.Workflows.WorkflowRun
+
+ require Runic
+
+ setup do
+ scope = project_scope_fixture()
+ %{version: version} = published_version_fixture(scope)
+
+ registry = unique_name(:registry)
+ task_supervisor = unique_name(:task_supervisor)
+
+ tmp_dir =
+ Path.join(System.tmp_dir!(), "fizz-sweeper-#{System.unique_integer([:positive])}")
+
+ start_supervised!({Registry, keys: :unique, name: registry})
+ start_supervised!({Task.Supervisor, name: task_supervisor})
+
+ on_exit(fn -> File.rm_rf(tmp_dir) end)
+
+ %{
+ scope: scope,
+ version: version,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ tmp_dir: tmp_dir
+ }
+ end
+
+ test "idle runs beyond the threshold are passivated", ctx do
+ sweeper = start_sweeper!(ctx)
+ run = insert_run(ctx.scope, ctx.version, :running, old_time())
+
+ pid = start_idle_worker!(run, ctx)
+ ref = Process.monitor(pid)
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ assert run.id in passivated_run_ids
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+
+ assert {:ok, %{status: :passivated}} = Fizz.Workflows.get_run(ctx.scope, run.id)
+ end
+
+ test "active runs are not passivated", ctx do
+ sweeper = start_sweeper!(ctx)
+ run = insert_run(ctx.scope, ctx.version, :running, DateTime.utc_now())
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ refute run.id in passivated_run_ids
+ assert {:ok, %{status: :running}} = Fizz.Workflows.get_run(ctx.scope, run.id)
+ end
+
+ test "completed and failed runs are not passivated", ctx do
+ sweeper = start_sweeper!(ctx)
+ completed_run = insert_run(ctx.scope, ctx.version, :completed, old_time())
+ failed_run = insert_run(ctx.scope, ctx.version, :failed, old_time())
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ refute completed_run.id in passivated_run_ids
+ refute failed_run.id in passivated_run_ids
+ assert {:ok, %{status: :completed}} = Fizz.Workflows.get_run(ctx.scope, completed_run.id)
+ assert {:ok, %{status: :failed}} = Fizz.Workflows.get_run(ctx.scope, failed_run.id)
+ end
+
+ test "runs without a worker and without a checkpoint are not passivated", ctx do
+ sweeper = start_sweeper!(ctx)
+ run = insert_run(ctx.scope, ctx.version, :sleeping, old_time())
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ refute run.id in passivated_run_ids
+ assert {:ok, %{status: :sleeping}} = Fizz.Workflows.get_run(ctx.scope, run.id)
+ end
+
+ test "runs without a worker can still be passivated when a checkpoint exists", ctx do
+ sweeper = start_sweeper!(ctx)
+ run = insert_run(ctx.scope, ctx.version, :sleeping, old_time())
+
+ persist_checkpoint!(run, ctx)
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ assert run.id in passivated_run_ids
+ assert {:ok, %{status: :passivated}} = Fizz.Workflows.get_run(ctx.scope, run.id)
+ end
+
+ test "passivated runs have local SQLite files evicted when litestream is running", ctx do
+ fake_litestream = start_fake_litestream!()
+ sweeper = start_sweeper!(ctx, litestream_server: fake_litestream)
+ run = insert_run(ctx.scope, ctx.version, :running, old_time())
+
+ pid = start_idle_worker!(run, ctx)
+ ref = Process.monitor(pid)
+
+ db_path = run_db_path(run, ctx)
+ assert File.exists?(db_path)
+
+ assert {:ok, [_]} = PassivationSweeper.sweep(server: sweeper)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+
+ refute File.exists?(db_path)
+ end
+
+ test "local files are preserved when litestream is not running", ctx do
+ sweeper = start_sweeper!(ctx)
+ run = insert_run(ctx.scope, ctx.version, :running, old_time())
+
+ pid = start_idle_worker!(run, ctx)
+ ref = Process.monitor(pid)
+
+ db_path = run_db_path(run, ctx)
+ assert File.exists?(db_path)
+
+ assert {:ok, [_]} = PassivationSweeper.sweep(server: sweeper)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+
+ assert File.exists?(db_path)
+ end
+
+ test "sweep passivates at most the configured batch size", ctx do
+ sweeper = start_sweeper!(ctx, batch_size: 2, max_concurrency: 2)
+
+ runs =
+ for _index <- 1..3 do
+ run = insert_run(ctx.scope, ctx.version, :running, old_time())
+ pid = start_idle_worker!(run, ctx)
+ {run, Process.monitor(pid)}
+ end
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ assert length(passivated_run_ids) == 2
+
+ for {run, ref} <- runs, run.id in passivated_run_ids do
+ assert_receive {:DOWN, ^ref, :process, _pid, :normal}, 2_000
+ end
+
+ statuses =
+ runs
+ |> Enum.map(fn {run, _ref} -> Fizz.Workflows.get_run(ctx.scope, run.id) end)
+ |> Enum.map(fn {:ok, run} -> run.status end)
+
+ assert Enum.count(statuses, &(&1 == :passivated)) == 2
+ assert Enum.count(statuses, &(&1 == :running)) == 1
+ end
+
+ test "wal_checkpoint failure does not block passivation", ctx do
+ fake_litestream = start_fake_litestream!()
+ sweeper = start_sweeper!(ctx, litestream_server: fake_litestream)
+ run = insert_run(ctx.scope, ctx.version, :running, old_time())
+
+ pid = start_idle_worker!(run, ctx)
+ ref = Process.monitor(pid)
+
+ # Pre-delete the file so wal_checkpoint will fail
+ db_path = run_db_path(run, ctx)
+ File.rm(db_path)
+
+ assert {:ok, passivated_run_ids} = PassivationSweeper.sweep(server: sweeper)
+ assert run.id in passivated_run_ids
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+
+ assert {:ok, %{status: :passivated}} = Fizz.Workflows.get_run(ctx.scope, run.id)
+ end
+
+ defp start_sweeper!(ctx, extra_opts \\ []) do
+ name = unique_name(:sweeper)
+
+ start_supervised!(
+ {PassivationSweeper,
+ Keyword.merge(
+ [
+ name: name,
+ interval_ms: 60_000,
+ idle_threshold_ms: 60_000,
+ worker_opts: [registry: ctx.registry],
+ data_dir: ctx.tmp_dir
+ ],
+ extra_opts
+ )}
+ )
+
+ name
+ end
+
+ defp start_fake_litestream! do
+ name = unique_name(:fake_litestream)
+
+ start_supervised!(%{
+ id: name,
+ start: {GenServer, :start_link, [__MODULE__.FakeLitestream, [], [name: name]]}
+ })
+
+ name
+ end
+
+ defp run_db_path(run, ctx) do
+ Paths.db_path(
+ Path.expand(ctx.tmp_dir),
+ run.id,
+ ctx.scope.project.workos_organization_id,
+ ctx.scope.project.id
+ )
+ end
+
+ defp start_idle_worker!(run, ctx) do
+ fence_token = 1
+
+ insert_lease(run.id, fence_token)
+
+ {:ok, store_state} =
+ SqliteStore.init(run.id,
+ data_dir: ctx.tmp_dir,
+ org_id: ctx.scope.project.workos_organization_id,
+ project_id: ctx.scope.project.id,
+ fence_token: fence_token,
+ repo: Repo
+ )
+
+ workflow = Runic.workflow(steps: [])
+
+ start_supervised!(
+ {Worker,
+ [
+ run_id: run.id,
+ workflow: workflow,
+ run_context: ContextBuilder.build_run_context(ctx.scope, run),
+ store: store_state,
+ fence_token: fence_token,
+ registry: ctx.registry,
+ task_supervisor: ctx.task_supervisor,
+ checkpoint_strategy: :every_cycle
+ ]}
+ )
+ end
+
+ defp insert_run(scope, version, status, last_active_at) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: status,
+ input: %{},
+ last_active_at: last_active_at,
+ started_at: now,
+ completed_at: if(status in [:completed, :failed], do: now)
+ })
+ |> Repo.insert!()
+ end
+
+ defp insert_lease(run_id, fence_token) do
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ INSERT INTO workflow_run_leases (run_id, owner_node, fence_token, checkpoint_seq, lease_expiry)
+ VALUES ($1, NULL, $2, 0, NOW() + interval '30 seconds')
+ """,
+ [dump_uuid(run_id), fence_token]
+ )
+ end
+
+ defp dump_uuid(run_id), do: Ecto.UUID.dump!(run_id)
+
+ defp old_time do
+ DateTime.add(DateTime.utc_now(), -5, :minute)
+ end
+
+ defp persist_checkpoint!(run, ctx) do
+ fence_token = 1
+ insert_lease(run.id, fence_token)
+
+ {:ok, store_state} =
+ SqliteStore.init(run.id,
+ data_dir: ctx.tmp_dir,
+ org_id: ctx.scope.project.workos_organization_id,
+ project_id: ctx.scope.project.id,
+ fence_token: fence_token,
+ repo: Repo
+ )
+
+ workflow = Runic.workflow(steps: [])
+ :ok = SqliteStore.save(run.id, Runic.Workflow.event_log(workflow), store_state)
+ end
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+end
diff --git a/test/fizz/workflows/rehydration_test.exs b/test/fizz/workflows/rehydration_test.exs
new file mode 100644
index 0000000..9be86d2
--- /dev/null
+++ b/test/fizz/workflows/rehydration_test.exs
@@ -0,0 +1,327 @@
+defmodule Fizz.Workflows.RehydrationTest do
+ use Fizz.DataCase, async: false
+
+ import Ecto.Query
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Workflows
+ alias Fizz.Workflows.Compiler
+ alias Fizz.Workflows.DurableTimer
+ alias Fizz.Workflows.PassivationSweeper
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.Store.SqliteStore
+ alias Fizz.Workflows.TimerPoller
+ alias Fizz.Workflows.WorkflowRun
+ alias Runic.Workflow, as: RunicWorkflow
+
+ setup do
+ scope = project_scope_fixture()
+
+ tmp_dir =
+ Path.join(System.tmp_dir!(), "fizz-rehydration-#{System.unique_integer([:positive])}")
+
+ previous_data_dir = Application.get_env(:fizz, :workflow_data_dir)
+ previous_workflow_env = Application.get_env(:fizz, Fizz.Workflows, [])
+
+ Application.put_env(:fizz, :workflow_data_dir, tmp_dir)
+
+ on_exit(fn ->
+ Application.put_env(:fizz, :workflow_data_dir, previous_data_dir)
+ Application.put_env(:fizz, Fizz.Workflows, previous_workflow_env)
+ File.rm_rf(tmp_dir)
+ end)
+
+ %{scope: scope, tmp_dir: tmp_dir}
+ end
+
+ test "sqlite workflow log replay restores splitter aggregator outputs", %{scope: scope} do
+ %{snapshot_attrs: snapshot_attrs, ids: ids} = split_math_collect_snapshot_attrs()
+ %{version: version} = published_version_fixture(scope, snapshot_attrs)
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"items" => [1, 2, 3]})
+
+ completed_run =
+ eventually(fn ->
+ with {:ok, %WorkflowRun{status: :completed} = workflow_run} <-
+ Workflows.get_run(scope, run.id) do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.output == %{"value" => [[11.0, 12.0, 13.0]]}
+
+ {:ok, store_state} = store_state_for_run(run, scope)
+ assert {:ok, event_log} = SqliteStore.load(run.id, store_state)
+ assert {:ok, compiled_workflow, _compiled_hash} = Compiler.compile(version)
+
+ restored_workflow = RunicWorkflow.from_events(event_log, compiled_workflow)
+
+ assert RunicWorkflow.raw_productions(restored_workflow, ids.aggregator) == [
+ [11.0, 12.0, 13.0]
+ ]
+
+ assert RunicWorkflow.raw_productions(restored_workflow, ids.output) == [[11.0, 12.0, 13.0]]
+ assert Map.get(restored_workflow, :fizz_metadata).result_step_ids == [ids.output]
+
+ assert_worker_shutdown(run.id)
+ end
+
+ test "passivated splitter aggregator workflow resumes from sqlite checkpoint and completes", %{
+ scope: scope
+ } do
+ put_workflow_runtime(idle_timeout_ms: 25)
+
+ %{snapshot_attrs: snapshot_attrs, ids: ids} = split_math_wait_collect_snapshot_attrs(250)
+ %{version: version} = published_version_fixture(scope, snapshot_attrs)
+
+ poller = unique_name(:poller)
+ sweeper = unique_name(:sweeper)
+
+ start_supervised!({TimerPoller, name: poller, interval_ms: 60_000, claim_ttl_ms: 100})
+
+ start_supervised!(
+ {PassivationSweeper, name: sweeper, interval_ms: 60_000, idle_threshold_ms: 25}
+ )
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"items" => [1, 2, 3]})
+
+ timers =
+ eventually(fn ->
+ pending_timers = pending_timers_for_run(run.id)
+
+ case {pending_timers, Workflows.get_run(scope, run.id)} do
+ {[%DurableTimer{}, %DurableTimer{}, %DurableTimer{}] = timers,
+ {:ok, %WorkflowRun{status: :sleeping}}} ->
+ {:ok, timers}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ worker_pid =
+ eventually(fn ->
+ case Worker.lookup(run.id) do
+ nil -> :retry
+ pid -> {:ok, pid}
+ end
+ end)
+
+ ref = Process.monitor(worker_pid)
+
+ passivated_run_ids =
+ eventually(fn ->
+ case PassivationSweeper.sweep(server: sweeper) do
+ {:ok, run_ids} ->
+ if run.id in run_ids, do: {:ok, run_ids}, else: :retry
+
+ _ ->
+ :retry
+ end
+ end)
+
+ assert run.id in passivated_run_ids
+ assert_receive {:DOWN, ^ref, :process, ^worker_pid, :normal}, 2_000
+ assert {:ok, %{status: :passivated}} = Workflows.get_run(scope, run.id)
+
+ {:ok, store_state} = store_state_for_run(run, scope)
+ assert {:ok, _event_log} = SqliteStore.load(run.id, store_state)
+
+ timer_ids = Enum.map(timers, & &1.id) |> MapSet.new()
+
+ fired_ids =
+ eventually(fn ->
+ _ = TimerPoller.poll(server: poller)
+
+ fired_ids =
+ DurableTimer
+ |> where([timer], timer.id in ^MapSet.to_list(timer_ids) and timer.status == :fired)
+ |> select([timer], timer.id)
+ |> Repo.all()
+
+ if MapSet.subset?(timer_ids, MapSet.new(fired_ids)), do: {:ok, fired_ids}, else: :retry
+ end)
+
+ assert MapSet.subset?(timer_ids, MapSet.new(fired_ids))
+
+ completed_run =
+ eventually(fn ->
+ with {:ok, %WorkflowRun{status: :completed} = workflow_run} <-
+ Workflows.get_run(scope, run.id) do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.output == %{"value" => [[11.0, 12.0, 13.0]]}
+
+ assert {:ok, step_executions} = Workflows.list_run_step_executions(scope, run.id)
+
+ math_executions =
+ step_executions
+ |> Enum.filter(&(&1.step_id == ids.math))
+ |> Enum.sort_by(& &1.item_index)
+
+ assert Enum.map(math_executions, & &1.item_index) == [0, 1, 2]
+ assert Enum.map(math_executions, & &1.output_data) == [11.0, 12.0, 13.0]
+
+ aggregator_executions =
+ step_executions
+ |> Enum.filter(&(&1.step_id == ids.aggregator))
+
+ assert [aggregator_execution] = aggregator_executions
+ assert aggregator_execution.output_data == [11.0, 12.0, 13.0]
+
+ assert_worker_shutdown(run.id)
+ end
+
+ defp split_math_collect_snapshot_attrs do
+ trigger = step(%{type_id: "manual_input", name: "Manual Trigger"})
+
+ splitter =
+ step(%{
+ type_id: "splitter",
+ name: "Split Items",
+ config: %{"field" => "{{ json.items }}"}
+ })
+
+ math =
+ step(%{
+ type_id: "math",
+ name: "Add Ten",
+ config: %{"operation" => "add", "value" => "{{ input }}", "operand" => 10}
+ })
+
+ aggregator =
+ step(%{
+ type_id: "aggregator",
+ name: "Collect",
+ config: %{"operation" => "collect"}
+ })
+
+ output = step(%{type_id: "data_output", name: "Output"})
+
+ snapshot_attrs =
+ snapshot_attrs(%{
+ steps: [trigger, splitter, math, aggregator, output],
+ connections: [
+ connection(%{source_step_id: trigger.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: math.id}),
+ connection(%{source_step_id: math.id, target_step_id: aggregator.id}),
+ connection(%{source_step_id: aggregator.id, target_step_id: output.id})
+ ]
+ })
+
+ %{
+ snapshot_attrs: snapshot_attrs,
+ ids: %{math: math.id, aggregator: aggregator.id, output: output.id}
+ }
+ end
+
+ defp split_math_wait_collect_snapshot_attrs(duration_ms) do
+ trigger = step(%{type_id: "manual_input", name: "Manual Trigger"})
+
+ splitter =
+ step(%{
+ type_id: "splitter",
+ name: "Split Items",
+ config: %{"field" => "{{ json.items }}"}
+ })
+
+ math =
+ step(%{
+ type_id: "math",
+ name: "Add Ten",
+ config: %{"operation" => "add", "value" => "{{ input }}", "operand" => 10}
+ })
+
+ wait_step =
+ step(%{
+ type_id: "wait",
+ name: "Wait",
+ config: %{"duration" => duration_ms, "unit" => "milliseconds"}
+ })
+
+ aggregator =
+ step(%{
+ type_id: "aggregator",
+ name: "Collect",
+ config: %{"operation" => "collect"}
+ })
+
+ output = step(%{type_id: "data_output", name: "Output"})
+
+ snapshot_attrs =
+ snapshot_attrs(%{
+ steps: [trigger, splitter, math, wait_step, aggregator, output],
+ connections: [
+ connection(%{source_step_id: trigger.id, target_step_id: splitter.id}),
+ connection(%{source_step_id: splitter.id, target_step_id: math.id}),
+ connection(%{source_step_id: math.id, target_step_id: wait_step.id}),
+ connection(%{source_step_id: wait_step.id, target_step_id: aggregator.id}),
+ connection(%{source_step_id: aggregator.id, target_step_id: output.id})
+ ]
+ })
+
+ %{
+ snapshot_attrs: snapshot_attrs,
+ ids: %{math: math.id, aggregator: aggregator.id, output: output.id}
+ }
+ end
+
+ defp store_state_for_run(run, scope) do
+ SqliteStore.init(run.id,
+ org_id: scope.project.workos_organization_id,
+ project_id: scope.project.id,
+ fence_token: 0,
+ repo: Repo
+ )
+ end
+
+ defp pending_timers_for_run(run_id) do
+ DurableTimer
+ |> where([timer], timer.run_id == ^run_id and timer.status == :pending)
+ |> order_by([timer], asc: timer.inserted_at)
+ |> Repo.all()
+ end
+
+ defp put_workflow_runtime(opts) do
+ current = Application.get_env(:fizz, Fizz.Workflows, [])
+ Application.put_env(:fizz, Fizz.Workflows, Keyword.merge(current, opts))
+ end
+
+ defp eventually(fun, attempts \\ 100)
+
+ defp eventually(fun, attempts) when attempts > 0 do
+ case fun.() do
+ {:ok, value} ->
+ value
+
+ :retry ->
+ receive do
+ after
+ 20 -> eventually(fun, attempts - 1)
+ end
+ end
+ end
+
+ defp eventually(_fun, 0), do: flunk("condition was not met in time")
+
+ defp assert_worker_shutdown(run_id) do
+ case Worker.lookup(run_id) do
+ nil ->
+ :ok
+
+ pid ->
+ ref = Process.monitor(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+ end
+ end
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+end
diff --git a/test/fizz/workflows/retry_policy_test.exs b/test/fizz/workflows/retry_policy_test.exs
new file mode 100644
index 0000000..89629cb
--- /dev/null
+++ b/test/fizz/workflows/retry_policy_test.exs
@@ -0,0 +1,83 @@
+defmodule Fizz.Workflows.RetryPolicyTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.{StepError, RetryPolicy}
+
+ describe "validate!/1" do
+ test "accepts a complete retry policy" do
+ policy = %RetryPolicy{
+ max_attempts: 3,
+ backoff: :exponential,
+ initial_delay_ms: 500,
+ max_delay_ms: 30_000,
+ retry_on: [:rate_limit, :network, :transient]
+ }
+
+ assert RetryPolicy.validate!(policy) == policy
+ end
+
+ test "rejects malformed retry policies" do
+ assert_raise ArgumentError, ~r/invalid retry policy/, fn ->
+ RetryPolicy.validate!(%RetryPolicy{max_attempts: 0})
+ end
+
+ assert_raise ArgumentError, ~r/invalid retry policy/, fn ->
+ RetryPolicy.validate!(%RetryPolicy{backoff: :random})
+ end
+
+ assert_raise ArgumentError, ~r/invalid retry policy/, fn ->
+ RetryPolicy.validate!(%RetryPolicy{retry_on: ["rate_limit"]})
+ end
+ end
+ end
+
+ describe "retryable?/3" do
+ test "matches retryable step error categories while attempts remain" do
+ policy = %RetryPolicy{max_attempts: 3, retry_on: [:rate_limit, :network]}
+ rate_limited = %StepError{category: :rate_limit, retryable?: true}
+ validation = %StepError{category: :validation, retryable?: false}
+
+ assert RetryPolicy.retryable?(policy, rate_limited, 1)
+ assert RetryPolicy.retryable?(policy, rate_limited, 2)
+ refute RetryPolicy.retryable?(policy, rate_limited, 3)
+ refute RetryPolicy.retryable?(policy, validation, 1)
+ end
+ end
+
+ describe "next_delay_ms/3" do
+ test "honors retry-after metadata before computed backoff" do
+ policy = %RetryPolicy{
+ max_attempts: 3,
+ backoff: :exponential,
+ initial_delay_ms: 250,
+ max_delay_ms: 5_000
+ }
+
+ error = %StepError{category: :rate_limit, retryable?: true, retry_after_ms: 2_000}
+
+ assert RetryPolicy.next_delay_ms(policy, error, 1) == 2_000
+ end
+
+ test "computes bounded exponential and linear backoff" do
+ exponential = %RetryPolicy{
+ max_attempts: 5,
+ backoff: :exponential,
+ initial_delay_ms: 250,
+ max_delay_ms: 1_000
+ }
+
+ linear = %RetryPolicy{
+ max_attempts: 5,
+ backoff: :linear,
+ initial_delay_ms: 250,
+ max_delay_ms: 1_000
+ }
+
+ error = %StepError{category: :network, retryable?: true}
+
+ assert RetryPolicy.next_delay_ms(exponential, error, 1) == 250
+ assert RetryPolicy.next_delay_ms(exponential, error, 3) == 1_000
+ assert RetryPolicy.next_delay_ms(linear, error, 3) == 750
+ end
+ end
+end
diff --git a/test/fizz/workflows/runner/worker_failure_test.exs b/test/fizz/workflows/runner/worker_failure_test.exs
new file mode 100644
index 0000000..23180dd
--- /dev/null
+++ b/test/fizz/workflows/runner/worker_failure_test.exs
@@ -0,0 +1,606 @@
+defmodule Fizz.Workflows.Runner.WorkerFailureTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Workflows.StepError
+ alias Fizz.Workflows
+ alias Fizz.Workflows.Compiler
+ alias Fizz.Workflows.RetryPolicy
+ alias Fizz.Workflows.Runner.RunnableConsumerSupervisor
+ alias Fizz.Workflows.Runner.RunnableDispatcher
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.Runtime.ContextBuilder
+ alias Fizz.Workflows.StepExecutionError
+ alias Fizz.Workflows.Store.SqliteStore
+ alias Fizz.Workflows.WorkflowRun
+
+ require Runic
+
+ setup do
+ scope = project_scope_fixture()
+ registry = unique_name(:registry)
+ task_supervisor = unique_name(:task_supervisor)
+ runnable_dispatcher = unique_name(:runnable_dispatcher)
+ runnable_consumer_supervisor = unique_name(:runnable_consumer_supervisor)
+
+ tmp_dir =
+ Path.join(System.tmp_dir!(), "fizz-worker-failure-#{System.unique_integer([:positive])}")
+
+ start_supervised!({Registry, keys: :unique, name: registry})
+ start_supervised!({Task.Supervisor, name: task_supervisor})
+ start_supervised!({RunnableDispatcher, name: runnable_dispatcher})
+
+ start_supervised!(
+ {RunnableConsumerSupervisor,
+ name: runnable_consumer_supervisor,
+ dispatcher: runnable_dispatcher,
+ task_supervisor: task_supervisor,
+ max_concurrency: 4}
+ )
+
+ on_exit(fn -> File.rm_rf(tmp_dir) end)
+
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ }
+ end
+
+ describe "task crash (:DOWN)" do
+ test "task crash -> step :failed, run :failed",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:task_running, self()})
+
+ receive do
+ :release -> input
+ end
+ end,
+ name: :blocking_step
+ )
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run.id}")
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ Worker.run(pid, %{})
+
+ # Wait for the task to be running
+ assert_receive {:task_running, task_pid}, 2_000
+
+ # Kill the task process to trigger the :DOWN handler
+ Process.exit(task_pid, :kill)
+
+ assert_receive {:step_failed, %{run_id: run_id}}, 5_000
+ assert run_id == run.id
+
+ assert_receive {:run_status_changed, %{run_id: ^run_id, status: :failed}}, 5_000
+
+ assert {:ok, %{status: :failed}} = Workflows.get_run(scope, run.id)
+ end
+
+ test "large errors are summarized in step failure broadcasts",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn _input ->
+ raise RuntimeError, message: String.duplicate("x", 5_000)
+ end,
+ name: :large_failure
+ )
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+ run_id = run.id
+
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run_id}")
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ Worker.run(pid, %{"large" => String.duplicate("y", 5_000)})
+
+ assert_receive {:step_failed,
+ %{
+ run_id: ^run_id,
+ input_summary: input_summary,
+ error: %{message: message, details: %{inspect: inspect_summary}}
+ } = payload},
+ 5_000
+
+ assert byte_size(input_summary) <= 1_027
+ assert byte_size(message) <= 1_027
+ assert byte_size(inspect_summary) <= 1_027
+ refute Map.has_key?(payload, :input)
+
+ assert_receive {:run_status_changed, %{run_id: ^run_id, status: :failed}}, 5_000
+ assert {:ok, %{status: :failed}} = Workflows.get_run(scope, run.id)
+ end
+ end
+
+ describe "step retries" do
+ test "retryable step errors sleep the run and resume the runnable",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ attempts = start_supervised!({Agent, fn -> 0 end})
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ attempt = Agent.get_and_update(attempts, &{&1, &1 + 1})
+
+ case attempt do
+ 0 ->
+ raise StepExecutionError.exception(
+ reason:
+ StepError.new(
+ code: :network_error,
+ category: :network,
+ message: "Network request failed",
+ source: :google_sheets,
+ retry_after_ms: 0,
+ retryable?: true,
+ details: %{
+ reason: :timeout
+ }
+ ),
+ step_id: "retry_step",
+ step_type_id: "google_sheets_append_row",
+ retry: %RetryPolicy{max_attempts: 3}
+ )
+
+ _ ->
+ Map.put(input, "attempt", attempt)
+ end
+ end,
+ name: :retry_step
+ )
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+ run_id = run.id
+
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run_id}")
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir,
+ idle_timeout_ms: 0
+ )
+
+ Worker.run(pid, %{"value" => "ok"})
+
+ assert_receive {:step_started, %{run_id: ^run_id, step_id: "retry_step", attempt: 0}},
+ 2_000
+
+ assert_receive {:step_failed,
+ %{
+ run_id: ^run_id,
+ step_id: "retry_step",
+ attempt: 0,
+ error: %{type: "step_execution_error"}
+ }},
+ 2_000
+
+ assert_receive {:run_status_changed, %{run_id: ^run_id, status: :sleeping}}, 2_000
+
+ assert {:ok,
+ %{
+ status: :sleeping,
+ error: %{
+ "type" => "step_retry",
+ "step_type_id" => "google_sheets_append_row",
+ "failed_attempt" => 1,
+ "next_attempt" => 1
+ }
+ }} = Workflows.get_run(scope, run_id)
+
+ assert {:ok, [timer]} =
+ Workflows.claim_due_timers(now: DateTime.utc_now(), claimed_by: "retry-test")
+
+ assert timer.run_id == run_id
+ assert :ok = Workflows.deliver_run_event(run_id, {:timer_fired, timer}, registry: registry)
+
+ assert_receive {:run_status_changed, %{run_id: ^run_id, status: :running}}, 2_000
+
+ assert_receive {:step_started, %{run_id: ^run_id, step_id: "retry_step", attempt: 1}},
+ 2_000
+
+ assert_receive {:step_completed, %{run_id: ^run_id, step_id: "retry_step", attempt: 1}},
+ 2_000
+
+ assert_receive {:run_status_changed, %{run_id: ^run_id, status: :completed}}, 2_000
+
+ assert {:ok,
+ %{
+ status: :completed,
+ error: nil,
+ output: %{"value" => [%{"attempt" => 1, "value" => "ok"}]}
+ }} =
+ Workflows.get_run(scope, run_id)
+
+ refute_receive {:run_status_changed, %{run_id: ^run_id, status: :failed}}, 100
+ end
+ end
+
+ describe "fail_and_stop/2 broadcasts :step_cancelled for active siblings" do
+ test "step fails with concurrent siblings -> causal step :failed, siblings :cancelled, run :failed",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+
+ # Build a workflow where :entry fans out to :sibling_a and :sibling_b
+ # Both siblings block, allowing us to kill one and observe the other getting cancelled
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(Runic.step(fn input -> input end, name: :entry))
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:sibling_a_running, self()})
+ receive do: (:release -> input)
+ end,
+ name: :sibling_a
+ ),
+ to: :entry
+ )
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:sibling_b_running, self()})
+ receive do: (:release -> input)
+ end,
+ name: :sibling_b
+ ),
+ to: :entry
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run.id}")
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir,
+ max_concurrency: 4
+ )
+
+ Worker.run(pid, %{})
+
+ # Wait for both siblings to be running
+ assert_receive {:sibling_a_running, sibling_a_pid}, 2_000
+ assert_receive {:sibling_b_running, _sibling_b_pid}, 2_000
+
+ # Flush any prior broadcast messages (entry step events)
+ flush_mailbox()
+
+ # Kill sibling_a to trigger fail_and_stop
+ Process.exit(sibling_a_pid, :kill)
+
+ # The causal step should get :step_failed
+ assert_receive {:step_failed, %{run_id: run_id}}, 5_000
+ assert run_id == run.id
+
+ # The sibling should get :step_cancelled
+ assert_receive {:step_cancelled, %{run_id: ^run_id, cancelled_at: %DateTime{}}}, 5_000
+
+ # Run should be failed
+ assert_receive {:run_status_changed, %{run_id: ^run_id, status: :failed}}, 5_000
+
+ assert {:ok, %{status: :failed}} = Workflows.get_run(scope, run.id)
+ end
+ end
+
+ describe "checkpoint failure resilience" do
+ test "checkpoint failure during fail_and_stop -> run still :failed in DB",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:task_running, self()})
+ receive do: (:release -> input)
+ end,
+ name: :blocking_step
+ )
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run.id}")
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ Worker.run(pid, %{})
+ assert_receive {:task_running, task_pid}, 2_000
+
+ # Corrupt the store directory to force checkpoint failure
+ File.rm_rf(tmp_dir)
+
+ # Kill the task to trigger fail_and_stop (which will try to checkpoint and fail)
+ Process.exit(task_pid, :kill)
+
+ assert_receive {:run_status_changed, %{status: :failed}}, 5_000
+
+ {:ok, db_run} = Workflows.get_run(scope, run.id)
+ assert db_run.status == :failed
+ end
+ end
+
+ describe "terminate/2 safety net" do
+ test "worker stopped via GenServer.stop marks run :failed in DB",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:task_running, self()})
+ receive do: (:release -> input)
+ end,
+ name: :blocking_step
+ )
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+
+ Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run.id}")
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ Worker.run(pid, %{})
+ assert_receive {:task_running, _task_pid}, 2_000
+
+ # GenServer.stop triggers terminate/2 with :normal reason
+ GenServer.stop(pid, :shutdown)
+
+ assert {:ok, %{status: :failed}} = Workflows.get_run(scope, run.id)
+ end
+ end
+
+ describe "cancellation from non-running states" do
+ test "cancel from :sleeping state -> run :cancelled",
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ %{version: version} = published_version_fixture(scope, long_running_snapshot_attrs(30_000))
+ {:ok, workflow, compiled_hash} = Compiler.compile(version)
+ run = insert_running_run(scope, version, %{compiled_hash: compiled_hash})
+
+ pid =
+ start_worker!(workflow, run, scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ Worker.run(pid, %{})
+
+ wait_until(fn ->
+ case Worker.lookup(run.id, registry: registry) do
+ nil -> false
+ wpid -> :sys.get_state(wpid).status == :sleeping
+ end
+ end)
+
+ assert {:ok, %{status: :cancelled}} = Workflows.cancel_run(scope, run.id)
+ end
+
+ test "cancel when no worker is running (passivated) -> run :cancelled",
+ %{scope: scope} do
+ # Simulate a passivated run: create a run in :sleeping status with no active worker
+ %{version: version} = published_version_fixture(scope, long_running_snapshot_attrs(30_000))
+ now = DateTime.utc_now()
+
+ run =
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :sleeping,
+ input: %{},
+ last_active_at: now,
+ started_at: now
+ })
+ |> Repo.insert!()
+
+ # No worker is running — cancel should still work
+ assert {:ok, %{status: :cancelled}} = Workflows.cancel_run(scope, run.id)
+ end
+ end
+
+ # --- Shared helpers ---
+
+ defp start_worker!(workflow, run, scope, opts) do
+ registry = Keyword.fetch!(opts, :registry)
+ runnable_dispatcher = Keyword.fetch!(opts, :runnable_dispatcher)
+ tmp_dir = Keyword.fetch!(opts, :tmp_dir)
+ fence_token = Keyword.get(opts, :fence_token, 1)
+
+ insert_lease(run.id, fence_token)
+
+ {:ok, store_state} =
+ SqliteStore.init(run.id,
+ data_dir: tmp_dir,
+ org_id: scope.project.workos_organization_id,
+ project_id: scope.project.id,
+ fence_token: fence_token,
+ repo: Repo
+ )
+
+ start_supervised!(
+ {Worker,
+ [
+ run_id: run.id,
+ workflow: workflow,
+ run_context: ContextBuilder.build_run_context(scope, run),
+ store: store_state,
+ fence_token: fence_token,
+ registry: registry,
+ runnable_dispatcher: runnable_dispatcher,
+ max_concurrency: Keyword.get(opts, :max_concurrency, 2),
+ checkpoint_strategy: :every_cycle,
+ idle_timeout_ms: Keyword.get(opts, :idle_timeout_ms, 5_000)
+ ]}
+ )
+ end
+
+ defp insert_running_run(scope, version, attrs) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(
+ Map.merge(
+ %{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :running,
+ input: %{},
+ last_active_at: now,
+ started_at: now
+ },
+ attrs
+ )
+ )
+ |> Repo.insert!()
+ end
+
+ defp insert_lease(run_id, fence_token) do
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ INSERT INTO workflow_run_leases (run_id, owner_node, fence_token, checkpoint_seq, lease_expiry)
+ VALUES ($1, NULL, $2, 0, NOW() + interval '30 seconds')
+ """,
+ [Ecto.UUID.dump!(run_id), fence_token]
+ )
+ end
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+
+ defp wait_until(fun, attempts \\ 100)
+
+ defp wait_until(fun, attempts) when attempts > 0 do
+ if fun.() do
+ :ok
+ else
+ Process.sleep(20)
+ wait_until(fun, attempts - 1)
+ end
+ end
+
+ defp wait_until(_fun, 0), do: flunk("wait_until timed out")
+
+ defp flush_mailbox do
+ receive do
+ _ -> flush_mailbox()
+ after
+ 50 -> :ok
+ end
+ end
+end
diff --git a/test/fizz/workflows/runner/worker_test.exs b/test/fizz/workflows/runner/worker_test.exs
new file mode 100644
index 0000000..8dfef60
--- /dev/null
+++ b/test/fizz/workflows/runner/worker_test.exs
@@ -0,0 +1,689 @@
+defmodule Fizz.Workflows.Runner.WorkerTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Workflows.Compiler
+ alias Fizz.Workflows.Runner.RunnableConsumerSupervisor
+ alias Fizz.Workflows.Runner.RunnableDispatcher
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.Runtime.ContextBuilder
+ alias Fizz.Workflows.Store.SqliteStore
+ alias Fizz.Workflows.WorkflowRun
+
+ require Runic
+
+ setup do
+ scope = project_scope_fixture()
+ registry = unique_name(:registry)
+ task_supervisor = unique_name(:task_supervisor)
+ runnable_dispatcher = unique_name(:runnable_dispatcher)
+ runnable_consumer_supervisor = unique_name(:runnable_consumer_supervisor)
+
+ tmp_dir =
+ Path.join(System.tmp_dir!(), "fizz-worker-#{System.unique_integer([:positive])}")
+
+ start_supervised!({Registry, keys: :unique, name: registry})
+ start_supervised!({Task.Supervisor, name: task_supervisor})
+ start_supervised!({RunnableDispatcher, name: runnable_dispatcher})
+
+ start_supervised!(
+ {RunnableConsumerSupervisor,
+ name: runnable_consumer_supervisor,
+ dispatcher: runnable_dispatcher,
+ task_supervisor: task_supervisor,
+ max_concurrency: 4}
+ )
+
+ on_exit(fn -> File.rm_rf(tmp_dir) end)
+
+ %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ }
+ end
+
+ test "starts with a compiled workflow and runs to completion", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ %{version: version} = published_version_fixture(scope)
+ {:ok, workflow, compiled_hash} = Compiler.compile(version)
+
+ run =
+ insert_running_run(scope, version, %{
+ compiled_hash: compiled_hash,
+ last_active_at: old_time()
+ })
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ assert :ok = Worker.run(pid, %{"name" => "Ada"})
+
+ assert %{status: :completed} = wait_for_run_status(scope, run.id, registry)
+ cleanup_worker(run.id, registry)
+
+ assert {:ok, completed_run} = Fizz.Workflows.get_run(scope, run.id)
+ assert completed_run.status == :completed
+ assert completed_run.output != nil
+ end
+
+ test "updates last_active_at on step completion", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ %{version: version} = published_version_fixture(scope)
+ {:ok, workflow, compiled_hash} = Compiler.compile(version)
+ last_active_at = old_time()
+
+ run =
+ insert_running_run(scope, version, %{
+ compiled_hash: compiled_hash,
+ last_active_at: last_active_at
+ })
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ assert :ok = Worker.run(pid, %{"name" => "Grace"})
+
+ assert %{status: :completed} = wait_for_run_status(scope, run.id, registry)
+ cleanup_worker(run.id, registry)
+
+ assert {:ok, completed_run} = Fizz.Workflows.get_run(scope, run.id)
+ assert DateTime.compare(completed_run.last_active_at, last_active_at) == :gt
+ end
+
+ test "broadcasts step lifecycle and run status events", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ %{version: version} = published_version_fixture(scope)
+ {:ok, workflow, compiled_hash} = Compiler.compile(version)
+ run = insert_running_run(scope, version, %{compiled_hash: compiled_hash})
+ run_id = run.id
+
+ :ok = Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run_id}")
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ assert :ok = Worker.run(pid, %{"name" => "Toni"})
+
+ assert_receive {:run_status_changed,
+ %{run_id: ^run_id, status: :running, timestamp: %DateTime{}}},
+ 2_000
+
+ assert_receive {:step_started,
+ %{
+ run_id: ^run_id,
+ step_id: step_id,
+ started_at: %DateTime{},
+ attempt: 0,
+ input_summary: input_summary
+ } = started_payload},
+ 2_000
+
+ assert is_binary(step_id)
+ assert is_binary(input_summary)
+ refute Map.has_key?(started_payload, :input)
+
+ assert_receive {:step_completed,
+ %{
+ run_id: ^run_id,
+ step_id: ^step_id,
+ completed_at: %DateTime{},
+ duration_us: duration_us,
+ output_item_count: output_item_count,
+ output_summary: output_summary
+ } = completed_payload},
+ 2_000
+
+ assert is_integer(duration_us)
+ assert output_item_count == 1
+ assert is_binary(output_summary)
+ refute Map.has_key?(completed_payload, :input)
+ refute Map.has_key?(completed_payload, :output)
+
+ assert_receive {:run_status_changed,
+ %{run_id: ^run_id, status: :completed, timestamp: %DateTime{}}},
+ 2_000
+
+ assert %{status: :completed} = wait_for_run_status(scope, run_id, registry)
+ cleanup_worker(run_id, registry)
+ end
+
+ test "step lifecycle payloads summarize large values", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(fn _input -> Enum.to_list(1..5_000) end, name: :large_output)
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{compiled_hash: nil})
+ run_id = run.id
+
+ :ok = Phoenix.PubSub.subscribe(Fizz.PubSub, "workflow_run:#{run_id}")
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ assert :ok = Worker.run(pid, %{"large" => Enum.to_list(1..5_000)})
+
+ assert_receive {:step_started, %{input_summary: input_summary} = started_payload}, 2_000
+ assert byte_size(input_summary) <= 1_027
+ refute Map.has_key?(started_payload, :input)
+
+ assert_receive {:step_completed, %{output_summary: output_summary} = completed_payload}, 2_000
+ assert byte_size(output_summary) <= 1_027
+ refute Map.has_key?(completed_payload, :input)
+ refute Map.has_key?(completed_payload, :output)
+ refute String.contains?(output_summary, "5000")
+
+ assert %{status: :completed} = wait_for_run_status(scope, run_id, registry)
+ cleanup_worker(run_id, registry)
+ end
+
+ test "transitions the run to completed when the workflow is satisfied", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ %{version: version} = published_version_fixture(scope)
+ {:ok, workflow, compiled_hash} = Compiler.compile(version)
+ run = insert_running_run(scope, version, %{compiled_hash: compiled_hash})
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ )
+
+ assert :ok = Worker.run(pid, %{"name" => "Lin"})
+ assert %{status: :completed} = wait_for_run_status(scope, run.id, registry)
+ cleanup_worker(run.id, registry)
+ assert {:ok, %{status: :completed}} = Fizz.Workflows.get_run(scope, run.id)
+ end
+
+ test "defers dispatch when max_concurrency is reached", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:step_started, :one, self()})
+
+ receive do
+ :release -> input
+ end
+ end,
+ name: :one
+ )
+ )
+ |> Runic.Workflow.add(
+ Runic.step(
+ fn input ->
+ send(test_pid, {:step_started, :two, self()})
+
+ receive do
+ :release -> input
+ end
+ end,
+ name: :two
+ ),
+ to: :one
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{})
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: runnable_dispatcher,
+ tmp_dir: tmp_dir,
+ max_concurrency: 1
+ )
+
+ assert :ok = Worker.run(pid, %{"value" => 1})
+
+ assert_receive {:step_started, :one, first_task_pid}, 2_000
+
+ state = :sys.get_state(pid)
+ assert map_size(state.active_tasks) == 1
+ refute_receive {:step_started, :two, _pid}, 100
+
+ send(first_task_pid, :release)
+
+ assert_receive {:step_started, :two, second_task_pid}, 2_000
+ send(second_task_pid, :release)
+
+ assert %{status: :completed} = wait_for_run_status(scope, run.id, registry)
+ cleanup_worker(run.id, registry)
+ end
+
+ test "global saturation defers runnable execution without crashing", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+ limited_dispatcher = unique_name(:limited_runnable_dispatcher)
+ limited_consumer_supervisor = unique_name(:limited_runnable_consumer_supervisor)
+
+ start_supervised!(
+ Supervisor.child_spec({RunnableDispatcher, name: limited_dispatcher},
+ id: limited_dispatcher
+ )
+ )
+
+ start_supervised!(
+ Supervisor.child_spec(
+ {RunnableConsumerSupervisor,
+ name: limited_consumer_supervisor,
+ dispatcher: limited_dispatcher,
+ task_supervisor: task_supervisor,
+ max_concurrency: 1},
+ id: limited_consumer_supervisor
+ )
+ )
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(blocking_step(:one, test_pid))
+ |> Runic.Workflow.add(blocking_step(:two, test_pid))
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{})
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: limited_dispatcher,
+ tmp_dir: tmp_dir,
+ max_concurrency: 2
+ )
+
+ assert :ok = Worker.run(pid, %{"value" => 1})
+
+ assert_receive {:step_started, first_step, first_task_pid}, 2_000
+ refute_receive {:step_started, _second_step, _second_task_pid}, 100
+
+ send(first_task_pid, :release)
+
+ assert_receive {:step_started, second_step, second_task_pid}, 2_000
+ assert first_step != second_step
+
+ send(second_task_pid, :release)
+
+ assert %{status: :completed} = wait_for_run_status(scope, run.id, registry)
+ cleanup_worker(run.id, registry)
+ end
+
+ test "multiple runs share global runnable capacity fairly", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+ limited_dispatcher = unique_name(:fair_runnable_dispatcher)
+ limited_consumer_supervisor = unique_name(:fair_runnable_consumer_supervisor)
+
+ start_supervised!(
+ Supervisor.child_spec({RunnableDispatcher, name: limited_dispatcher},
+ id: limited_dispatcher
+ )
+ )
+
+ start_supervised!(
+ Supervisor.child_spec(
+ {RunnableConsumerSupervisor,
+ name: limited_consumer_supervisor,
+ dispatcher: limited_dispatcher,
+ task_supervisor: task_supervisor,
+ max_concurrency: 1},
+ id: limited_consumer_supervisor
+ )
+ )
+
+ %{version: version} = published_version_fixture(scope)
+ run_a = insert_running_run(scope, version, %{})
+ run_b = insert_running_run(scope, version, %{})
+ run_a_id = run_a.id
+ run_b_id = run_b.id
+
+ workflow_a =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(blocking_step(:a_one, test_pid, {run_a_id, :one}))
+ |> Runic.Workflow.add(blocking_step(:a_two, test_pid, {run_a_id, :two}))
+
+ workflow_b =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(blocking_step(:b_one, test_pid, {run_b_id, :one}))
+
+ pid_a =
+ start_worker!(
+ workflow_a,
+ run_a,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: limited_dispatcher,
+ tmp_dir: tmp_dir,
+ max_concurrency: 2
+ )
+
+ pid_b =
+ start_worker!(
+ workflow_b,
+ run_b,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: limited_dispatcher,
+ tmp_dir: tmp_dir,
+ max_concurrency: 1
+ )
+
+ assert :ok = Worker.run(pid_a, %{"value" => "a"})
+ assert_receive {:step_started, {^run_a_id, _first_step}, first_task_pid}, 2_000
+
+ assert :ok = Worker.run(pid_b, %{"value" => "b"})
+ refute_receive {:step_started, {^run_b_id, :one}, _pid}, 100
+
+ send(first_task_pid, :release)
+
+ assert_receive {:step_started, next_label, run_b_task_pid}, 2_000
+ assert next_label == {run_b_id, :one}
+
+ send(run_b_task_pid, :release)
+
+ assert_receive {:step_started, final_label, run_a_task_pid}, 2_000
+ assert elem(final_label, 0) == run_a_id
+
+ send(run_a_task_pid, :release)
+
+ assert %{status: :completed} = wait_for_run_status(scope, run_a_id, registry)
+ assert %{status: :completed} = wait_for_run_status(scope, run_b_id, registry)
+ cleanup_worker(run_a_id, registry)
+ cleanup_worker(run_b_id, registry)
+ end
+
+ test "worker shutdown cancels running work and drops queued work", %{
+ scope: scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ tmp_dir: tmp_dir
+ } do
+ test_pid = self()
+ limited_dispatcher = unique_name(:shutdown_runnable_dispatcher)
+ limited_consumer_supervisor = unique_name(:shutdown_runnable_consumer_supervisor)
+
+ start_supervised!(
+ Supervisor.child_spec({RunnableDispatcher, name: limited_dispatcher},
+ id: limited_dispatcher
+ )
+ )
+
+ start_supervised!(
+ Supervisor.child_spec(
+ {RunnableConsumerSupervisor,
+ name: limited_consumer_supervisor,
+ dispatcher: limited_dispatcher,
+ task_supervisor: task_supervisor,
+ max_concurrency: 1},
+ id: limited_consumer_supervisor
+ )
+ )
+
+ workflow =
+ Runic.Workflow.new()
+ |> Runic.Workflow.add(blocking_step(:one, test_pid))
+ |> Runic.Workflow.add(blocking_step(:two, test_pid))
+
+ %{version: version} = published_version_fixture(scope)
+ run = insert_running_run(scope, version, %{})
+
+ pid =
+ start_worker!(
+ workflow,
+ run,
+ scope,
+ registry: registry,
+ task_supervisor: task_supervisor,
+ runnable_dispatcher: limited_dispatcher,
+ tmp_dir: tmp_dir,
+ max_concurrency: 2
+ )
+
+ assert :ok = Worker.run(pid, %{"value" => 1})
+ assert_receive {:step_started, :one, first_task_pid}, 2_000
+
+ worker_ref = Process.monitor(pid)
+ task_ref = Process.monitor(first_task_pid)
+
+ assert :ok = Worker.stop(pid, persist: false)
+ assert_receive {:DOWN, ^worker_ref, :process, ^pid, _reason}, 2_000
+ assert_receive {:DOWN, ^task_ref, :process, ^first_task_pid, _reason}, 2_000
+ refute_receive {:step_started, :two, _pid}, 200
+ end
+
+ defp start_worker!(workflow, run, scope, opts) do
+ registry = Keyword.fetch!(opts, :registry)
+ runnable_dispatcher = Keyword.fetch!(opts, :runnable_dispatcher)
+ tmp_dir = Keyword.fetch!(opts, :tmp_dir)
+ fence_token = Keyword.get(opts, :fence_token, 1)
+
+ insert_lease(run.id, fence_token)
+
+ {:ok, store_state} =
+ SqliteStore.init(run.id,
+ data_dir: tmp_dir,
+ org_id: scope.project.workos_organization_id,
+ project_id: scope.project.id,
+ fence_token: fence_token,
+ repo: Repo
+ )
+
+ start_supervised!(
+ {Worker,
+ [
+ run_id: run.id,
+ workflow: workflow,
+ run_context: ContextBuilder.build_run_context(scope, run),
+ store: store_state,
+ fence_token: fence_token,
+ registry: registry,
+ runnable_dispatcher: runnable_dispatcher,
+ max_concurrency: Keyword.get(opts, :max_concurrency, 2),
+ checkpoint_strategy: :every_cycle,
+ idle_timeout_ms: 5_000
+ ]}
+ )
+ end
+
+ defp insert_running_run(scope, version, attrs) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(
+ Map.merge(
+ %{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :running,
+ input: %{},
+ last_active_at: now,
+ started_at: now
+ },
+ attrs
+ )
+ )
+ |> Repo.insert!()
+ end
+
+ defp insert_lease(run_id, fence_token) do
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ INSERT INTO workflow_run_leases (run_id, owner_node, fence_token, checkpoint_seq, lease_expiry)
+ VALUES ($1, NULL, $2, 0, NOW() + interval '30 seconds')
+ """,
+ [dump_uuid(run_id), fence_token]
+ )
+ end
+
+ defp dump_uuid(run_id), do: Ecto.UUID.dump!(run_id)
+
+ defp old_time do
+ DateTime.add(DateTime.utc_now(), -10, :minute)
+ end
+
+ defp blocking_step(name, test_pid), do: blocking_step(name, test_pid, name)
+
+ defp blocking_step(name, test_pid, label) do
+ Runic.step(
+ fn input ->
+ send(test_pid, {:step_started, label, self()})
+
+ receive do
+ :release -> input
+ end
+ end,
+ name: name
+ )
+ end
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+
+ defp wait_for_run_status(scope, run_id, registry, attempts \\ 100)
+
+ defp wait_for_run_status(scope, run_id, registry, attempts) when attempts > 0 do
+ case Fizz.Workflows.get_run(scope, run_id) do
+ {:ok, %{status: :completed} = run} ->
+ run
+
+ _ ->
+ receive do
+ after
+ 20 -> wait_for_run_status(scope, run_id, registry, attempts - 1)
+ end
+ end
+ end
+
+ defp wait_for_run_status(_scope, run_id, registry, 0) do
+ case Worker.lookup(run_id, registry: registry) do
+ nil -> :ok
+ _pid -> :ok
+ end
+
+ flunk("run did not reach completed status")
+ end
+
+ defp cleanup_worker(run_id, registry) do
+ case Worker.lookup(run_id, registry: registry) do
+ nil ->
+ :ok
+
+ pid ->
+ ref = Process.monitor(pid)
+
+ receive do
+ {:DOWN, ^ref, :process, ^pid, _reason} ->
+ :ok
+ after
+ 0 ->
+ try do
+ Worker.stop(pid, persist: false)
+ catch
+ :exit, _reason -> :ok
+ end
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 2_000
+ end
+ end
+ end
+end
diff --git a/test/fizz/workflows/runtime/config_resolver_test.exs b/test/fizz/workflows/runtime/config_resolver_test.exs
new file mode 100644
index 0000000..802ce0e
--- /dev/null
+++ b/test/fizz/workflows/runtime/config_resolver_test.exs
@@ -0,0 +1,99 @@
+defmodule Fizz.Workflows.Runtime.ConfigResolverTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.Expressions
+ alias Fizz.Workflows.Expressions.AccessPlan
+ alias Fizz.Workflows.Runtime.ConfigResolver
+
+ test "resolve_config preserves native values for input expressions" do
+ compiled_config = %{"orders" => access_plan!("{{ input.orders }}")}
+ orders = [%{"id" => 1}, %{"id" => 2}]
+
+ assert ConfigResolver.resolve_config(compiled_config, context(%{"orders" => orders}, %{})) ==
+ %{"orders" => orders}
+ end
+
+ test "resolve_config preserves native values for json aliases" do
+ compiled_config = %{"orders" => access_plan!("{{ json.orders }}")}
+ orders = [%{"id" => 1}, %{"id" => 2}]
+
+ assert ConfigResolver.resolve_config(compiled_config, context(%{"orders" => orders}, %{})) ==
+ %{"orders" => orders}
+ end
+
+ test "resolve_config renders template expressions to strings" do
+ compiled_config = %{"message" => access_plan!("Hello {{ input.name }}")}
+
+ assert ConfigResolver.resolve_config(compiled_config, context(%{"name" => "Ada"}, %{})) == %{
+ "message" => "Hello Ada"
+ }
+ end
+
+ test "resolve_config reads step outputs using stable uuid references" do
+ step_id = Ecto.UUID.generate()
+
+ compiled_config = %{
+ "value" => access_plan!("{{ steps.#{step_id}.body }}", known_step_ids: [step_id])
+ }
+
+ resolved =
+ ConfigResolver.resolve_config(
+ compiled_config,
+ context(%{}, %{step_id => %{"body" => %{"ok" => true}}})
+ )
+
+ assert resolved == %{"value" => %{"ok" => true}}
+ end
+
+ test "resolve_config reads step outputs using named references" do
+ step_id = Ecto.UUID.generate()
+
+ compiled_config = %{
+ "value" =>
+ access_plan!(~s({{ steps["Manual Trigger"].body }}),
+ known_step_ids: [step_id],
+ step_name_to_id: %{"Manual Trigger" => step_id}
+ )
+ }
+
+ resolved =
+ ConfigResolver.resolve_config(
+ compiled_config,
+ context(%{}, %{step_id => %{"body" => %{"ok" => true}}})
+ )
+
+ assert resolved == %{"value" => %{"ok" => true}}
+ end
+
+ test "fast path resolves simple lookups without invoking Solid" do
+ compiled_config = %{
+ "orders" => %AccessPlan.ValueExpression{
+ path: ["input", "orders"],
+ parsed: :unused,
+ filters: []
+ }
+ }
+
+ assert ConfigResolver.resolve_config(compiled_config, context(%{"orders" => [1, 2, 3]}, %{})) ==
+ %{"orders" => [1, 2, 3]}
+ end
+
+ defp access_plan!(expression, opts \\ []) do
+ {:ok, plan} =
+ Expressions.to_access_plan(
+ expression,
+ Keyword.merge([strict_filters: true, known_step_ids: []], opts)
+ )
+
+ plan
+ end
+
+ defp context(input, steps) do
+ %{
+ input: input,
+ steps: steps,
+ workflow: %{},
+ env: %{}
+ }
+ end
+end
diff --git a/test/fizz/workflows/runtime/context_builder_test.exs b/test/fizz/workflows/runtime/context_builder_test.exs
new file mode 100644
index 0000000..df564a4
--- /dev/null
+++ b/test/fizz/workflows/runtime/context_builder_test.exs
@@ -0,0 +1,43 @@
+defmodule Fizz.Workflows.Runtime.ContextBuilderTest do
+ use Fizz.DataCase, async: true
+
+ alias Fizz.AccountsFixtures
+ alias Fizz.Workflows.Runtime.ContextBuilder
+ alias Runic.Workflow
+
+ test "exposes credential resolver through Runic global context" do
+ user = AccountsFixtures.user_fixture()
+ scope = AccountsFixtures.organization_scope_fixture(user: user)
+
+ run_attrs = %{
+ id: Ecto.UUID.generate(),
+ user_id: user.id,
+ workflow_definition_id: Ecto.UUID.generate(),
+ workflow_definition_version_id: Ecto.UUID.generate(),
+ project_id: Ecto.UUID.generate(),
+ workos_organization_id: scope.organization_id,
+ compiled_hash: "compiled"
+ }
+
+ context = ContextBuilder.build_run_context(scope, run_attrs)
+
+ assert is_function(context._credential_resolver, 4)
+ assert is_function(context._global._credential_resolver, 4)
+ assert context.user_id == user.id
+ assert context.project_id == run_attrs.project_id
+ assert context.workos_organization_id == scope.organization_id
+ assert %Fizz.Workflows.ExecutionContext{} = context.execution_context
+ assert context.execution_context.scope == scope
+ assert context.execution_context.project_id == run_attrs.project_id
+
+ workflow =
+ Workflow.new(name: "context-test")
+ |> Workflow.put_run_context(context)
+
+ run_context = Workflow.get_run_context(workflow, "any-step")
+
+ assert is_function(run_context._credential_resolver, 4)
+ assert run_context.workflow == context.workflow
+ assert run_context.user_id == user.id
+ end
+end
diff --git a/test/fizz/workflows/signal_router_test.exs b/test/fizz/workflows/signal_router_test.exs
new file mode 100644
index 0000000..1f7add6
--- /dev/null
+++ b/test/fizz/workflows/signal_router_test.exs
@@ -0,0 +1,340 @@
+defmodule Fizz.Workflows.SignalRouterTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Workflows
+ alias Fizz.Workflows.DurableTimer
+ alias Fizz.Workflows.PassivationSweeper
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.SignalInbox
+ alias Fizz.Workflows.SignalRouter
+ alias Fizz.Workflows.WorkflowRun
+
+ setup do
+ scope = project_scope_fixture()
+ original_workflow_env = Application.get_env(:fizz, Fizz.Workflows, [])
+
+ on_exit(fn ->
+ Application.put_env(:fizz, Fizz.Workflows, original_workflow_env)
+ end)
+
+ %{scope: scope}
+ end
+
+ test "accepts a signal into the inbox", %{scope: scope} do
+ run = insert_run(scope, :running)
+
+ assert {:ok, %SignalInbox{} = signal} =
+ SignalRouter.accept_signal(run.id, "sig-1", "poke", %{"kind" => "test"})
+
+ assert signal.run_id == run.id
+ assert signal.signal_id == "sig-1"
+ assert signal.status == :pending
+ assert signal_count(run.id, "sig-1") == 1
+ end
+
+ test "duplicate (run_id, signal_id) collapses to one record", %{scope: scope} do
+ run = insert_run(scope, :running)
+
+ assert {:ok, %SignalInbox{id: first_id}} =
+ SignalRouter.accept_signal(run.id, "sig-1", "poke", %{"kind" => "test"})
+
+ assert {:ok, %SignalInbox{id: second_id}} =
+ SignalRouter.accept_signal(run.id, "sig-1", "poke", %{"kind" => "test"})
+
+ assert first_id == second_id
+ assert signal_count(run.id, "sig-1") == 1
+ end
+
+ test "same signal_id on different runs is accepted independently", %{scope: scope} do
+ run_a = insert_run(scope, :running)
+ run_b = insert_run(scope, :running)
+
+ assert {:ok, %SignalInbox{run_id: run_a_id}} =
+ SignalRouter.accept_signal(run_a.id, "shared-id", "poke", %{"run" => "a"})
+
+ assert {:ok, %SignalInbox{run_id: run_b_id}} =
+ SignalRouter.accept_signal(run_b.id, "shared-id", "poke", %{"run" => "b"})
+
+ assert run_a_id == run_a.id
+ assert run_b_id == run_b.id
+ assert signal_count(run_a.id, "shared-id") == 1
+ assert signal_count(run_b.id, "shared-id") == 1
+ end
+
+ test "signal to a terminal run is recorded but skipped", %{scope: scope} do
+ run = insert_run(scope, :completed)
+
+ assert {:ok, %SignalInbox{} = signal} =
+ SignalRouter.accept_signal(run.id, "sig-terminal", "poke", %{"kind" => "terminal"})
+
+ assert signal.status == :skipped
+ end
+
+ test "different signal_id values with the same payload remain distinct", %{scope: scope} do
+ run = insert_run(scope, :running)
+
+ assert {:ok, %SignalInbox{id: first_id}} =
+ SignalRouter.accept_signal(run.id, "sig-a", "poke", %{"kind" => "same"})
+
+ assert {:ok, %SignalInbox{id: second_id}} =
+ SignalRouter.accept_signal(run.id, "sig-b", "poke", %{"kind" => "same"})
+
+ assert first_id != second_id
+ assert signal_count_for_run(run.id) == 2
+ end
+
+ test "external signal delivered to a running workflow triggers a step", %{scope: scope} do
+ put_workflow_runtime(idle_timeout_ms: 1_000)
+
+ %{version: version} =
+ published_version_fixture(scope, long_running_snapshot_attrs(100))
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"kind" => "initial"})
+
+ _timer =
+ eventually(fn ->
+ case pending_timers(run.id) do
+ [_ | _] -> {:ok, :ready}
+ _ -> :retry
+ end
+ end)
+
+ assert {:ok, %SignalInbox{id: signal_row_id}} =
+ SignalRouter.accept_signal(run.id, "sig-running", "poke", %{"kind" => "signal"})
+
+ delivered_signal =
+ eventually(fn ->
+ case Workflows.get_signal(signal_row_id) do
+ {:ok, %SignalInbox{status: :delivered} = signal} -> {:ok, signal}
+ _ -> :retry
+ end
+ end)
+
+ assert delivered_signal.status == :delivered
+
+ completed_run =
+ eventually(fn ->
+ with {:ok, %WorkflowRun{status: :completed} = workflow_run} <-
+ Workflows.get_run(scope, run.id) do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ outputs = completed_run.output["value"]
+ assert is_list(outputs)
+
+ delivered_outputs =
+ Enum.filter(outputs, fn
+ %{"type" => "signal", "signal_id" => "sig-running"} -> true
+ _ -> false
+ end)
+
+ assert length(delivered_outputs) >= 1
+ assert_worker_shutdown(run.id)
+ end
+
+ test "signal to a passivated workflow wakes the worker", %{scope: scope} do
+ put_workflow_runtime(idle_timeout_ms: 25)
+
+ %{version: version} =
+ published_version_fixture(scope, long_running_snapshot_attrs(200))
+
+ sweeper = unique_name(:sweeper)
+
+ start_supervised!(
+ {PassivationSweeper, name: sweeper, interval_ms: 60_000, idle_threshold_ms: 25}
+ )
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"kind" => "initial"})
+
+ assert eventually(fn ->
+ case {pending_timers(run.id), Workflows.get_run(scope, run.id)} do
+ {[_ | _], {:ok, %WorkflowRun{status: :sleeping} = workflow_run}} ->
+ {:ok, workflow_run}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ worker_pid =
+ eventually(fn ->
+ case Worker.lookup(run.id) do
+ nil -> :retry
+ pid -> {:ok, pid}
+ end
+ end)
+
+ ref = Process.monitor(worker_pid)
+
+ run_ids =
+ eventually(fn ->
+ case PassivationSweeper.sweep(server: sweeper) do
+ {:ok, run_ids} ->
+ if run.id in run_ids, do: {:ok, run_ids}, else: :retry
+
+ _ ->
+ :retry
+ end
+ end)
+
+ assert run.id in run_ids
+ assert_receive {:DOWN, ^ref, :process, ^worker_pid, :normal}, 2_000
+ assert {:ok, %{status: :passivated}} = Workflows.get_run(scope, run.id)
+
+ assert {:ok, %SignalInbox{id: signal_row_id}} =
+ SignalRouter.accept_signal(run.id, "sig-passive", "poke", %{"kind" => "signal"})
+
+ delivered_signal =
+ eventually(fn ->
+ case Workflows.get_signal(signal_row_id) do
+ {:ok, %SignalInbox{status: :delivered} = signal} -> {:ok, signal}
+ _ -> :retry
+ end
+ end)
+
+ assert delivered_signal.status == :delivered
+
+ assert eventually(fn ->
+ case Worker.lookup(run.id) do
+ nil -> :retry
+ pid when is_pid(pid) -> {:ok, pid}
+ end
+ end)
+
+ assert eventually(fn ->
+ case Workflows.get_run(scope, run.id) do
+ {:ok, %WorkflowRun{status: status} = workflow_run}
+ when status in [:running, :sleeping] ->
+ {:ok, workflow_run}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ assert {:ok, _cancelled_run} = Workflows.cancel_run(scope, run.id)
+ end
+
+ test "pending signal claims are disjoint and recover stale deliveries", %{scope: scope} do
+ run = insert_run(scope, :running)
+
+ signal_ids =
+ for index <- 1..4 do
+ {:ok, signal} =
+ Workflows.create_signal_inbox(run.id, "claim-#{index}", "poke", %{"index" => index})
+
+ signal.id
+ end
+
+ task_a =
+ Task.async(fn ->
+ Workflows.claim_pending_signals(limit: 2, claimed_by: "router-a")
+ end)
+
+ task_b =
+ Task.async(fn ->
+ Workflows.claim_pending_signals(limit: 2, claimed_by: "router-b")
+ end)
+
+ assert {:ok, claimed_a} = Task.await(task_a, 2_000)
+ assert {:ok, claimed_b} = Task.await(task_b, 2_000)
+
+ claimed_a_ids = Enum.map(claimed_a, & &1.id)
+ claimed_b_ids = Enum.map(claimed_b, & &1.id)
+
+ assert length(claimed_a_ids) == 2
+ assert length(claimed_b_ids) == 2
+ assert MapSet.disjoint?(MapSet.new(claimed_a_ids), MapSet.new(claimed_b_ids))
+ assert MapSet.new(claimed_a_ids ++ claimed_b_ids) == MapSet.new(signal_ids)
+
+ assert {:ok, 4} = Workflows.recover_stale_signals(claim_ttl_ms: 0)
+
+ statuses =
+ Repo.all(from(signal in SignalInbox, where: signal.id in ^signal_ids))
+ |> Enum.map(& &1.status)
+
+ assert Enum.all?(statuses, &(&1 == :pending))
+ end
+
+ defp put_workflow_runtime(opts) do
+ current = Application.get_env(:fizz, Fizz.Workflows, [])
+ Application.put_env(:fizz, Fizz.Workflows, Keyword.merge(current, opts))
+ end
+
+ defp insert_run(scope, status) do
+ now = DateTime.utc_now()
+ %{version: version} = published_version_fixture(scope)
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: status,
+ input: %{},
+ last_active_at: now,
+ started_at: now,
+ completed_at: if(status == :completed, do: now)
+ })
+ |> Repo.insert!()
+ end
+
+ defp signal_count(run_id, signal_id) do
+ SignalInbox
+ |> where([signal], signal.run_id == ^run_id and signal.signal_id == ^signal_id)
+ |> Repo.aggregate(:count)
+ end
+
+ defp signal_count_for_run(run_id) do
+ SignalInbox
+ |> where([signal], signal.run_id == ^run_id)
+ |> Repo.aggregate(:count)
+ end
+
+ defp pending_timers(run_id) do
+ Repo.all(
+ from(timer in DurableTimer,
+ where: timer.run_id == ^run_id and timer.status == :pending
+ )
+ )
+ end
+
+ defp eventually(fun, attempts \\ 100)
+
+ defp eventually(fun, attempts) when attempts > 0 do
+ case fun.() do
+ {:ok, value} ->
+ value
+
+ :retry ->
+ receive do
+ after
+ 20 -> eventually(fun, attempts - 1)
+ end
+ end
+ end
+
+ defp eventually(_fun, 0), do: flunk("condition was not met in time")
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+
+ defp assert_worker_shutdown(run_id) do
+ case Worker.lookup(run_id) do
+ nil ->
+ :ok
+
+ pid ->
+ ref = Process.monitor(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+ end
+ end
+end
diff --git a/test/fizz/workflows/step_error_test.exs b/test/fizz/workflows/step_error_test.exs
new file mode 100644
index 0000000..a548d57
--- /dev/null
+++ b/test/fizz/workflows/step_error_test.exs
@@ -0,0 +1,85 @@
+defmodule Fizz.Workflows.StepErrorTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.StepError
+
+ describe "http/2" do
+ test "normalizes rate-limit responses with retry metadata" do
+ error =
+ StepError.http(%{
+ status: 429,
+ headers: [{"retry-after", "7"}],
+ body: %{"error" => %{"message" => "slow down"}}
+ })
+
+ assert %StepError{} = error
+ assert error.code == :rate_limited
+ assert error.category == :rate_limit
+ assert error.status == 429
+ assert error.retry_after_ms == 7_000
+ assert error.retryable?
+ assert error.message == "slow down"
+ end
+
+ test "normalizes transient provider responses" do
+ error =
+ StepError.http(%{
+ status: 503,
+ headers: [],
+ body: %{"error" => %{"message" => "temporarily unavailable"}}
+ })
+
+ assert error.code == :provider_unavailable
+ assert error.category == :transient
+ assert error.status == 503
+ assert error.retryable?
+ end
+
+ test "normalizes auth and permission responses as non-retryable" do
+ unauthorized = StepError.http(%{status: 401, headers: [], body: %{}})
+ forbidden = StepError.http(%{status: 403, headers: [], body: %{}})
+
+ assert unauthorized.code == :unauthorized
+ assert unauthorized.category == :auth
+ refute unauthorized.retryable?
+
+ assert forbidden.code == :forbidden
+ assert forbidden.category == :permission
+ refute forbidden.retryable?
+ end
+ end
+
+ describe "normalize/2" do
+ test "keeps existing step errors intact" do
+ error = StepError.new(code: :custom, category: :validation, message: "bad input")
+
+ assert StepError.normalize(error) == error
+ end
+
+ test "turns credential and validation atoms into step errors" do
+ credential_error = StepError.normalize(:credential_ref_required)
+ invalid_row_error = StepError.normalize(:invalid_row_values)
+
+ assert credential_error.code == :credential_ref_required
+ assert credential_error.category == :credential
+ assert credential_error.message =~ "credential"
+
+ assert invalid_row_error.code == :invalid_row_values
+ assert invalid_row_error.category == :validation
+ refute invalid_row_error.retryable?
+ end
+
+ test "normalizes missing param and context tuples" do
+ missing_param = StepError.normalize({:missing_param, "spreadsheet_id"})
+ missing_context = StepError.normalize({:missing_context, :project_id})
+
+ assert missing_param.code == :missing_param
+ assert missing_param.category == :validation
+ assert missing_param.details == %{param: "spreadsheet_id"}
+
+ assert missing_context.code == :missing_context
+ assert missing_context.category == :validation
+ assert missing_context.details == %{context_key: :project_id}
+ end
+ end
+end
diff --git a/test/fizz/workflows/store/litestream_manager_test.exs b/test/fizz/workflows/store/litestream_manager_test.exs
new file mode 100644
index 0000000..4cfa18a
--- /dev/null
+++ b/test/fizz/workflows/store/litestream_manager_test.exs
@@ -0,0 +1,261 @@
+defmodule Fizz.Workflows.Store.LitestreamManagerTest do
+ use ExUnit.Case, async: true
+
+ alias Fizz.Workflows.Store.LitestreamManager
+ alias Fizz.Workflows.Store.Sqlite
+
+ setup do
+ tmp_dir =
+ Path.join(
+ System.tmp_dir!(),
+ "fizz-litestream-#{System.unique_integer([:positive])}"
+ )
+
+ File.mkdir_p!(tmp_dir)
+ on_exit(fn -> File.rm_rf(tmp_dir) end)
+
+ %{tmp_dir: tmp_dir}
+ end
+
+ test "config generation produces directory-mode replication YAML", %{tmp_dir: tmp_dir} do
+ assert {:ok, config_path} =
+ LitestreamManager.generate_config(
+ data_dir: tmp_dir,
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ aws_region: "us-east-1"
+ )
+
+ content = File.read!(config_path)
+
+ assert content =~ "dir: '#{Path.expand(tmp_dir)}'"
+ assert content =~ "pattern: \"*.sqlite\""
+ assert content =~ "recursive: true"
+ assert content =~ "watch: true"
+ assert content =~ "bucket: 'bucket'"
+ assert content =~ "path: 'workflows'"
+ end
+
+ test "config generation includes endpoint for s3-compatible services", %{tmp_dir: tmp_dir} do
+ assert {:ok, config_path} =
+ LitestreamManager.generate_config(
+ data_dir: tmp_dir,
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ aws_region: "us-east-1",
+ s3_endpoint: "http://127.0.0.1:9000"
+ )
+
+ content = File.read!(config_path)
+
+ assert content =~ "endpoint: 'http://127.0.0.1:9000'"
+ end
+
+ test "restore computes the correct S3 replica URL from run_id", %{tmp_dir: tmp_dir} do
+ source_db = Path.join(tmp_dir, "source.sqlite")
+ restore_log = Path.join(tmp_dir, "restore.log")
+ fake_bin = Path.join(tmp_dir, "fake-litestream")
+ run_id = Ecto.UUID.generate()
+
+ create_valid_sqlite(source_db)
+ write_fake_litestream(fake_bin, source_db, restore_log)
+
+ assert {:ok, local_path} =
+ LitestreamManager.restore(
+ run_id,
+ data_dir: tmp_dir,
+ org_id: "org_123",
+ project_id: "project_456",
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ bin_path: fake_bin
+ )
+
+ expected_url =
+ LitestreamManager.replica_url(
+ run_id,
+ org_id: "org_123",
+ project_id: "project_456",
+ s3_bucket: "bucket",
+ s3_prefix: "workflows"
+ )
+
+ assert File.exists?(local_path)
+ assert File.read!(restore_log) =~ expected_url
+ end
+
+ test "replica_url includes endpoint query parameter when configured" do
+ run_id = Ecto.UUID.generate()
+
+ url =
+ LitestreamManager.replica_url(
+ run_id,
+ org_id: "org_123",
+ project_id: "project_456",
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ s3_endpoint: "http://127.0.0.1:9000"
+ )
+
+ assert url =~ "s3://bucket/workflows/"
+ assert url =~ "endpoint=http%3A%2F%2F127.0.0.1%3A9000"
+ end
+
+ test "restore refuses to overwrite an existing local file", %{tmp_dir: tmp_dir} do
+ run_id = Ecto.UUID.generate()
+
+ local_path =
+ LitestreamManager.local_path(
+ run_id,
+ data_dir: tmp_dir,
+ org_id: "org_123",
+ project_id: "project_456"
+ )
+
+ File.mkdir_p!(Path.dirname(local_path))
+ File.write!(local_path, "already here")
+
+ assert {:error, :already_exists} =
+ LitestreamManager.restore(
+ run_id,
+ data_dir: tmp_dir,
+ org_id: "org_123",
+ project_id: "project_456",
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ bin_path: Path.join(tmp_dir, "missing-litestream")
+ )
+ end
+
+ test "manager reports down when the binary is unavailable", %{tmp_dir: tmp_dir} do
+ name = unique_name()
+
+ start_supervised!(
+ {LitestreamManager,
+ name: name,
+ data_dir: tmp_dir,
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ aws_region: "us-east-1",
+ bin_path: Path.join(tmp_dir, "missing-litestream")}
+ )
+
+ assert :down = LitestreamManager.status(server: name)
+ end
+
+ test "manager reports running when the port is alive", %{tmp_dir: tmp_dir} do
+ fake_bin = Path.join(tmp_dir, "fake-litestream")
+
+ write_fake_litestream(
+ fake_bin,
+ Path.join(tmp_dir, "source.sqlite"),
+ Path.join(tmp_dir, "restore.log")
+ )
+
+ name = unique_name()
+
+ start_supervised!(
+ {LitestreamManager,
+ name: name,
+ data_dir: tmp_dir,
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ aws_region: "us-east-1",
+ bin_path: fake_bin}
+ )
+
+ assert :running = LitestreamManager.status(server: name)
+ end
+
+ test "manager starts litestream at warn log level by default", %{tmp_dir: tmp_dir} do
+ fake_bin = Path.join(tmp_dir, "fake-litestream")
+ write_fake_litestream_requiring_log_level(fake_bin, "warn")
+ name = unique_name()
+
+ pid =
+ start_supervised!(
+ {LitestreamManager,
+ name: name,
+ data_dir: tmp_dir,
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ aws_region: "us-east-1",
+ bin_path: fake_bin}
+ )
+
+ _ = :sys.get_state(pid)
+
+ assert :running = LitestreamManager.status(server: name)
+ end
+
+ test "manager starts litestream with configured log level", %{tmp_dir: tmp_dir} do
+ fake_bin = Path.join(tmp_dir, "fake-litestream")
+ write_fake_litestream_requiring_log_level(fake_bin, "error")
+ name = unique_name()
+
+ pid =
+ start_supervised!(
+ {LitestreamManager,
+ name: name,
+ data_dir: tmp_dir,
+ s3_bucket: "bucket",
+ s3_prefix: "workflows",
+ aws_region: "us-east-1",
+ bin_path: fake_bin,
+ log_level: "error"}
+ )
+
+ _ = :sys.get_state(pid)
+
+ assert :running = LitestreamManager.status(server: name)
+ end
+
+ defp create_valid_sqlite(path) do
+ assert :ok =
+ Sqlite.with_db(path, [create_dirs?: true], fn db ->
+ Sqlite.execute(db, "CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY)")
+ end)
+ end
+
+ defp write_fake_litestream(path, source_db, restore_log) do
+ script = """
+ #!/bin/sh
+ if [ "$1" = "replicate" ]; then
+ while true; do
+ sleep 1
+ done
+ fi
+
+ if [ "$1" = "restore" ]; then
+ echo "$@" > "#{restore_log}"
+ cp "#{source_db}" "$3"
+ exit 0
+ fi
+
+ exit 1
+ """
+
+ File.write!(path, script)
+ File.chmod!(path, 0o755)
+ end
+
+ defp write_fake_litestream_requiring_log_level(path, expected_log_level) do
+ script = """
+ #!/bin/sh
+ if [ "$1" = "replicate" ] && [ "$4" = "-log-level" ] && [ "$5" = "#{expected_log_level}" ]; then
+ while true; do
+ sleep 1
+ done
+ fi
+
+ exit 42
+ """
+
+ File.write!(path, script)
+ File.chmod!(path, 0o755)
+ end
+
+ defp unique_name do
+ Module.concat([__MODULE__, "Manager#{System.unique_integer([:positive])}"])
+ end
+end
diff --git a/test/fizz/workflows/store/sqlite_store_test.exs b/test/fizz/workflows/store/sqlite_store_test.exs
new file mode 100644
index 0000000..a466957
--- /dev/null
+++ b/test/fizz/workflows/store/sqlite_store_test.exs
@@ -0,0 +1,229 @@
+defmodule Fizz.Workflows.Store.SqliteStoreTest do
+ use Fizz.DataCase, async: false
+
+ alias Fizz.Workflows.Store.Paths
+ alias Fizz.Workflows.Store.Sqlite
+ alias Fizz.Workflows.Store.SqliteMigrations
+ alias Fizz.Workflows.Store.SqliteStore
+ alias Fizz.Workflows.Store.StaleOwnerError
+ alias Runic.Workflow.Events.FactProduced
+
+ setup do
+ tmp_dir =
+ Path.join(System.tmp_dir!(), "fizz-sqlite-store-#{System.unique_integer([:positive])}")
+
+ run_id = Ecto.UUID.generate()
+
+ on_exit(fn -> File.rm_rf(tmp_dir) end)
+
+ opts = [
+ data_dir: tmp_dir,
+ org_id: "org_test",
+ project_id: Ecto.UUID.generate(),
+ fence_token: 1,
+ repo: Repo
+ ]
+
+ %{tmp_dir: tmp_dir, run_id: run_id, opts: opts}
+ end
+
+ test "init creates sqlite file with expected schema and WAL mode", %{
+ run_id: run_id,
+ opts: opts
+ } do
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+ assert File.exists?(state.db_path)
+ current_version = SqliteMigrations.current_version()
+
+ assert :ok =
+ Sqlite.with_db(state.db_path, fn db ->
+ assert {:ok, "wal"} = Sqlite.first_value(db, "PRAGMA journal_mode")
+ assert {:ok, 1} = Sqlite.first_value(db, "PRAGMA foreign_keys")
+ assert {:ok, ^current_version} = Sqlite.first_value(db, "PRAGMA user_version")
+
+ assert {:ok, rows} =
+ Sqlite.query(
+ db,
+ """
+ SELECT name
+ FROM sqlite_master
+ WHERE type = 'table'
+ AND name IN ('workflow_log', 'shard_fence', 'facts', 'meta')
+ ORDER BY name
+ """
+ )
+
+ assert Enum.map(rows, &hd/1) == ["facts", "meta", "shard_fence", "workflow_log"]
+ :ok
+ end)
+ end
+
+ test "save and load round-trip the workflow log", %{run_id: run_id, opts: opts} do
+ insert_lease(run_id, 1, "NOW() + interval '30 seconds'")
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ log = [%{event: :started}, %{event: :completed, output: %{ok: true}}]
+
+ assert :ok = SqliteStore.save(run_id, log, state)
+ assert {:ok, ^log} = SqliteStore.load(run_id, state)
+
+ assert %{checkpoint_seq: 1, fence_token: 1} = lease_row(run_id)
+ end
+
+ test "save replaces the previous workflow log snapshot", %{run_id: run_id, opts: opts} do
+ insert_lease(run_id, 1, "NOW() + interval '30 seconds'")
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ first_log = [%{event: :started}, %{event: :middle}]
+ second_log = first_log ++ [%{event: :completed}]
+
+ assert :ok = SqliteStore.save(run_id, first_log, state)
+ assert :ok = SqliteStore.save(run_id, second_log, state)
+ assert :ok = SqliteStore.save(run_id, second_log, state)
+ assert {:ok, ^second_log} = SqliteStore.load(run_id, state)
+
+ assert :ok =
+ Sqlite.with_db(state.db_path, fn db ->
+ assert {:ok, rows} =
+ Sqlite.query(db, "SELECT data FROM workflow_log ORDER BY id ASC")
+
+ assert [snapshot] = Enum.map(rows, fn [blob] -> :erlang.binary_to_term(blob) end)
+ assert snapshot == second_log
+
+ :ok
+ end)
+
+ assert %{checkpoint_seq: 3, fence_token: 1} = lease_row(run_id)
+ end
+
+ test "save does not duplicate fact rows across snapshots", %{run_id: run_id, opts: opts} do
+ insert_lease(run_id, 1, "NOW() + interval '30 seconds'")
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ first_fact = %FactProduced{hash: "fact-1", value: %{number: 1}}
+ second_fact = %FactProduced{hash: "fact-2", value: %{number: 2}}
+
+ assert :ok = SqliteStore.save(run_id, [first_fact], state)
+ assert :ok = SqliteStore.save(run_id, [first_fact, second_fact], state)
+
+ assert :ok =
+ Sqlite.with_db(state.db_path, fn db ->
+ assert {:ok, [[2]]} = Sqlite.query(db, "SELECT COUNT(*) FROM facts")
+ :ok
+ end)
+ end
+
+ test "legacy snapshot logs load and are replaced by the next snapshot", %{
+ run_id: run_id,
+ opts: opts
+ } do
+ insert_lease(run_id, 1, "NOW() + interval '30 seconds'")
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ legacy_log = [%{event: :legacy}]
+
+ assert :ok =
+ Sqlite.with_db(state.db_path, fn db ->
+ assert {:ok, _rows} =
+ Sqlite.query(
+ db,
+ "INSERT INTO workflow_log (data, created_at) VALUES (?, ?)",
+ [
+ {:blob, :erlang.term_to_binary(legacy_log, [:compressed])},
+ DateTime.utc_now() |> DateTime.to_iso8601()
+ ]
+ )
+
+ :ok
+ end)
+
+ assert {:ok, ^legacy_log} = SqliteStore.load(run_id, state)
+
+ migrated_log = legacy_log ++ [%{event: :after_legacy}]
+ assert :ok = SqliteStore.save(run_id, migrated_log, state)
+
+ assert :ok =
+ Sqlite.with_db(state.db_path, fn db ->
+ assert {:ok, rows} =
+ Sqlite.query(db, "SELECT data FROM workflow_log ORDER BY id ASC")
+
+ assert [snapshot] = Enum.map(rows, fn [blob] -> :erlang.binary_to_term(blob) end)
+ assert snapshot == migrated_log
+ :ok
+ end)
+ end
+
+ test "save with a stale fence token raises StaleOwnerError", %{run_id: run_id, opts: opts} do
+ insert_lease(run_id, 2, "NOW() + interval '30 seconds'")
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ assert_raise StaleOwnerError, fn ->
+ SqliteStore.save(run_id, [%{event: :stale}], state)
+ end
+ end
+
+ test "save_fact and load_fact round-trip individual facts", %{run_id: run_id, opts: opts} do
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ fact = %{payload: [1, 2, 3], nested: %{ok: true}}
+
+ assert :ok = SqliteStore.save_fact("hash-123", fact, state)
+ assert {:ok, ^fact} = SqliteStore.load_fact("hash-123", state)
+ end
+
+ test "schema migration upgrades an older user_version", %{run_id: run_id, opts: opts} do
+ path = SqliteStore.db_path(run_id, opts)
+ current_version = SqliteMigrations.current_version()
+
+ assert :ok =
+ Sqlite.with_db(path, [create_dirs?: true, configure?: false], fn db ->
+ Sqlite.execute(db, "PRAGMA user_version = 0")
+ end)
+
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ assert :ok =
+ Sqlite.with_db(state.db_path, fn db ->
+ assert {:ok, ^current_version} = Sqlite.first_value(db, "PRAGMA user_version")
+ :ok
+ end)
+ end
+
+ test "file is created in the hashed directory structure for Litestream discovery", %{
+ tmp_dir: tmp_dir,
+ run_id: run_id,
+ opts: opts
+ } do
+ assert {:ok, state} = SqliteStore.init(run_id, opts)
+
+ expected_relative = Paths.relative_db_path(run_id, opts[:org_id], opts[:project_id])
+
+ assert Path.relative_to(state.db_path, Path.expand(tmp_dir)) == expected_relative
+ end
+
+ defp insert_lease(run_id, fence_token, lease_expiry_sql) do
+ sql = """
+ INSERT INTO workflow_run_leases (run_id, owner_node, fence_token, checkpoint_seq, lease_expiry)
+ VALUES ($1, NULL, $2, 0, #{lease_expiry_sql})
+ """
+
+ assert {:ok, _result} = Ecto.Adapters.SQL.query(Repo, sql, [dump_uuid(run_id), fence_token])
+ end
+
+ defp lease_row(run_id) do
+ assert {:ok, %{rows: [[owner_node, fence_token, checkpoint_seq]]}} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ SELECT owner_node, fence_token, checkpoint_seq
+ FROM workflow_run_leases
+ WHERE run_id = $1
+ """,
+ [dump_uuid(run_id)]
+ )
+
+ %{owner_node: owner_node, fence_token: fence_token, checkpoint_seq: checkpoint_seq}
+ end
+
+ defp dump_uuid(run_id), do: Ecto.UUID.dump!(run_id)
+end
diff --git a/test/fizz/workflows/timer_poller_test.exs b/test/fizz/workflows/timer_poller_test.exs
new file mode 100644
index 0000000..91db58c
--- /dev/null
+++ b/test/fizz/workflows/timer_poller_test.exs
@@ -0,0 +1,250 @@
+defmodule Fizz.Workflows.TimerPollerTest do
+ use Fizz.DataCase, async: false
+
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Workflows
+ alias Fizz.Workflows.DurableTimer
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.TimerPoller
+ alias Fizz.Workflows.WorkflowRun
+
+ setup do
+ scope = project_scope_fixture()
+ original_workflow_env = Application.get_env(:fizz, Fizz.Workflows, [])
+
+ on_exit(fn ->
+ Application.put_env(:fizz, Fizz.Workflows, original_workflow_env)
+ end)
+
+ %{scope: scope}
+ end
+
+ test "timer created by a workflow run is fired by the poller after fire_at", %{scope: scope} do
+ put_workflow_runtime(idle_timeout_ms: 25)
+
+ %{version: version} =
+ published_version_fixture(scope, long_running_snapshot_attrs(100))
+
+ poller = unique_name(:poller)
+
+ start_supervised!({TimerPoller, name: poller, interval_ms: 60_000, claim_ttl_ms: 100})
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"kind" => "initial"})
+
+ timer =
+ eventually(fn ->
+ case pending_timers_for_run(run.id) do
+ [%DurableTimer{} = timer] -> {:ok, timer}
+ _ -> :retry
+ end
+ end)
+
+ assert timer.status == :pending
+
+ fired_ids =
+ eventually(fn ->
+ case TimerPoller.poll(server: poller) do
+ {:ok, [_ | _] = ids} -> {:ok, ids}
+ _ -> :retry
+ end
+ end)
+
+ assert timer.id in fired_ids
+
+ completed_run =
+ eventually(fn ->
+ with {:ok, %WorkflowRun{status: :completed} = workflow_run} <-
+ Workflows.get_run(scope, run.id) do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.status == :completed
+ assert {:ok, %DurableTimer{status: :fired}} = Workflows.get_timer(timer.id)
+ assert_worker_shutdown(run.id)
+ end
+
+ test "cancelled timers are skipped by the poller", %{scope: scope} do
+ put_workflow_runtime(idle_timeout_ms: 25)
+
+ %{version: version} =
+ published_version_fixture(scope, long_running_snapshot_attrs(100))
+
+ poller = unique_name(:poller)
+
+ start_supervised!({TimerPoller, name: poller, interval_ms: 60_000, claim_ttl_ms: 100})
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"kind" => "cancel-me"})
+
+ timer =
+ eventually(fn ->
+ case pending_timers_for_run(run.id) do
+ [%DurableTimer{} = timer] -> {:ok, timer}
+ _ -> :retry
+ end
+ end)
+
+ assert {:ok, %{status: :cancelled}} = Workflows.cancel_run(scope, run.id)
+ assert {:ok, []} = TimerPoller.poll(server: poller)
+ assert {:ok, %DurableTimer{status: :cancelled}} = Workflows.get_timer(timer.id)
+ end
+
+ test "concurrent pollers claim disjoint timer batches with skip locked", %{scope: scope} do
+ runs =
+ for _ <- 1..4 do
+ insert_run(scope, :completed)
+ end
+
+ timers =
+ Enum.map(runs, fn run ->
+ insert_timer(run, %{
+ fire_at: DateTime.add(DateTime.utc_now(), -1, :second),
+ status: :pending,
+ payload: nil
+ })
+ end)
+
+ poller_a = unique_name(:poller_a)
+ poller_b = unique_name(:poller_b)
+
+ start_supervised!(
+ {TimerPoller, name: poller_a, interval_ms: 60_000, batch_size: 2, claimed_by: "poller-a"}
+ )
+
+ start_supervised!(
+ {TimerPoller, name: poller_b, interval_ms: 60_000, batch_size: 2, claimed_by: "poller-b"}
+ )
+
+ task_a = Task.async(fn -> TimerPoller.poll(server: poller_a) end)
+ task_b = Task.async(fn -> TimerPoller.poll(server: poller_b) end)
+
+ assert {:ok, claimed_a} = Task.await(task_a)
+ assert {:ok, claimed_b} = Task.await(task_b)
+
+ assert MapSet.disjoint?(MapSet.new(claimed_a), MapSet.new(claimed_b))
+
+ claimed_ids = MapSet.new(claimed_a ++ claimed_b)
+ expected_ids = MapSet.new(Enum.map(timers, & &1.id))
+
+ assert claimed_ids == expected_ids
+
+ assert Enum.all?(reload_timers(timers), &(&1.status == :fired))
+ end
+
+ test "stale firing timers are reset to pending after claim ttl expiry", %{scope: scope} do
+ run = insert_run(scope, :sleeping)
+
+ stale_timer =
+ insert_timer(run, %{
+ fire_at: DateTime.add(DateTime.utc_now(), 1, :minute),
+ status: :firing,
+ claimed_at: DateTime.add(DateTime.utc_now(), -5, :minute),
+ claimed_by: "dead-poller",
+ payload: nil
+ })
+
+ poller = unique_name(:poller)
+
+ start_supervised!(
+ {TimerPoller,
+ name: poller, interval_ms: 60_000, claim_ttl_ms: 1_000, claimed_by: "recovery"}
+ )
+
+ assert {:ok, []} = TimerPoller.poll(server: poller)
+
+ assert {:ok, %DurableTimer{} = recovered} = Workflows.get_timer(stale_timer.id)
+ assert recovered.status == :pending
+ assert recovered.claimed_at == nil
+ assert recovered.claimed_by == nil
+ end
+
+ defp put_workflow_runtime(opts) do
+ current = Application.get_env(:fizz, Fizz.Workflows, [])
+ Application.put_env(:fizz, Fizz.Workflows, Keyword.merge(current, opts))
+ end
+
+ defp pending_timers_for_run(run_id) do
+ DurableTimer
+ |> where([timer], timer.run_id == ^run_id and timer.status == :pending)
+ |> order_by([timer], asc: timer.inserted_at)
+ |> Repo.all()
+ end
+
+ defp insert_run(scope, status) do
+ now = DateTime.utc_now()
+ %{version: version} = published_version_fixture(scope)
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: status,
+ input: %{},
+ last_active_at: now,
+ started_at: now,
+ completed_at: if(status == :completed, do: now)
+ })
+ |> Repo.insert!()
+ end
+
+ defp insert_timer(run, attrs) do
+ defaults = %{
+ run_id: run.id,
+ step_id: "wait-step",
+ timer_name: "wait-step",
+ project_id: run.project_id,
+ workos_organization_id: run.workos_organization_id,
+ fire_at: DateTime.utc_now(),
+ status: :pending,
+ payload: %{},
+ claimed_at: nil,
+ claimed_by: nil
+ }
+
+ %DurableTimer{}
+ |> DurableTimer.changeset(Map.merge(defaults, attrs))
+ |> Repo.insert!()
+ end
+
+ defp reload_timers(timers) do
+ Enum.map(timers, &Repo.get!(DurableTimer, &1.id))
+ end
+
+ defp eventually(fun, attempts \\ 100)
+
+ defp eventually(fun, attempts) when attempts > 0 do
+ case fun.() do
+ {:ok, value} ->
+ value
+
+ :retry ->
+ receive do
+ after
+ 20 -> eventually(fun, attempts - 1)
+ end
+ end
+ end
+
+ defp eventually(_fun, 0), do: flunk("condition was not met in time")
+
+ defp unique_name(name) do
+ Module.concat([__MODULE__, "#{name}_#{System.unique_integer([:positive])}"])
+ end
+
+ defp assert_worker_shutdown(run_id) do
+ case Worker.lookup(run_id) do
+ nil ->
+ :ok
+
+ pid ->
+ ref = Process.monitor(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+ end
+ end
+end
diff --git a/test/fizz/workflows/validator_subnodes_test.exs b/test/fizz/workflows/validator_subnodes_test.exs
deleted file mode 100644
index d043d51..0000000
--- a/test/fizz/workflows/validator_subnodes_test.exs
+++ /dev/null
@@ -1,140 +0,0 @@
-defmodule Fizz.Workflows.ValidatorSubnodesTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Workflows.Validator
- alias Fizz.Workflows.WorkflowDraft
- alias Fizz.Workflows.Embeds.{Connection, Step}
-
- describe "validate/1 subnode slots" do
- test "accepts valid slot wiring" do
- draft = %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("agent", "ai_agent"),
- step("model", "openai_model"),
- step("prompt", "ai_prompt_template"),
- step("tool", "ai_tool_http")
- ],
- connections: [
- conn("c1", "model", "agent", "model"),
- conn("c2", "prompt", "agent", "prompt"),
- conn("c3", "tool", "agent", "tools")
- ],
- groups: []
- }
-
- assert :ok = Validator.validate(draft)
- end
-
- test "fails when required slots are missing" do
- draft = %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("agent", "ai_agent"),
- step("model", "openai_model")
- ],
- connections: [
- conn("c1", "model", "agent", "model")
- ],
- groups: []
- }
-
- assert {:error, errors} = Validator.validate(draft)
- assert Enum.any?(errors, fn error -> inspect(error) =~ "required slot prompt" end)
- end
-
- test "fails when source type is not accepted by slot" do
- draft = %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("agent", "ai_agent"),
- step("math", "math"),
- step("prompt", "ai_prompt_template")
- ],
- connections: [
- conn("c1", "math", "agent", "model"),
- conn("c2", "prompt", "agent", "prompt")
- ],
- groups: []
- }
-
- assert {:error, errors} = Validator.validate(draft)
-
- assert Enum.any?(errors, fn error ->
- inspect(error) =~ "is not allowed for slot model"
- end)
- end
-
- test "fails when one-cardinality slot has multiple connections" do
- draft = %WorkflowDraft{
- workflow_id: Ecto.UUID.generate(),
- steps: [
- step("agent", "ai_agent"),
- step("model_a", "openai_model"),
- step("model_b", "anthropic_model"),
- step("prompt", "ai_prompt_template")
- ],
- connections: [
- conn("c1", "model_a", "agent", "model"),
- conn("c2", "model_b", "agent", "model"),
- conn("c3", "prompt", "agent", "prompt")
- ],
- groups: []
- }
-
- assert {:error, errors} = Validator.validate(draft)
-
- assert Enum.any?(errors, fn error ->
- inspect(error) =~ "allows only one sub-node connection"
- end)
- end
- end
-
- defp step(id, type_id) do
- config =
- case type_id do
- "openai_model" ->
- %{
- "model" => "gpt-4.1-mini",
- "credential_ref" => %{
- "id" => "cred_openai_#{id}",
- "provider" => "openai_api_key",
- "auth_type" => "api_key",
- "owner_user_id" => "user_123"
- }
- }
-
- "anthropic_model" ->
- %{
- "model" => "claude-3-5-sonnet-latest",
- "credential_ref" => %{
- "id" => "cred_anthropic_#{id}",
- "provider" => "anthropic_api_key",
- "auth_type" => "api_key",
- "owner_user_id" => "user_123"
- }
- }
-
- _ ->
- %{}
- end
-
- %Step{
- id: id,
- type_id: type_id,
- name: id,
- config: config,
- position: %{}
- }
- end
-
- defp conn(id, source, target, target_input) do
- %Connection{
- id: id,
- source_step_id: source,
- source_output: "main",
- target_step_id: target,
- target_input: target_input
- }
- end
-end
diff --git a/test/fizz/workflows/workflow_run_test.exs b/test/fizz/workflows/workflow_run_test.exs
new file mode 100644
index 0000000..8ef7798
--- /dev/null
+++ b/test/fizz/workflows/workflow_run_test.exs
@@ -0,0 +1,46 @@
+defmodule Fizz.Workflows.WorkflowRunTest do
+ use Fizz.DataCase, async: true
+
+ alias Fizz.Workflows.WorkflowRun
+
+ test "valid status transitions are accepted" do
+ assert WorkflowRun.transition_status(build_run(:pending), :running).valid?
+ assert WorkflowRun.transition_status(build_run(:running), :sleeping).valid?
+ assert WorkflowRun.transition_status(build_run(:running), :completed).valid?
+ assert WorkflowRun.transition_status(build_run(:sleeping), :passivated).valid?
+ assert WorkflowRun.transition_status(build_run(:passivated), :running).valid?
+ assert WorkflowRun.transition_status(build_run(:passivated), :cancelled).valid?
+ end
+
+ test "invalid transitions are rejected" do
+ changeset = WorkflowRun.transition_status(build_run(:completed), :running)
+
+ refute changeset.valid?
+ assert "cannot transition from completed to running" in errors_on(changeset).status
+ end
+
+ test "terminal states reject all transitions" do
+ for status <- [:completed, :failed, :cancelled, :continued] do
+ changeset = WorkflowRun.transition_status(build_run(status), :running)
+ refute changeset.valid?
+ assert "cannot transition from #{status} to running" in errors_on(changeset).status
+ end
+ end
+
+ defp build_run(status) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{
+ id: Ecto.UUID.generate(),
+ user_id: Ecto.UUID.generate(),
+ workflow_definition_id: Ecto.UUID.generate(),
+ workflow_definition_version_id: Ecto.UUID.generate(),
+ project_id: Ecto.UUID.generate(),
+ workos_organization_id: "org_test",
+ status: status,
+ input: %{},
+ last_active_at: now,
+ started_at: now
+ }
+ end
+end
diff --git a/test/fizz/workflows_test.exs b/test/fizz/workflows_test.exs
index 4efcded..ca19a1a 100644
--- a/test/fizz/workflows_test.exs
+++ b/test/fizz/workflows_test.exs
@@ -1,65 +1,856 @@
defmodule Fizz.WorkflowsTest do
- use ExUnit.Case, async: true
+ use Fizz.DataCase, async: false
- import Ecto.Changeset
+ import Fizz.AccountsFixtures
- alias Fizz.Workflows.Workflow
- alias Fizz.Workflows.WorkflowDraft
+ alias Fizz.Accounts.Scope
+ alias Fizz.Repo
alias Fizz.Workflows
+ alias Fizz.Workflows.Runner.Worker
+ alias Fizz.Workflows.Store.SqliteStore
+ alias Fizz.Workflows.WorkflowRun
+ alias Fizz.WorkflowsFixtures
+ alias Runic.Workflow.{Fact, RunnableCompleted, RunnableDispatched, RunnableFailed}
- describe "workflow changesets and step identity" do
- test "update_changeset/2 ignores workspace_id and user_id changes" do
- workflow = %Workflow{
- name: "Original",
- workspace_id: Ecto.UUID.generate(),
- user_id: Ecto.UUID.generate()
+ test "create definition with initial empty draft" do
+ scope = project_scope_fixture()
+
+ assert {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Customer Intake",
+ description: "Collect and route inbound requests"
+ })
+
+ assert definition.project_id == scope.project.id
+ assert definition.workos_organization_id == scope.project.workos_organization_id
+ assert definition.created_by_user_id == scope.user.id
+ assert definition.archived_at == nil
+
+ assert draft.workflow_definition_id == definition.id
+ assert draft.version == 1
+ assert draft.status == :draft
+ assert draft.steps == []
+ assert draft.connections == []
+ assert draft.step_groups == []
+ assert draft.viewport == %{"x" => 0, "y" => 0, "zoom" => 1.0}
+ assert draft.settings == %{}
+ end
+
+ test "save draft with valid steps and connections" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ entry_step = step(%{type_id: "manual_input", name: "Entry"})
+ debug_step = step(%{type_id: "debug", name: "Log"})
+
+ attrs =
+ snapshot_attrs(%{
+ steps: [entry_step, debug_step],
+ connections: [
+ connection(%{
+ source_step_id: entry_step.id,
+ target_step_id: debug_step.id
+ })
+ ]
+ })
+
+ assert {:ok, saved_draft} = Workflows.save_draft(scope, draft, attrs)
+
+ assert Enum.map(saved_draft.steps, & &1.id) == [entry_step.id, debug_step.id]
+ assert Enum.map(saved_draft.connections, & &1.id) == [List.first(attrs.connections).id]
+ assert saved_draft.step_groups == []
+ end
+
+ test "save draft rejects cyclic graphs" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ first_step = step(%{type_id: "debug", name: "First"})
+ second_step = step(%{type_id: "debug", name: "Second"})
+
+ attrs =
+ snapshot_attrs(%{
+ steps: [first_step, second_step],
+ connections: [
+ connection(%{source_step_id: first_step.id, target_step_id: second_step.id}),
+ connection(%{source_step_id: second_step.id, target_step_id: first_step.id})
+ ]
+ })
+
+ assert {:error, changeset} = Workflows.save_draft(scope, draft, attrs)
+ assert_message!(errors_on(changeset).connections, "creates a cycle")
+ end
+
+ test "save draft rejects unknown type ids" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ attrs =
+ snapshot_attrs(%{
+ steps: [step(%{type_id: "missing_step_type", name: "Missing"})]
+ })
+
+ assert {:error, changeset} = Workflows.save_draft(scope, draft, attrs)
+ assert_message!(errors_on(changeset).steps, "unknown step types")
+ end
+
+ test "save draft rejects duplicate step ids" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ duplicate_id = Ecto.UUID.generate()
+
+ attrs =
+ snapshot_attrs(%{
+ steps: [
+ step(%{id: duplicate_id, name: "First"}),
+ step(%{id: duplicate_id, name: "Second"})
+ ]
+ })
+
+ assert {:error, changeset} = Workflows.save_draft(scope, draft, attrs)
+ assert_message!(errors_on(changeset).steps, "duplicate step ids")
+ end
+
+ test "publish draft stamps published_at and compiled_hash" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ assert {:ok, saved_draft} =
+ Workflows.save_draft(scope, draft, valid_snapshot_attrs())
+
+ assert {:ok, published_version} = Workflows.publish_draft(scope, saved_draft)
+
+ assert published_version.status == :published
+ assert %DateTime{} = published_version.published_at
+ assert published_version.published_by_user_id == scope.user.id
+ assert is_binary(published_version.compiled_hash)
+ assert byte_size(published_version.compiled_hash) == 64
+ end
+
+ test "publish draft rejects when no entry step exists" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ assert {:error, changeset} = Workflows.publish_draft(scope, draft)
+ assert_message!(errors_on(changeset).steps, "at least one entry step")
+ end
+
+ test "publish draft rejects invalid step config" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ invalid_http_step =
+ step(%{
+ type_id: "http_request",
+ name: "HTTP Request",
+ config: %{}
+ })
+
+ assert {:ok, saved_draft} =
+ Workflows.save_draft(scope, draft, snapshot_attrs(%{steps: [invalid_http_step]}))
+
+ assert {:error, changeset} = Workflows.publish_draft(scope, saved_draft)
+ assert_message!(errors_on(changeset).steps, "invalid config")
+ end
+
+ test "publish draft rejects unsupported expression filters" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ debug_step =
+ step(%{
+ type_id: "debug",
+ name: "Debug",
+ config: %{"label" => "{{ input.name | concat: \"!\" }}"}
+ })
+
+ assert {:ok, saved_draft} =
+ Workflows.save_draft(scope, draft, snapshot_attrs(%{steps: [debug_step]}))
+
+ assert {:error, changeset} = Workflows.publish_draft(scope, saved_draft)
+ assert_message!(errors_on(changeset).steps, "unsupported filter `concat`")
+ end
+
+ test "publish draft rejects invalid step expression references" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ debug_step =
+ step(%{
+ type_id: "debug",
+ name: "Debug",
+ config: %{"label" => "{{ steps.#{Ecto.UUID.generate()}.body }}"}
+ })
+
+ assert {:ok, saved_draft} =
+ Workflows.save_draft(scope, draft, snapshot_attrs(%{steps: [debug_step]}))
+
+ assert {:error, changeset} = Workflows.publish_draft(scope, saved_draft)
+ assert_message!(errors_on(changeset).steps, "unknown step reference")
+ end
+
+ test "published version is immutable" do
+ scope = project_scope_fixture()
+ %{draft: draft} = definition_fixture(scope)
+
+ assert {:ok, saved_draft} =
+ Workflows.save_draft(scope, draft, valid_snapshot_attrs())
+
+ assert {:ok, published_version} = Workflows.publish_draft(scope, saved_draft)
+
+ assert {:error, :not_a_draft} =
+ Workflows.save_draft(scope, published_version, valid_snapshot_attrs())
+ end
+
+ test "edit after publish clones to new draft" do
+ scope = project_scope_fixture()
+ %{definition: definition, draft: draft} = definition_fixture(scope)
+
+ assert {:ok, saved_draft} =
+ Workflows.save_draft(scope, draft, valid_snapshot_attrs())
+
+ assert {:ok, published_version} = Workflows.publish_draft(scope, saved_draft)
+ assert {:ok, cloned_draft} = Workflows.edit_definition(scope, definition)
+
+ assert cloned_draft.id != published_version.id
+ assert cloned_draft.version == 2
+ assert cloned_draft.status == :draft
+ assert Enum.map(cloned_draft.steps, & &1.id) == Enum.map(published_version.steps, & &1.id)
+
+ assert Enum.map(cloned_draft.connections, & &1.id) ==
+ Enum.map(published_version.connections, & &1.id)
+
+ assert cloned_draft.compiled_hash == nil
+ assert cloned_draft.published_at == nil
+ assert cloned_draft.published_by_user_id == nil
+ end
+
+ test "archive hides from list queries" do
+ scope = project_scope_fixture()
+ %{definition: definition} = definition_fixture(scope)
+
+ assert {:ok, [listed_definition]} = Workflows.list_definitions(scope)
+ assert listed_definition.id == definition.id
+
+ assert {:ok, archived_definition} = Workflows.archive_definition(scope, definition)
+ assert %DateTime{} = archived_definition.archived_at
+ assert {:ok, []} = Workflows.list_definitions(scope)
+ end
+
+ test "start_run executes steps and transitions to completed" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ %{version: version} = WorkflowsFixtures.published_version_fixture(scope)
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"name" => "Ada"})
+
+ completed_run =
+ eventually(fn ->
+ with {:ok, workflow_run} <- Workflows.get_run(scope, run.id),
+ true <- workflow_run.status == :completed do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.status == :completed
+ assert completed_run.output != nil
+ assert_worker_shutdown(run.id)
+ end
+
+ test "complete_run normalizes structs in output payloads" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ %{version: version} = WorkflowsFixtures.published_version_fixture(scope)
+ run = insert_running_run(scope, version)
+
+ response = %ReqLLM.Response{
+ id: "resp_test",
+ model: "test-model",
+ context: nil,
+ message: %ReqLLM.Message{
+ role: :assistant,
+ content: [ReqLLM.Message.ContentPart.text("done")]
+ },
+ usage: %{input_tokens: 7, output_tokens: 3, total_tokens: 10},
+ finish_reason: :stop
+ }
+
+ assert {:ok, completed_run} =
+ Workflows.complete_run(run.id, %{
+ "response" => response,
+ "finished_at" => run.started_at
+ })
+
+ assert completed_run.status == :completed
+ assert completed_run.output["response"]["id"] == "resp_test"
+
+ assert completed_run.output["response"]["message"]["content"] == [
+ %{
+ "data" => nil,
+ "file_id" => nil,
+ "filename" => nil,
+ "media_type" => nil,
+ "metadata" => %{},
+ "text" => "done",
+ "type" => ":text",
+ "url" => nil
+ }
+ ]
+
+ assert completed_run.output["finished_at"] == DateTime.to_iso8601(run.started_at)
+ end
+
+ test "start_run requires bindings for declared credentials" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ entry_step = WorkflowsFixtures.step(%{type_id: "debug", name: "Entry"})
+
+ image_step =
+ WorkflowsFixtures.step(%{
+ type_id: "openai_image_generation",
+ name: "Image",
+ config:
+ "openai_image_generation"
+ |> Fizz.Integrations.Steps.Registry.get_default_config()
+ |> Map.put("prompt", "a generated product mockup")
+ })
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [entry_step, image_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: entry_step.id,
+ target_step_id: image_step.id
+ })
+ ]
+ })
+
+ %{version: version} = WorkflowsFixtures.published_version_fixture(scope, snapshot_attrs)
+
+ assert {:error, {:credential_bindings_required, [descriptor]}} =
+ Workflows.start_run(scope, version, %{})
+
+ assert descriptor.step_id == image_step.id
+ assert descriptor.requirement_key == "auth"
+ assert descriptor.provider == "openai_api_key"
+ assert descriptor.auth_type == "api_key"
+ end
+
+ test "list_run_step_executions exposes split iterations without compiler internals" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ trigger = WorkflowsFixtures.step(%{type_id: "manual_input", name: "Manual"})
+
+ splitter =
+ WorkflowsFixtures.step(%{
+ type_id: "splitter",
+ name: "Split",
+ config: %{"field" => "{{ json.items }}"}
+ })
+
+ debug = WorkflowsFixtures.step(%{type_id: "debug", name: "Debug"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [trigger, splitter, debug],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: trigger.id,
+ target_step_id: splitter.id
+ }),
+ WorkflowsFixtures.connection(%{
+ source_step_id: splitter.id,
+ target_step_id: debug.id
+ })
+ ]
+ })
+
+ %{version: version} = WorkflowsFixtures.published_version_fixture(scope, snapshot_attrs)
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"items" => [1, 2, 3]})
+
+ _completed_run =
+ eventually(fn ->
+ with {:ok, workflow_run} <- Workflows.get_run(scope, run.id),
+ true <- workflow_run.status == :completed do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ assert {:ok, step_executions} = Workflows.list_run_step_executions(scope, run.id)
+
+ step_ids =
+ step_executions
+ |> Enum.map(& &1.step_id)
+ |> Enum.uniq()
+ |> Enum.sort()
+
+ assert step_ids == Enum.sort([trigger.id, splitter.id, debug.id])
+ refute Enum.any?(step_executions, &String.contains?(&1.step_id, "__"))
+
+ splitter_executions =
+ step_executions
+ |> Enum.filter(&(&1.step_id == splitter.id))
+ |> Enum.sort_by(& &1.item_index)
+
+ assert Enum.map(splitter_executions, & &1.item_index) == [0, 1, 2]
+ assert Enum.map(splitter_executions, & &1.items_total) == [3, 3, 3]
+ assert Enum.map(splitter_executions, & &1.input_data) == [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
+ assert Enum.map(splitter_executions, & &1.output_data) == [1, 2, 3]
+
+ debug_executions =
+ step_executions
+ |> Enum.filter(&(&1.step_id == debug.id))
+ |> Enum.sort_by(& &1.item_index)
+
+ assert Enum.map(debug_executions, & &1.item_index) == [0, 1, 2]
+ assert Enum.map(debug_executions, & &1.items_total) == [3, 3, 3]
+ assert Enum.map(debug_executions, & &1.input_data) == [1, 2, 3]
+
+ assert_worker_shutdown(run.id)
+ end
+
+ test "completed run output excludes internal splitter artifacts and preserves iteration metadata downstream" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ trigger = WorkflowsFixtures.step(%{type_id: "manual_input", name: "Manual Trigger"})
+
+ debug =
+ WorkflowsFixtures.step(%{
+ type_id: "debug",
+ name: "Inspect Request",
+ config: %{"label" => "Incoming request", "level" => "info"}
+ })
+
+ output = WorkflowsFixtures.step(%{type_id: "data_output", name: "Output"})
+
+ splitter =
+ WorkflowsFixtures.step(%{
+ type_id: "splitter",
+ name: "Split Items",
+ config: %{"field" => "{{ json.email }}"}
+ })
+
+ math =
+ WorkflowsFixtures.step(%{
+ type_id: "math",
+ name: "Math",
+ config: %{"operation" => "add", "value" => "{{ input }}", "operand" => 10}
+ })
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [trigger, debug, output, splitter, math],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: trigger.id,
+ target_step_id: debug.id
+ }),
+ WorkflowsFixtures.connection(%{
+ source_step_id: debug.id,
+ target_step_id: output.id
+ }),
+ WorkflowsFixtures.connection(%{
+ source_step_id: trigger.id,
+ target_step_id: splitter.id
+ }),
+ WorkflowsFixtures.connection(%{
+ source_step_id: splitter.id,
+ target_step_id: math.id
+ })
+ ]
+ })
+
+ %{version: version} = WorkflowsFixtures.published_version_fixture(scope, snapshot_attrs)
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"email" => [1, 2, 3]})
+
+ completed_run =
+ eventually(fn ->
+ with {:ok, workflow_run} <- Workflows.get_run(scope, run.id),
+ true <- workflow_run.status == :completed do
+ {:ok, workflow_run}
+ else
+ _ -> :retry
+ end
+ end)
+
+ assert completed_run.output == %{"value" => [%{"email" => [1, 2, 3]}]}
+
+ assert {:ok, step_executions} = Workflows.list_run_step_executions(scope, run.id)
+
+ step_ids =
+ step_executions
+ |> Enum.map(& &1.step_id)
+ |> Enum.uniq()
+ |> Enum.sort()
+
+ assert step_ids == Enum.sort([trigger.id, debug.id, output.id, splitter.id, math.id])
+
+ splitter_executions =
+ step_executions
+ |> Enum.filter(&(&1.step_id == splitter.id))
+ |> Enum.sort_by(& &1.item_index)
+
+ assert Enum.map(splitter_executions, & &1.item_index) == [0, 1, 2]
+ assert Enum.map(splitter_executions, & &1.items_total) == [3, 3, 3]
+ assert Enum.map(splitter_executions, & &1.input_data) == [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
+ assert Enum.map(splitter_executions, & &1.output_data) == [1, 2, 3]
+
+ math_executions =
+ step_executions
+ |> Enum.filter(&(&1.step_id == math.id))
+ |> Enum.sort_by(& &1.item_index)
+
+ assert Enum.map(math_executions, & &1.item_index) == [0, 1, 2]
+ assert Enum.map(math_executions, & &1.items_total) == [3, 3, 3]
+ assert Enum.map(math_executions, & &1.input_data) == [1, 2, 3]
+ assert Enum.map(math_executions, & &1.output_data) == [11.0, 12.0, 13.0]
+
+ assert_worker_shutdown(run.id)
+ end
+
+ test "cancel_run transitions the run to cancelled and stops the worker" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+
+ %{version: version} =
+ WorkflowsFixtures.published_version_fixture(
+ scope,
+ WorkflowsFixtures.long_running_snapshot_attrs(1_000)
+ )
+
+ assert {:ok, run} = Workflows.start_run(scope, version, %{"name" => "Ada"})
+
+ pid =
+ eventually(fn ->
+ case {Worker.lookup(run.id), Workflows.get_run(scope, run.id)} do
+ {worker_pid, {:ok, %{status: :sleeping}}} when is_pid(worker_pid) ->
+ {:ok, worker_pid}
+
+ _ ->
+ :retry
+ end
+ end)
+
+ ref = Process.monitor(pid)
+
+ assert {:ok, cancelled_run} = Workflows.cancel_run(scope, run.id)
+ assert cancelled_run.status == :cancelled
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
+ assert {:ok, %{status: :cancelled}} = Workflows.get_run(scope, run.id)
+ end
+
+ test "list_run_step_executions preserves microsecond durations from persisted completed events" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ step = WorkflowsFixtures.step(%{type_id: "debug", name: "Fast Step"})
+
+ %{version: version} =
+ WorkflowsFixtures.published_version_fixture(
+ scope,
+ WorkflowsFixtures.snapshot_attrs(%{steps: [step]})
+ )
+
+ run = insert_completed_run(scope, version)
+ tmp_dir = unique_tmp_dir("step-execution-duration")
+ configure_workflow_data_dir(tmp_dir)
+ insert_lease(run.id, 1)
+
+ {:ok, store_state} =
+ SqliteStore.init(run.id,
+ data_dir: tmp_dir,
+ org_id: scope.project.workos_organization_id,
+ project_id: scope.project.id,
+ fence_token: 1,
+ repo: Repo
+ )
+
+ input_fact = %Fact{hash: 101, value: %{"email" => "test"}}
+
+ output_fact = %Fact{
+ hash: 202,
+ value: %{"email" => "test"},
+ ancestry: {303, input_fact.hash}
+ }
+
+ event_log = [
+ %RunnableDispatched{
+ runnable_id: 123,
+ node_name: step.id,
+ node_hash: 303,
+ input_fact: input_fact,
+ dispatched_at: 0,
+ policy: nil,
+ attempt: 0
+ },
+ %RunnableCompleted{
+ runnable_id: 123,
+ node_hash: 303,
+ result_fact: output_fact,
+ completed_at: 0,
+ attempt: 0,
+ duration_ms: 0,
+ duration_us: 713
}
+ ]
- changeset =
- Workflow.update_changeset(workflow, %{
- name: "Renamed",
- workspace_id: Ecto.UUID.generate(),
- user_id: Ecto.UUID.generate()
- })
-
- assert changeset.valid?
- assert get_change(changeset, :name) == "Renamed"
- refute Map.has_key?(changeset.changes, :workspace_id)
- refute Map.has_key?(changeset.changes, :user_id)
- end
+ assert :ok = SqliteStore.save(run.id, event_log, store_state)
+
+ assert {:ok,
+ [%{duration_us: 713, output_item_count: 1, status: "completed", step_id: step_id}]} =
+ Workflows.list_run_step_executions(scope, run.id)
+
+ assert step_id == step.id
+ end
+
+ test "list_run_step_executions preserves microsecond durations from persisted failed events" do
+ scope = WorkflowsFixtures.project_scope_fixture()
+ step = WorkflowsFixtures.step(%{type_id: "debug", name: "Fast Failure"})
+
+ %{version: version} =
+ WorkflowsFixtures.published_version_fixture(
+ scope,
+ WorkflowsFixtures.snapshot_attrs(%{steps: [step]})
+ )
+
+ run = insert_failed_run(scope, version)
+ tmp_dir = unique_tmp_dir("step-execution-failure-duration")
+ configure_workflow_data_dir(tmp_dir)
+ insert_lease(run.id, 1)
+
+ {:ok, store_state} =
+ SqliteStore.init(run.id,
+ data_dir: tmp_dir,
+ org_id: scope.project.workos_organization_id,
+ project_id: scope.project.id,
+ fence_token: 1,
+ repo: Repo
+ )
+
+ input_fact = %Fact{hash: 404, value: %{"email" => "test"}}
- test "create_changeset/2 includes workspace_id and user_id" do
- attrs = %{
- name: "Created",
- workspace_id: Ecto.UUID.generate(),
- user_id: Ecto.UUID.generate()
+ event_log = [
+ %RunnableDispatched{
+ runnable_id: 456,
+ node_name: step.id,
+ node_hash: 505,
+ input_fact: input_fact,
+ dispatched_at: 0,
+ policy: nil,
+ attempt: 0
+ },
+ %RunnableFailed{
+ runnable_id: 456,
+ node_hash: 505,
+ error: :boom,
+ failed_at: 0,
+ duration_us: 811,
+ attempts: 1,
+ failure_action: :halt
}
+ ]
- changeset = Workflow.create_changeset(%Workflow{}, attrs)
+ assert :ok = SqliteStore.save(run.id, event_log, store_state)
- assert changeset.valid?
- assert get_change(changeset, :workspace_id) == attrs.workspace_id
- assert get_change(changeset, :user_id) == attrs.user_id
- end
+ assert {:ok, [%{duration_us: 811, status: "failed", step_id: step_id}]} =
+ Workflows.list_run_step_executions(scope, run.id)
+
+ assert step_id == step.id
+ end
+
+ defp definition_fixture(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Workflow #{System.unique_integer([:positive])}",
+ description: "Draft definition"
+ })
+
+ %{definition: definition, draft: draft}
+ end
+
+ defp project_scope_fixture do
+ user = user_fixture()
+ organization_scope = organization_scope_fixture(user: user)
+
+ project =
+ project_fixture(organization_scope, %{name: "Project #{System.unique_integer([:positive])}"})
+
+ organization_scope
+ |> Scope.with_project(project)
+ |> Scope.with_project_role(:admin)
+ end
+
+ defp valid_snapshot_attrs do
+ entry_step = step(%{type_id: "manual_input", name: "Entry"})
+ debug_step = step(%{type_id: "debug", name: "Debug"})
+
+ snapshot_attrs(%{
+ steps: [entry_step, debug_step],
+ connections: [
+ connection(%{source_step_id: entry_step.id, target_step_id: debug_step.id})
+ ]
+ })
+ end
+
+ defp snapshot_attrs(overrides) do
+ Map.merge(
+ %{
+ steps: [],
+ connections: [],
+ step_groups: [],
+ viewport: %{"x" => 0, "y" => 0, "zoom" => 1.0},
+ settings: %{}
+ },
+ overrides
+ )
+ end
+
+ defp step(attrs) do
+ Map.merge(
+ %{
+ id: Ecto.UUID.generate(),
+ type_id: "debug",
+ name: "Step #{System.unique_integer([:positive])}",
+ config: %{},
+ position: %{"x" => 100, "y" => 100},
+ notes: nil
+ },
+ attrs
+ )
+ end
- test "workflow draft changeset casts editor_state" do
- workflow_id = Ecto.UUID.generate()
+ defp connection(attrs) do
+ Map.merge(
+ %{
+ id: Ecto.UUID.generate(),
+ source_step_id: Ecto.UUID.generate(),
+ source_output: "main",
+ target_step_id: Ecto.UUID.generate(),
+ target_input: "main"
+ },
+ attrs
+ )
+ end
+
+ defp assert_message!(messages, expected_substring) do
+ assert Enum.any?(messages, &String.contains?(&1, expected_substring))
+ end
- changeset =
- WorkflowDraft.changeset(%WorkflowDraft{workflow_id: workflow_id}, %{
- workflow_id: workflow_id,
- editor_state: %{"locks" => %{"step_1" => "user_1"}}
- })
+ defp eventually(fun, attempts \\ 50)
- assert changeset.valid?
- assert get_change(changeset, :editor_state) == %{"locks" => %{"step_1" => "user_1"}}
+ defp eventually(fun, attempts) when attempts > 0 do
+ case fun.() do
+ {:ok, value} ->
+ value
+
+ :retry ->
+ receive do
+ after
+ 20 -> eventually(fun, attempts - 1)
+ end
end
+ end
+
+ defp eventually(_fun, 0), do: flunk("condition was not met in time")
- test "generate_unique_step_identity/2 works for string-key maps" do
- existing_steps = [%{"name" => "HTTP Request", "id" => "http_request"}]
+ defp assert_worker_shutdown(run_id) do
+ case Worker.lookup(run_id) do
+ nil ->
+ :ok
- assert {"HTTP Request 2", "http_request_2"} =
- Workflows.generate_unique_step_identity(existing_steps, "HTTP Request")
+ pid ->
+ ref = Process.monitor(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2_000
end
end
+
+ defp insert_running_run(scope, version) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :running,
+ input: %{},
+ last_active_at: now,
+ started_at: now
+ })
+ |> Repo.insert!()
+ end
+
+ defp insert_completed_run(scope, version) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :completed,
+ input: %{},
+ output: %{},
+ last_active_at: now,
+ started_at: now,
+ completed_at: now
+ })
+ |> Repo.insert!()
+ end
+
+ defp insert_failed_run(scope, version) do
+ now = DateTime.utc_now()
+
+ %WorkflowRun{}
+ |> WorkflowRun.changeset(%{
+ user_id: scope.user.id,
+ workflow_definition_id: version.workflow_definition_id,
+ workflow_definition_version_id: version.id,
+ project_id: scope.project.id,
+ workos_organization_id: scope.project.workos_organization_id,
+ status: :failed,
+ input: %{},
+ error: %{"message" => "boom"},
+ last_active_at: now,
+ started_at: now,
+ completed_at: now
+ })
+ |> Repo.insert!()
+ end
+
+ defp insert_lease(run_id, fence_token) do
+ assert {:ok, _result} =
+ Ecto.Adapters.SQL.query(
+ Repo,
+ """
+ INSERT INTO workflow_run_leases (run_id, owner_node, fence_token, checkpoint_seq, lease_expiry)
+ VALUES ($1, NULL, $2, 0, NOW() + interval '30 seconds')
+ """,
+ [dump_uuid(run_id), fence_token]
+ )
+ end
+
+ defp dump_uuid(run_id), do: Ecto.UUID.dump!(run_id)
+
+ defp unique_tmp_dir(prefix) do
+ tmp_dir = Path.join(System.tmp_dir!(), "#{prefix}-#{System.unique_integer([:positive])}")
+ on_exit(fn -> File.rm_rf(tmp_dir) end)
+ tmp_dir
+ end
+
+ defp configure_workflow_data_dir(tmp_dir) do
+ previous = Application.get_env(:fizz, :workflow_data_dir)
+ Application.put_env(:fizz, :workflow_data_dir, tmp_dir)
+
+ on_exit(fn ->
+ Application.put_env(:fizz, :workflow_data_dir, previous)
+ end)
+ end
end
diff --git a/test/fizz/sprites_test.exs b/test/fizz/workspaces_test.exs
similarity index 75%
rename from test/fizz/sprites_test.exs
rename to test/fizz/workspaces_test.exs
index e1baec9..809d9e7 100644
--- a/test/fizz/sprites_test.exs
+++ b/test/fizz/workspaces_test.exs
@@ -1,25 +1,26 @@
-defmodule Fizz.SpritesTest do
+defmodule Fizz.WorkspacesTest do
use Fizz.DataCase, async: false
import Fizz.AccountsFixtures
alias Fizz.Accounts.Scope
- alias Fizz.Sprites
- alias Fizz.Sprites.{ConsoleSession, Sprite}
+ alias Fizz.Workspaces
+ alias Fizz.Workspaces.{ConsoleSession, Workspace}
alias Fizz.WorkOSHTTPMock
- test "list_workspace_sprites/2 returns unauthenticated when scope is nil" do
- assert {:error, :unauthenticated} = Sprites.list_workspace_sprites(nil, Ecto.UUID.generate())
+ test "list_project_workspaces/2 returns unauthenticated when scope is nil" do
+ assert {:error, :unauthenticated} =
+ Workspaces.list_project_workspaces(nil, Ecto.UUID.generate())
end
- test "provision_sprite/3 returns unauthenticated when scope is nil" do
+ test "create_workspace/3 returns unauthenticated when scope is nil" do
assert {:error, :unauthenticated} =
- Sprites.provision_sprite(nil, Ecto.UUID.generate(), %{"name" => "demo"})
+ Workspaces.create_workspace(nil, Ecto.UUID.generate(), %{"name" => "demo"})
end
test "queue_job/4 returns unauthenticated when scope is nil" do
assert {:error, :unauthenticated} =
- Sprites.queue_job(nil, Ecto.UUID.generate(), Ecto.UUID.generate(), %{
+ Workspaces.queue_job(nil, Ecto.UUID.generate(), Ecto.UUID.generate(), %{
"command" => "echo hi"
})
end
@@ -54,9 +55,9 @@ defmodule Fizz.SpritesTest do
owner_scope =
organization_scope_fixture(user: user, organization_id: org_id, organization_role: :owner)
- workspace = workspace_fixture(owner_scope, %{name: "Workspace #{System.unique_integer()}"})
- sprite = sprite_fixture(workspace.id, user.id)
- console_session = console_session_fixture(workspace.id, sprite.id, user.id)
+ project = project_fixture(owner_scope, %{name: "Project #{System.unique_integer()}"})
+ workspace = workspace_fixture(project.id, user.id)
+ console_session = console_session_fixture(project.id, workspace.id, user.id)
WorkOSHTTPMock.put_responses([
membership_response(user.workos_user_id, org_id),
@@ -65,26 +66,26 @@ defmodule Fizz.SpritesTest do
%{
scope: Scope.for_user(user),
+ project: project,
workspace: workspace,
- sprite: sprite,
console_session: console_session
}
end
test "closes once and preserves the first close reason on repeated calls", %{
scope: scope,
+ project: project,
workspace: workspace,
- sprite: sprite,
console_session: console_session
} do
- topic = "sprite_console:#{console_session.id}"
+ topic = "workspace_console:#{console_session.id}"
FizzWeb.Endpoint.subscribe(topic)
assert {:ok, closed_session} =
- Sprites.close_console(
+ Workspaces.close_console(
scope,
+ project.id,
workspace.id,
- sprite.id,
console_session.id,
"runner_down"
)
@@ -99,10 +100,10 @@ defmodule Fizz.SpritesTest do
}
assert {:ok, already_closed_session} =
- Sprites.close_console(
+ Workspaces.close_console(
scope,
+ project.id,
workspace.id,
- sprite.id,
console_session.id,
"disconnect"
)
@@ -113,25 +114,25 @@ defmodule Fizz.SpritesTest do
end
end
- defp sprite_fixture(workspace_id, user_id) do
+ defp workspace_fixture(project_id, user_id) do
unique = System.unique_integer([:positive])
- %Sprite{}
- |> Sprite.changeset(%{
- workspace_id: workspace_id,
+ %Workspace{}
+ |> Workspace.changeset(%{
+ project_id: project_id,
created_by_user_id: user_id,
- name: "sprite#{unique}",
+ name: "workspace#{unique}",
remote_name: "remote-#{unique}",
status: :ready
})
|> Repo.insert!()
end
- defp console_session_fixture(workspace_id, sprite_id, user_id) do
+ defp console_session_fixture(project_id, workspace_id, user_id) do
%ConsoleSession{}
|> ConsoleSession.changeset(%{
+ project_id: project_id,
workspace_id: workspace_id,
- sprite_id: sprite_id,
opened_by_user_id: user_id,
state: :active,
opened_at: DateTime.utc_now()
diff --git a/test/fizz_web/controllers/triggers/webhook_controller_test.exs b/test/fizz_web/controllers/triggers/webhook_controller_test.exs
new file mode 100644
index 0000000..e4c31ca
--- /dev/null
+++ b/test/fizz_web/controllers/triggers/webhook_controller_test.exs
@@ -0,0 +1,227 @@
+defmodule FizzWeb.Triggers.WebhookControllerTest do
+ use FizzWeb.ConnCase, async: false
+ use Oban.Testing, repo: Fizz.Repo
+
+ import Ecto.Query
+ import Fizz.WorkflowsFixtures
+
+ alias Fizz.Accounts.OauthConnection
+ alias Fizz.Repo
+ alias Fizz.Fields.Credential
+ alias Fizz.Integrations.Steps.Registry, as: StepRegistry
+ alias Fizz.TestSupport.Executors.FailingWebhookTrigger
+ alias Fizz.Triggers.Registry
+ alias Fizz.Triggers.TriggerRegistration
+ alias Fizz.Triggers.Workers.TriggerFireWorker
+ alias Fizz.Workflows
+
+ setup do
+ register_test_step_type(FailingWebhookTrigger)
+
+ scope = project_scope_fixture()
+ version = publish_github_trigger_workflow!(scope)
+ registration = webhook_registration(version.id)
+
+ start_supervised!({Registry, notifications?: false, refresh_interval_ms: :timer.hours(1)})
+ :ok = Registry.refresh()
+
+ %{scope: scope, registration: registration}
+ end
+
+ test "valid HMAC returns 202 Accepted and enqueues TriggerFireWorker", %{
+ conn: conn,
+ registration: registration
+ } do
+ registration_id = registration.id
+ body = github_payload() |> Jason.encode!()
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("x-github-event", "push")
+ |> put_req_header("x-github-delivery", "delivery-123")
+ |> put_req_header(
+ "x-hub-signature-256",
+ github_signature(body, registration.webhook_secret)
+ )
+ |> post(~p"/triggers/wh/#{registration.webhook_path}", body)
+
+ assert response(conn, 202) == ""
+
+ assert [
+ %{
+ args: %{
+ "trigger_registration_id" => ^registration_id,
+ "event_id" => "delivery-123",
+ "normalized_data" => %{"event_type" => "push"}
+ }
+ }
+ ] = all_enqueued(worker: TriggerFireWorker)
+ end
+
+ test "invalid HMAC returns 401 Unauthorized", %{conn: conn, registration: registration} do
+ body = github_payload() |> Jason.encode!()
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("x-github-event", "push")
+ |> put_req_header("x-hub-signature-256", github_signature(body, "wrong-secret"))
+ |> post(~p"/triggers/wh/#{registration.webhook_path}", body)
+
+ assert response(conn, 401) == ""
+ assert [] == all_enqueued(worker: TriggerFireWorker)
+ end
+
+ test "unknown webhook_path returns 404 Not Found", %{conn: conn} do
+ body = github_payload() |> Jason.encode!()
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(~p"/triggers/wh/unknown-path", body)
+
+ assert response(conn, 404) == ""
+ end
+
+ test "match? returning false returns 200 OK without enqueueing a job", %{
+ conn: conn,
+ registration: registration
+ } do
+ body = github_payload() |> Jason.encode!()
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("x-github-event", "issues")
+ |> put_req_header(
+ "x-hub-signature-256",
+ github_signature(body, registration.webhook_secret)
+ )
+ |> post(~p"/triggers/wh/#{registration.webhook_path}", body)
+
+ assert response(conn, 200) == ""
+ assert [] == all_enqueued(worker: TriggerFireWorker)
+ end
+
+ test "normalize_event errors return 422 Unprocessable Entity", %{conn: conn, scope: scope} do
+ %{version: version} = published_version_fixture(scope, failing_trigger_snapshot_attrs())
+ registration = webhook_registration(version.id)
+ :ok = Registry.refresh()
+
+ body = github_payload() |> Jason.encode!()
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header(
+ "x-hub-signature-256",
+ github_signature(body, registration.webhook_secret)
+ )
+ |> post(~p"/triggers/wh/#{registration.webhook_path}", body)
+
+ assert response(conn, 422) == ""
+ assert [] == all_enqueued(worker: TriggerFireWorker)
+ end
+
+ defp github_trigger_snapshot_attrs do
+ snapshot_attrs(%{
+ steps: [
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "github_trigger",
+ name: "GitHub Trigger",
+ config:
+ "github_trigger"
+ |> Fizz.Integrations.Steps.Registry.get_default_config()
+ |> Map.merge(%{"events" => ["push"], "repository" => "acme/site"})
+ })
+ ]
+ })
+ end
+
+ defp failing_trigger_snapshot_attrs do
+ snapshot_attrs(%{
+ steps: [
+ step(%{
+ id: Ecto.UUID.generate(),
+ type_id: "failing_webhook_trigger",
+ name: "Failing Trigger"
+ })
+ ]
+ })
+ end
+
+ defp webhook_registration(version_id) do
+ Repo.one!(
+ from(registration in TriggerRegistration,
+ where:
+ registration.definition_version_id == ^version_id and registration.kind == "webhook"
+ )
+ )
+ end
+
+ defp github_payload do
+ %{
+ "action" => "opened",
+ "ref" => "refs/heads/main",
+ "repository" => %{"full_name" => "acme/site"},
+ "sender" => %{"login" => "monalisa"}
+ }
+ end
+
+ defp github_signature(body, secret) do
+ digest =
+ :crypto.mac(:hmac, :sha256, secret, body)
+ |> Base.encode16(case: :lower)
+
+ "sha256=#{digest}"
+ end
+
+ defp register_test_step_type(module) do
+ definition = module.__step_definition__()
+ :ok = StepRegistry.register(definition)
+
+ on_exit(fn ->
+ :ok = StepRegistry.unregister(definition.id)
+ end)
+ end
+
+ defp publish_github_trigger_workflow!(scope) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Webhook Controller #{System.unique_integer([:positive])}",
+ description: "Webhook controller test"
+ })
+
+ snapshot_attrs = github_trigger_snapshot_attrs()
+ [trigger] = snapshot_attrs.steps
+
+ {:ok, saved_draft} = Workflows.save_draft(scope, draft, snapshot_attrs)
+ connection = insert_oauth_connection!(scope, "github_oauth")
+
+ assert {:ok, _binding} =
+ Credential.upsert_binding(saved_draft, scope, %{
+ user_id: scope.user.id,
+ workflow_definition_id: definition.id,
+ step_id: trigger.id,
+ requirement_key: "auth",
+ binding_data: %{"credential_id" => connection.id},
+ workos_organization_id: scope.organization_id
+ })
+
+ assert {:ok, version} = Workflows.publish_draft(scope, saved_draft)
+ version
+ end
+
+ defp insert_oauth_connection!(scope, provider) do
+ %OauthConnection{}
+ |> OauthConnection.changeset(%{
+ workos_organization_id: scope.organization_id,
+ user_id: scope.user.id,
+ provider: provider,
+ status: :active
+ })
+ |> Repo.insert!()
+ end
+end
diff --git a/test/fizz_web/live/projects_live_test.exs b/test/fizz_web/live/projects_live_test.exs
new file mode 100644
index 0000000..004923d
--- /dev/null
+++ b/test/fizz_web/live/projects_live_test.exs
@@ -0,0 +1,17 @@
+defmodule FizzWeb.ProjectsLiveTest do
+ use FizzWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ test "projects index requires authentication", %{conn: conn} do
+ assert {:error, {:redirect, %{to: "/auth/workos"}}} =
+ live(conn, ~p"/projects")
+ end
+
+ test "project show requires authentication", %{conn: conn} do
+ project_id = Ecto.UUID.generate()
+
+ assert {:error, {:redirect, %{to: "/auth/workos"}}} =
+ live(conn, ~p"/projects/#{project_id}")
+ end
+end
diff --git a/test/fizz_web/live/sprites_live_test.exs b/test/fizz_web/live/sprites_live_test.exs
deleted file mode 100644
index cae8fc1..0000000
--- a/test/fizz_web/live/sprites_live_test.exs
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule FizzWeb.SpritesLiveTest do
- use FizzWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- test "sprites index requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
-
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces/#{workspace_id}/sprites")
- end
-end
diff --git a/test/fizz_web/live/user_management_live_test.exs b/test/fizz_web/live/user_management_live_test.exs
index 6bff010..2df2430 100644
--- a/test/fizz_web/live/user_management_live_test.exs
+++ b/test/fizz_web/live/user_management_live_test.exs
@@ -150,6 +150,7 @@ defmodule FizzWeb.UserManagementLiveTest do
|> render_click()
assert has_element?(view, "#create-credential-form")
+ assert has_element?(view, "#credential_credentials_secret")
view
|> element("#create-credential-form")
@@ -158,7 +159,7 @@ defmodule FizzWeb.UserManagementLiveTest do
"provider" => "openai_api_key",
"provider_label" => "OpenAI Key",
"provider_custom_name" => "",
- "secret" => "sk-openai-1"
+ "credentials" => %{"secret" => "sk-openai-1"}
}
})
@@ -175,7 +176,7 @@ defmodule FizzWeb.UserManagementLiveTest do
"rotate_credential" => %{
"provider_label" => "OpenAI Key Rotated",
"provider_custom_name" => "",
- "secret" => "sk-openai-2"
+ "credentials" => %{"secret" => "sk-openai-2"}
}
})
@@ -192,6 +193,59 @@ defmodule FizzWeb.UserManagementLiveTest do
refute Repo.get(ApiCredential, credential.id)
end
+ test "keeps create credential form mounted after duplicate label error", %{conn: conn} do
+ put_http_responses([
+ {:repeat, &org_scoped_credential_flow_response/1}
+ ])
+
+ {:ok, view, _html} = live(conn, ~p"/settings/")
+
+ view
+ |> element("#settings-tab-api-keys")
+ |> render_click()
+
+ view
+ |> element("#open-create-credential-modal")
+ |> render_click()
+
+ view
+ |> element("#select-provider-openai_api_key")
+ |> render_click()
+
+ view
+ |> element("#create-credential-form")
+ |> render_submit(%{
+ "credential" => %{
+ "provider" => "openai_api_key",
+ "provider_label" => "OpenAI Duplicate",
+ "provider_custom_name" => "",
+ "credentials" => %{"secret" => "sk-openai-first"}
+ }
+ })
+
+ view
+ |> element("#open-create-credential-modal")
+ |> render_click()
+
+ view
+ |> element("#select-provider-openai_api_key")
+ |> render_click()
+
+ view
+ |> element("#create-credential-form")
+ |> render_submit(%{
+ "credential" => %{
+ "provider" => "openai_api_key",
+ "provider_label" => "OpenAI Duplicate",
+ "provider_custom_name" => "",
+ "credentials" => %{"secret" => "sk-openai-second"}
+ }
+ })
+
+ assert has_element?(view, "#create-credential-form")
+ assert has_element?(view, "#credential_credentials_secret")
+ end
+
test "switches organizations and refreshes token", %{conn: conn, user: user} do
put_http_responses([
memberships_response([
@@ -278,7 +332,7 @@ defmodule FizzWeb.UserManagementLiveTest do
"role" => %{"slug" => "owner"}
}
}},
- # build_scope_for_workspace context check
+ # build_scope_for_project context check
memberships_response([
%{
"id" => "om_personal",
diff --git a/test/fizz_web/live/workflow_editor_live_test.exs b/test/fizz_web/live/workflow_editor_live_test.exs
new file mode 100644
index 0000000..b1d22da
--- /dev/null
+++ b/test/fizz_web/live/workflow_editor_live_test.exs
@@ -0,0 +1,1699 @@
+defmodule FizzWeb.WorkflowEditorLiveTest do
+ use FizzWeb.ConnCase, async: false
+
+ import LiveVue.Test
+ import Phoenix.LiveViewTest
+
+ alias Fizz.Accounts
+ alias Fizz.Accounts.{ApiCredential, Scope}
+ alias Fizz.Workflows
+ alias Fizz.Workflows.DraftSession
+ alias Fizz.WorkflowsFixtures
+ alias FizzWeb.Presence
+
+ defmodule ReqMock do
+ def request(opts) do
+ case {opts[:method], opts[:url]} do
+ {:get, "/user_management/organization_memberships"} ->
+ organization_id =
+ Keyword.get(opts[:params] || [], :organization_id) ||
+ get_in(opts, [:params, :organization_id]) ||
+ "org_test"
+
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: %{
+ "data" => [
+ %{
+ "id" => "om_#{System.unique_integer([:positive])}",
+ "organization_id" => organization_id,
+ "status" => "active",
+ "role" => %{"slug" => "owner"}
+ }
+ ]
+ }
+ }}
+
+ {:post, "/widgets/token"} ->
+ {:ok, %Req.Response{status: 200, body: %{"token" => "widget_token_123"}}}
+
+ _ ->
+ {:ok, %Req.Response{status: 404, body: %{"message" => "Not found"}}}
+ end
+ end
+ end
+
+ setup do
+ previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
+ previous_workos_client = Application.get_env(:workos, WorkOS.Client)
+ previous_draft_session = Application.get_env(:fizz, DraftSession, [])
+
+ Application.put_env(:fizz, :workos_http_client_module, ReqMock)
+
+ Application.put_env(:fizz, DraftSession,
+ persist_debounce_ms: 25,
+ idle_timeout_ms: 75,
+ persist_retry_base_ms: 25,
+ persist_retry_max_ms: 50
+ )
+
+ Application.put_env(:workos, WorkOS.Client,
+ api_key: "sk_test_123",
+ client_id: "client_test_123",
+ client: Fizz.Accounts.WorkOS.ReqClient
+ )
+
+ on_exit(fn ->
+ restore_env(:fizz, :workos_http_client_module, previous_http_client)
+ Application.put_env(:fizz, DraftSession, previous_draft_session)
+ restore_env(:workos, WorkOS.Client, previous_workos_client)
+ end)
+
+ :ok
+ end
+
+ test "mounting loads the definition and draft and renders the workflow editor", %{
+ conn: conn
+ } do
+ %{conn: conn, definition: definition, draft: draft} = editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ assert has_element?(view, "#workflow-editor")
+
+ vue = get_vue(view, id: "workflow-editor")
+
+ assert vue.component == "WorkflowEditor"
+ assert vue.props["workflow"]["id"] == definition.id
+ assert vue.props["workflow"]["project_id"] == definition.project_id
+ assert vue.props["workflow"]["name"] == definition.name
+ assert vue.props["workflow"]["created_by_user_id"] == definition.created_by_user_id
+ assert vue.props["workflow"]["draft"]["id"] == draft.id
+ assert vue.props["workflow"]["draft"]["workflow_definition_id"] == definition.id
+ assert vue.props["workflow"]["draft"]["version"] == draft.version
+ assert vue.props["workflow"]["draft"]["status"] == "draft"
+ assert vue.props["workflow"]["draft"]["step_groups"] == []
+ assert vue.props["widgetToken"] == "widget_token_123"
+ refute Map.has_key?(vue.props["workflow"], "current_version_tag")
+ refute Map.has_key?(vue.props["workflow"], "public")
+ refute Map.has_key?(vue.props["workflow"], "user_id")
+ refute Map.has_key?(vue.props["workflow"]["draft"], "workflow_id")
+ refute Map.has_key?(vue.props["workflow"]["draft"], "groups")
+ refute Map.has_key?(vue.props["workflow"]["draft"], "triggers")
+ end
+
+ test "workflow editor props include draft steps and step groups from backend data", %{
+ conn: conn
+ } do
+ entry_step =
+ WorkflowsFixtures.step(%{
+ name: "Fetch Orders",
+ position: %{"x" => 140, "y" => 220}
+ })
+
+ grouped_step =
+ WorkflowsFixtures.step(%{
+ name: "Send Email",
+ position: %{"x" => 360, "y" => 220}
+ })
+
+ step_group = %{
+ id: Ecto.UUID.generate(),
+ name: "Fulfillment",
+ step_ids: [grouped_step.id],
+ position: %{"x" => 300, "y" => 180, "width" => 420, "height" => 240},
+ color: "#16A34A",
+ font_size: 16,
+ collapsed: false
+ }
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [entry_step, grouped_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: entry_step.id,
+ target_step_id: grouped_step.id
+ })
+ ],
+ step_groups: [step_group],
+ viewport: %{"x" => 32, "y" => 48, "zoom" => 1.2}
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ vue = get_vue(view, id: "workflow-editor")
+ draft = vue.props["workflow"]["draft"]
+
+ assert Enum.any?(draft["steps"], fn step ->
+ step["id"] == entry_step.id and
+ step["name"] == entry_step.name and
+ step["position"] == entry_step.position
+ end)
+
+ assert Enum.any?(draft["steps"], fn step ->
+ step["id"] == grouped_step.id and
+ step["name"] == grouped_step.name and
+ step["position"] == grouped_step.position
+ end)
+
+ assert draft["step_groups"] == [
+ %{
+ "id" => step_group.id,
+ "name" => step_group.name,
+ "step_ids" => step_group.step_ids,
+ "position" => step_group.position,
+ "color" => step_group.color,
+ "font_size" => step_group.font_size,
+ "collapsed" => step_group.collapsed
+ }
+ ]
+
+ refute Map.has_key?(hd(draft["step_groups"]), "output_step_id")
+ end
+
+ test "navigate_revisions opens the dedicated revision viewer page", %{conn: conn} do
+ %{conn: conn, definition: definition} = editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ {:ok, revision_view, _html} =
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "navigate_revisions"})
+ |> follow_redirect(
+ conn,
+ ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit/revisions"
+ )
+
+ register_revision_cleanup(revision_view)
+
+ assert has_element?(revision_view, "#workflow-revision-viewer")
+
+ vue = get_vue(revision_view, id: "workflow-revision-viewer")
+
+ assert vue.component == "RevisionViewer"
+ assert vue.props["workflow"]["id"] == definition.id
+ assert vue.props["revision"]["kind"] == "current"
+ assert vue.props["revision"]["label"] == "Current draft"
+ end
+
+ test "revision viewer selects an undo preview via patch params", %{conn: conn} do
+ %{conn: conn, definition: definition} = editor_fixture(conn)
+
+ {:ok, editor_view, _html} = live_editor(conn, definition)
+
+ editor_view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "debug",
+ "position" => %{"x" => 420, "y" => 180}
+ }
+ })
+
+ {:ok, revision_view, _html} = live_revisions(conn, definition)
+
+ revision_view
+ |> element("#workflow-revision-viewer")
+ |> render_hook("select_revision", %{"kind" => "undo", "depth" => 1})
+
+ path = assert_patch(revision_view)
+
+ assert path =~
+ ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit/revisions"
+
+ assert path =~ "kind=undo"
+ assert path =~ "depth=1"
+
+ socket = live_socket(revision_view)
+
+ assert socket.assigns.selected_revision["kind"] == "undo"
+ assert socket.assigns.selected_revision["depth"] == 1
+ assert socket.assigns.undo_stack != []
+ assert socket.assigns.selected_draft.steps == []
+ end
+
+ test "revision viewer applies a published version back into the current draft", %{conn: conn} do
+ entry_step =
+ WorkflowsFixtures.step(%{
+ name: "Published Step",
+ position: %{"x" => 140, "y" => 220}
+ })
+
+ published_snapshot =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [entry_step],
+ connections: [],
+ step_groups: []
+ })
+
+ %{
+ conn: conn,
+ definition: definition,
+ draft: draft,
+ project_scope: project_scope
+ } = editor_fixture(conn, published_snapshot)
+
+ assert {:ok, published_version} = Workflows.publish_draft(project_scope, draft)
+
+ {:ok, editor_view, _html} = live_editor(conn, definition)
+ current_draft_id = view_version_id(editor_view)
+
+ editor_view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "debug",
+ "position" => %{"x" => 420, "y" => 180}
+ }
+ })
+
+ {:ok, revision_view, _html} = live_revisions(conn, definition)
+
+ revision_view
+ |> element("#workflow-revision-viewer")
+ |> render_hook("select_revision", %{"kind" => "version", "id" => published_version.id})
+
+ path = assert_patch(revision_view)
+
+ assert path =~
+ ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit/revisions"
+
+ assert path =~ "kind=version"
+ assert path =~ "id=#{published_version.id}"
+
+ socket = live_socket(revision_view)
+
+ assert socket.assigns.selected_revision["kind"] == "version"
+ assert socket.assigns.selected_revision["id"] == published_version.id
+ assert socket.assigns.selected_draft.id == published_version.id
+
+ {:ok, redirected_view, _html} =
+ revision_view
+ |> element("#workflow-revision-viewer")
+ |> render_hook("apply_revision", %{})
+ |> follow_redirect(
+ conn,
+ ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit"
+ )
+
+ register_editor_cleanup(redirected_view)
+
+ assert has_element?(redirected_view, "#workflow-editor")
+
+ assert :ok =
+ wait_until(fn ->
+ case Workflows.get_version(project_scope, current_draft_id) do
+ {:ok, persisted_draft} ->
+ Enum.map(persisted_draft.steps, & &1.name) == ["Published Step"] and
+ persisted_draft.connections == []
+
+ {:error, _reason} ->
+ false
+ end
+ end)
+ end
+
+ test "editor_command add_step applies an operation through DraftSession", %{conn: conn} do
+ %{conn: conn, definition: definition, project_scope: project_scope, user: user} =
+ editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "debug",
+ "position" => %{"x" => 420, "y" => 180}
+ }
+ })
+
+ assert {:ok, draft, 1, undo_state, _editor_state} =
+ DraftSession.join(view_version_id(view), project_scope, user.id)
+
+ assert length(draft.steps) == 1
+ assert undo_state.canUndo
+ assert undo_state.undoLabel == "Add Step"
+ end
+
+ test "commit_drag_layout acknowledgements include the drag transaction id", %{conn: conn} do
+ moved_step =
+ WorkflowsFixtures.step(%{
+ name: "Fetch Orders",
+ position: %{"x" => 140, "y" => 220}
+ })
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [moved_step]
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ txn_id = "txn_drag_commit"
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "commit_drag_layout",
+ "payload" => %{
+ "txn_id" => txn_id,
+ "base_seq" => 0,
+ "groups" => [],
+ "step_positions" => %{
+ moved_step.id => %{"x" => 320, "y" => 260}
+ },
+ "group_id_by_step_id" => %{}
+ }
+ })
+
+ assert_push_event(view, "workflow:operation_ack", %{
+ type: "commit_drag_layout",
+ seq: 1,
+ txn_id: ^txn_id
+ })
+
+ updated_step = Enum.find(live_socket(view).assigns.draft.steps, &(&1.id == moved_step.id))
+
+ assert updated_step.position["x"] == 320
+ assert updated_step.position["y"] == 260
+ assert live_socket(view).assigns.collab_seq == 1
+ end
+
+ test "draft changes autosave and update the save status indicator", %{conn: conn} do
+ %{conn: conn, definition: definition, project_scope: project_scope} = editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ initial_updated_at = live_socket(view).assigns.draft.updated_at
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "debug",
+ "position" => %{"x" => 420, "y" => 180}
+ }
+ })
+
+ assert live_socket(view).assigns.save_status == "saving"
+
+ assert :ok =
+ wait_until(fn ->
+ render(view)
+
+ with {:ok, persisted_draft} <-
+ Workflows.get_version(project_scope, view_version_id(view)) do
+ live_socket(view).assigns.save_status == "saved" and
+ length(persisted_draft.steps) == 1 and
+ persisted_draft.updated_at != initial_updated_at
+ else
+ {:error, _reason} -> false
+ end
+ end)
+
+ vue = get_vue(view, id: "workflow-editor")
+ assert vue.props["saveStatus"] == "saved"
+ end
+
+ test "run_test persists the current draft before compiling", %{conn: conn} do
+ %{conn: conn, definition: definition, project_scope: project_scope} = editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "debug",
+ "position" => %{"x" => 420, "y" => 180}
+ }
+ })
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ assert {:ok, persisted_draft} = Workflows.get_version(project_scope, view_version_id(view))
+ assert length(persisted_draft.steps) == 1
+ assert live_socket(view).assigns.execution != nil
+
+ assert {:ok, %{status: :completed}} =
+ wait_for_run_status(
+ project_scope,
+ live_socket(view).assigns.execution.id,
+ :completed
+ )
+
+ assert :ok = wait_for_worker_exit(live_socket(view).assigns.execution.id)
+ end
+
+ test "run_test starts a workflow run tagged with editor_test", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.valid_snapshot_attrs()
+
+ %{conn: conn, definition: definition, project_scope: project_scope, user: user} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ execution = live_socket(view).assigns.execution
+
+ assert execution.workflow_definition_id == definition.id
+ assert execution.workflow_definition_version_id == view_version_id(view)
+ assert execution.trigger.type == "editor_test"
+ assert execution.triggered_by["kind"] == "editor_test"
+ assert {:ok, run} = Workflows.get_run(project_scope, execution.id)
+ assert run.triggered_by["kind"] == "editor_test"
+ assert run.triggered_by["user_id"] == user.id
+
+ assert {:ok, %{status: :completed}} =
+ wait_for_run_status(project_scope, execution.id, :completed)
+
+ assert :ok = wait_for_worker_exit(execution.id)
+ GenServer.stop(view.pid, :normal)
+ end
+
+ test "run_test restores missing credential declarations before readiness", %{conn: conn} do
+ append_step =
+ WorkflowsFixtures.step(%{
+ type_id: "google_sheets_append_row",
+ name: "Append Row",
+ config: %{
+ "credential_ref" => nil,
+ "spreadsheet_id" => "sheet_123",
+ "values" => %{"A" => "1"}
+ }
+ })
+
+ snapshot_attrs = WorkflowsFixtures.snapshot_attrs(%{steps: [append_step]})
+
+ %{conn: conn, definition: definition, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+ assert {:ok, runs_before} = Workflows.list_runs(project_scope, definition_id: definition.id)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ assert_push_event(view, "credential_bindings_needed", %{
+ descriptors: [
+ %{
+ step_id: step_id,
+ requirement_key: "auth",
+ provider: "google_oauth",
+ auth_type: "oauth"
+ }
+ ]
+ })
+
+ assert step_id == append_step.id
+ assert live_socket(view).assigns.execution == nil
+ assert live_socket(view).assigns.validation_errors == %{}
+
+ assert {:ok, runs_after} = Workflows.list_runs(project_scope, definition_id: definition.id)
+ assert runs_after == runs_before
+ end
+
+ test "run_test uses saved manual trigger test data as workflow input", %{conn: conn} do
+ trigger_step =
+ WorkflowsFixtures.step(%{
+ type_id: "manual_input",
+ name: "Manual Trigger",
+ config: %{
+ "input_schema" => %{
+ "type" => "object",
+ "properties" => %{
+ "name" => %{"type" => "string"},
+ "email" => %{"type" => "string"}
+ }
+ },
+ "test_data" => %{
+ "name" => "Ada Lovelace",
+ "email" => "ada@example.com"
+ }
+ }
+ })
+
+ debug_step = WorkflowsFixtures.step(%{type_id: "debug", name: "Debug"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [trigger_step, debug_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: trigger_step.id,
+ target_step_id: debug_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ execution = live_socket(view).assigns.execution
+
+ assert execution.input == %{
+ "name" => "Ada Lovelace",
+ "email" => "ada@example.com"
+ }
+
+ assert execution.triggered_by["trigger_step_id"] == trigger_step.id
+ assert {:ok, run} = Workflows.get_run(project_scope, execution.id)
+
+ assert run.input == %{
+ "name" => "Ada Lovelace",
+ "email" => "ada@example.com"
+ }
+
+ assert {:ok, %{status: :completed}} =
+ wait_for_run_status(project_scope, execution.id, :completed)
+
+ assert :ok = wait_for_worker_exit(execution.id)
+ GenServer.stop(view.pid, :normal)
+ end
+
+ test "run_node starts a partial run and excludes downstream steps", %{conn: conn} do
+ trigger_step =
+ WorkflowsFixtures.step(%{
+ type_id: "manual_input",
+ name: "Manual Trigger",
+ config: %{
+ "test_data" => %{"ticket_id" => "T-42"}
+ }
+ })
+
+ target_step = WorkflowsFixtures.step(%{type_id: "debug", name: "Target"})
+ downstream_step = WorkflowsFixtures.step(%{type_id: "debug", name: "Downstream"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [trigger_step, target_step, downstream_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: trigger_step.id,
+ target_step_id: target_step.id
+ }),
+ WorkflowsFixtures.connection(%{
+ source_step_id: target_step.id,
+ target_step_id: downstream_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "run_node",
+ "payload" => %{"step_id" => target_step.id}
+ })
+
+ execution = live_socket(view).assigns.execution
+
+ assert execution.input == %{"ticket_id" => "T-42"}
+ assert execution.triggered_by["mode"] == "partial"
+ assert execution.triggered_by["target_step_id"] == target_step.id
+
+ assert {:ok, run} = Workflows.get_run(project_scope, execution.id)
+ assert run.input == %{"ticket_id" => "T-42"}
+ assert run.triggered_by["mode"] == "partial"
+ assert run.triggered_by["target_step_id"] == target_step.id
+
+ assert {:ok, %{status: :completed}} =
+ wait_for_run_status(project_scope, execution.id, :completed)
+
+ assert :ok =
+ wait_until(fn ->
+ render(view)
+
+ step_ids =
+ live_socket(view).assigns.step_executions
+ |> Enum.map(& &1.step_id)
+ |> Enum.uniq()
+
+ Enum.sort(step_ids) == Enum.sort([trigger_step.id, target_step.id])
+ end)
+
+ refute Enum.any?(
+ live_socket(view).assigns.step_executions,
+ &(&1.step_id == downstream_step.id)
+ )
+
+ assert :ok = wait_for_worker_exit(execution.id)
+ GenServer.stop(view.pid, :normal)
+ end
+
+ test "publish_workflow persists before validation and blocks on validation errors", %{
+ conn: conn
+ } do
+ %{conn: conn, definition: definition, project_scope: project_scope} = editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "http_request",
+ "position" => %{"x" => 240, "y" => 180}
+ }
+ })
+
+ version_id = view_version_id(view)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "publish_workflow",
+ "payload" => %{}
+ })
+
+ assert_push_event(view, "workflow:publish_result", %{
+ success: false,
+ validation_errors: errors,
+ execution_hash_changed: execution_hash_changed
+ })
+
+ assert Enum.any?(errors, fn error ->
+ code = Map.get(error, :code) || Map.get(error, "code")
+ field = Map.get(error, :field) || Map.get(error, "field")
+
+ code == "missing_required_field" and field == "url"
+ end)
+
+ assert execution_hash_changed in [true, false, nil]
+
+ assert {:ok, persisted_draft} = Workflows.get_version(project_scope, version_id)
+ assert length(persisted_draft.steps) == 1
+ assert persisted_draft.status == :draft
+ assert persisted_draft.published_at == nil
+ end
+
+ test "validate_draft returns publish preview results for the publish modal", %{conn: conn} do
+ %{conn: conn, definition: definition} = editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "http_request",
+ "position" => %{"x" => 240, "y" => 180}
+ }
+ })
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "validate_draft", "payload" => %{}})
+
+ assert_push_event(view, "workflow:validation_result", %{
+ valid: false,
+ validation_errors: errors,
+ execution_hash_changed: execution_hash_changed
+ })
+
+ assert Enum.any?(errors, fn error ->
+ code = Map.get(error, :code) || Map.get(error, "code")
+ field = Map.get(error, :field) || Map.get(error, "field")
+
+ code == "missing_required_field" and field == "url"
+ end)
+
+ assert execution_hash_changed in [true, false, nil]
+ end
+
+ test "publish_workflow publishes a valid draft", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.valid_snapshot_attrs()
+
+ %{conn: conn, definition: definition, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ version_id = view_version_id(view)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "publish_workflow",
+ "payload" => %{}
+ })
+
+ assert {:ok, published_version} = Workflows.get_version(project_scope, version_id)
+ assert published_version.status == :published
+ assert %DateTime{} = published_version.published_at
+ end
+
+ test "undo and redo commands work through DraftSession", %{conn: conn} do
+ %{conn: conn, definition: definition, project_scope: project_scope, user: user} =
+ editor_fixture(conn)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "add_step",
+ "payload" => %{
+ "type_id" => "debug",
+ "position" => %{"x" => 100, "y" => 120}
+ }
+ })
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "undo", "payload" => %{"count" => 1}})
+
+ version_id = view_version_id(view)
+
+ assert {:ok, draft_after_undo, 2, undo_state_after_undo, _editor_state} =
+ DraftSession.join(version_id, project_scope, user.id)
+
+ assert draft_after_undo.steps == []
+ refute undo_state_after_undo.canUndo
+ assert undo_state_after_undo.canRedo
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "redo", "payload" => %{"count" => 1}})
+
+ assert {:ok, draft_after_redo, 3, undo_state_after_redo, _editor_state} =
+ DraftSession.join(version_id, project_scope, user.id)
+
+ assert length(draft_after_redo.steps) == 1
+ assert undo_state_after_redo.canUndo
+ refute undo_state_after_redo.canRedo
+ end
+
+ test "presence is tracked on mount", %{conn: conn} do
+ %{conn: conn, definition: definition, draft: draft, user: user} = editor_fixture(conn)
+ user_id = user.id
+
+ {:ok, _view, _html} = live_editor(conn, definition)
+
+ assert %{^user_id => %{metas: [meta | _]}} = Presence.list("draft:#{draft.id}")
+ assert meta.user_id == user.id
+ assert meta.user_email == user.email
+ assert meta.selected_steps == []
+ end
+
+ test "cursor presence updates propagate to collaborators and clear on leave", %{conn: conn} do
+ %{conn: owner_conn, definition: definition, project_scope: project_scope, user: owner_user} =
+ editor_fixture(conn)
+
+ owner_user_id = owner_user.id
+
+ %{conn: collaborator_conn, user: collaborator_user} =
+ collaborator_fixture(project_scope)
+
+ {:ok, owner_view, _html} =
+ live(owner_conn, ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit")
+
+ {:ok, collaborator_view, _html} =
+ live(
+ collaborator_conn,
+ ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit"
+ )
+
+ draft_topic = "draft:#{view_version_id(owner_view)}"
+
+ owner_view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "mouse_move",
+ "payload" => %{"x" => 128, "y" => 256}
+ })
+
+ assert :ok =
+ wait_until(fn ->
+ case Presence.list(draft_topic) do
+ %{^owner_user_id => %{metas: [meta | _rest]}} ->
+ meta.cursor == %{x: 128, y: 256}
+
+ _other ->
+ false
+ end
+ end)
+
+ send(
+ collaborator_view.pid,
+ %Phoenix.Socket.Broadcast{event: "presence_diff", topic: draft_topic, payload: %{}}
+ )
+
+ render(collaborator_view)
+
+ assert %{cursor: %{x: 128, y: 256}} = collaborator_presence(collaborator_view, owner_user_id)
+
+ owner_view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "mouse_leave", "payload" => %{}})
+
+ assert :ok =
+ wait_until(fn ->
+ case Presence.list(draft_topic) do
+ %{^owner_user_id => %{metas: [meta | _rest]}} ->
+ is_nil(meta.cursor)
+
+ _other ->
+ false
+ end
+ end)
+
+ send(
+ collaborator_view.pid,
+ %Phoenix.Socket.Broadcast{event: "presence_diff", topic: draft_topic, payload: %{}}
+ )
+
+ render(collaborator_view)
+
+ assert %{cursor: nil} = collaborator_presence(collaborator_view, owner_user_id)
+
+ assert live_socket(collaborator_view).assigns.current_user_id == collaborator_user.id
+ end
+
+ test "unauthenticated users are redirected", %{conn: conn} do
+ project_id = Ecto.UUID.generate()
+ definition_id = Ecto.UUID.generate()
+
+ assert {:error, {:redirect, %{to: "/auth/workos"}}} =
+ live(conn, ~p"/projects/#{project_id}/workflows/#{definition_id}/edit")
+ end
+
+ test "preview_expression renders against pinned upstream output", %{conn: conn} do
+ source_step = WorkflowsFixtures.step(%{name: "Fetch Orders"})
+ target_step = WorkflowsFixtures.step(%{name: "Send Email"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [source_step, target_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: source_step.id,
+ target_step_id: target_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "pin_output",
+ "payload" => %{
+ "step_id" => source_step.id,
+ "output_data" => %{"name" => "Alice"}
+ }
+ })
+
+ expression = "Hello {{ steps.#{source_step.id}.name }}"
+ preview_expression(view, target_step.id, "text", expression)
+
+ assert preview_value(view, preview_key(target_step.id, "text")) == "Hello Alice"
+ end
+
+ test "preview_expression returns parse errors for invalid expressions", %{conn: conn} do
+ source_step = WorkflowsFixtures.step(%{name: "Fetch Orders"})
+ target_step = WorkflowsFixtures.step(%{name: "Send Email"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [source_step, target_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: source_step.id,
+ target_step_id: target_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ expression = "{% if steps.#{source_step.id}.name %}"
+ preview_expression(view, target_step.id, "text", expression)
+
+ preview = preview_value(view, preview_key(target_step.id, "text"))
+
+ assert preview.type == "parse_error"
+ assert is_list(preview.errors)
+ assert preview.errors != []
+ end
+
+ test "preview context prefers pinned outputs over execution outputs", %{conn: conn} do
+ source_step = WorkflowsFixtures.step(%{name: "Fetch Orders"})
+ target_step = WorkflowsFixtures.step(%{name: "Send Email"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [source_step, target_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: source_step.id,
+ target_step_id: target_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ put_step_executions(view, [
+ %{
+ "step_id" => source_step.id,
+ "output_data" => %{"name" => "Execution"},
+ "inserted_at" => "2026-03-20T10:00:00Z"
+ }
+ ])
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "pin_output",
+ "payload" => %{
+ "step_id" => source_step.id,
+ "output_data" => %{"name" => "Pinned"}
+ }
+ })
+
+ expression = "Hello {{ steps.#{source_step.id}.name }}"
+ preview_expression(view, target_step.id, "text", expression)
+
+ assert preview_value(view, preview_key(target_step.id, "text")) == "Hello Pinned"
+ end
+
+ test "preview_expression debounces repeated requests for the same field", %{conn: conn} do
+ source_step = WorkflowsFixtures.step(%{name: "Fetch Orders"})
+ target_step = WorkflowsFixtures.step(%{name: "Send Email"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [source_step, target_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: source_step.id,
+ target_step_id: target_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "pin_output",
+ "payload" => %{
+ "step_id" => source_step.id,
+ "output_data" => %{"name" => "Alice"}
+ }
+ })
+
+ key = preview_key(target_step.id, "text")
+ first_expression = "Hello {{ steps.#{source_step.id}.name }}"
+ second_expression = "Goodbye {{ steps.#{source_step.id}.name }}"
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "preview_expression",
+ "payload" => %{
+ "step_id" => target_step.id,
+ "field_key" => "text",
+ "expression" => first_expression
+ }
+ })
+
+ first_ref = preview_timer_ref(view, key)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "preview_expression",
+ "payload" => %{
+ "step_id" => target_step.id,
+ "field_key" => "text",
+ "expression" => second_expression
+ }
+ })
+
+ second_ref = preview_timer_ref(view, key)
+
+ refute first_ref == second_ref
+
+ send(view.pid, {:preview_expression, key, target_step.id, first_expression, first_ref})
+ render(view)
+
+ refute Map.has_key?(expression_previews(view), key)
+
+ send(view.pid, {:preview_expression, key, target_step.id, second_expression, second_ref})
+ render(view)
+
+ assert preview_value(view, key) == "Goodbye Alice"
+ end
+
+ test "step execution events update step_executions assigns", %{conn: conn} do
+ step = WorkflowsFixtures.step(%{type_id: "debug", name: "Debug"})
+ step_id = step.id
+ snapshot_attrs = WorkflowsFixtures.snapshot_attrs(%{steps: [step]})
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ put_execution(view, %{id: "run-live", status: "running"})
+
+ started_at = DateTime.utc_now()
+
+ send(
+ view.pid,
+ {:step_started,
+ %{
+ run_id: "run-live",
+ runnable_id: 123,
+ step_id: step_id,
+ attempt: 0,
+ input: %{"name" => "Ada"},
+ input_fact_hash: "input-hash",
+ started_at: started_at
+ }}
+ )
+
+ render(view)
+
+ assert [
+ %{
+ id: "run-live:123:0",
+ step_id: ^step_id,
+ status: "running",
+ step_type_id: "debug",
+ input_data: %{"name" => "Ada"}
+ }
+ ] =
+ live_socket(view).assigns.step_executions
+
+ completed_at = DateTime.utc_now()
+
+ send(
+ view.pid,
+ {:step_completed,
+ %{
+ run_id: "run-live",
+ runnable_id: 123,
+ step_id: step_id,
+ attempt: 0,
+ input: %{"name" => "Ada"},
+ input_fact_hash: "input-hash",
+ output: %{"ok" => true},
+ output_fact_hash: "output-hash",
+ output_summary: "%{\"ok\" => true}",
+ duration_us: 12_000,
+ completed_at: completed_at
+ }}
+ )
+
+ render(view)
+
+ assert [
+ %{
+ id: "run-live:123:0",
+ status: "completed",
+ output_data: %{"ok" => true},
+ output_item_count: 1,
+ duration_us: 12_000
+ }
+ ] =
+ live_socket(view).assigns.step_executions
+ end
+
+ test "terminal status unsubscribes from the run topic", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.long_running_snapshot_attrs(5_000)
+
+ %{conn: conn, definition: definition, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ execution_id = live_socket(view).assigns.execution.id
+
+ send(
+ view.pid,
+ {:run_status_changed,
+ %{run_id: execution_id, status: :completed, timestamp: DateTime.utc_now()}}
+ )
+
+ render(view)
+
+ Phoenix.PubSub.broadcast(
+ Fizz.PubSub,
+ "workflow_run:#{execution_id}",
+ {:step_started,
+ %{
+ run_id: execution_id,
+ runnable_id: 999,
+ step_id: hd(Enum.map(snapshot_attrs.steps, & &1.id)),
+ attempt: 0,
+ started_at: DateTime.utc_now()
+ }}
+ )
+
+ _ = :sys.get_state(view.pid)
+
+ refute Enum.any?(
+ live_socket(view).assigns.step_executions,
+ &(&1.id == "#{execution_id}:999:0")
+ )
+
+ _ = Workflows.cancel_run(project_scope, execution_id)
+ assert :ok = wait_for_worker_exit(execution_id)
+ end
+
+ test "cancel_execution stops the active run", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.long_running_snapshot_attrs(5_000)
+
+ %{conn: conn, definition: definition, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ execution_id = live_socket(view).assigns.execution.id
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "cancel_execution", "payload" => %{}})
+
+ assert {:ok, %{status: :cancelled}} =
+ wait_for_run_status(project_scope, execution_id, :cancelled)
+
+ assert :ok = wait_for_worker_exit(execution_id)
+ end
+
+ test "step_cancelled event updates step execution status", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.valid_snapshot_attrs()
+ step_id = hd(Enum.map(snapshot_attrs.steps, & &1.id))
+ run_id = Ecto.UUID.generate()
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ put_execution(view, %{id: run_id, status: "running"})
+
+ send(
+ view.pid,
+ {:step_started,
+ %{
+ run_id: run_id,
+ runnable_id: 123,
+ step_id: step_id,
+ attempt: 0,
+ input: %{"x" => 1},
+ input_fact_hash: "hash",
+ started_at: DateTime.utc_now()
+ }}
+ )
+
+ render(view)
+ assert [%{status: "running"}] = live_socket(view).assigns.step_executions
+
+ send(
+ view.pid,
+ {:step_cancelled,
+ %{
+ run_id: run_id,
+ runnable_id: 123,
+ step_id: step_id,
+ cancelled_at: DateTime.utc_now()
+ }}
+ )
+
+ render(view)
+ assert [%{status: "cancelled"}] = live_socket(view).assigns.step_executions
+ end
+
+ test "terminal run status marks running steps as cancelled", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.valid_snapshot_attrs()
+ steps = snapshot_attrs.steps
+ step_a_id = Enum.at(steps, 0).id
+ step_b_id = Enum.at(steps, 1).id
+ run_id = Ecto.UUID.generate()
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ put_execution(view, %{id: run_id, status: "running"})
+
+ for {step_id, runnable_id} <- [{step_a_id, 1}, {step_b_id, 2}] do
+ send(
+ view.pid,
+ {:step_started,
+ %{
+ run_id: run_id,
+ runnable_id: runnable_id,
+ step_id: step_id,
+ attempt: 0,
+ input: %{},
+ input_fact_hash: "h",
+ started_at: DateTime.utc_now()
+ }}
+ )
+ end
+
+ render(view)
+ assert length(live_socket(view).assigns.step_executions) == 2
+ assert Enum.all?(live_socket(view).assigns.step_executions, &(&1.status == "running"))
+
+ send(
+ view.pid,
+ {:run_status_changed,
+ %{
+ run_id: run_id,
+ status: :failed,
+ timestamp: DateTime.utc_now()
+ }}
+ )
+
+ render(view)
+
+ statuses =
+ live_socket(view).assigns.step_executions
+ |> Enum.map(& &1.status)
+ |> Enum.sort()
+
+ assert statuses == ["cancelled", "cancelled"]
+ end
+
+ test "paused execution assigns correct status for stop button visibility", %{conn: conn} do
+ snapshot_attrs = WorkflowsFixtures.long_running_snapshot_attrs(5_000)
+ run_id = Ecto.UUID.generate()
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ put_execution(view, %{id: run_id, status: "paused"})
+
+ render(view)
+
+ execution = live_socket(view).assigns.execution
+ assert execution.status == "paused"
+ end
+
+ test "compilation errors are pushed to the client", %{conn: conn} do
+ source_step = WorkflowsFixtures.step(%{type_id: "debug", name: "Source"})
+ trigger_step = WorkflowsFixtures.step(%{type_id: "schedule_trigger", name: "Schedule"})
+
+ snapshot_attrs =
+ WorkflowsFixtures.snapshot_attrs(%{
+ steps: [source_step, trigger_step],
+ connections: [
+ WorkflowsFixtures.connection(%{
+ source_step_id: source_step.id,
+ target_step_id: trigger_step.id
+ })
+ ]
+ })
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{"type" => "run_test", "payload" => %{}})
+
+ errors =
+ live_socket(view).assigns.validation_errors
+ |> Map.values()
+ |> List.flatten()
+
+ assert Enum.any?(errors, fn error ->
+ error.code == :trigger_not_root and
+ error.message =~ "trigger steps must be graph roots with no incoming connections"
+ end)
+
+ assert live_socket(view).assigns.execution == nil
+ assert live_socket(view).assigns.validation_errors != %{}
+ end
+
+ test "resolve_field_options replies with credential options and pushes credential results", %{
+ conn: conn
+ } do
+ model_step = WorkflowsFixtures.step(%{type_id: "openai_model", name: "Model"})
+ model_step_id = model_step.id
+ snapshot_attrs = WorkflowsFixtures.snapshot_attrs(%{steps: [model_step]})
+
+ %{conn: conn, definition: definition, user: user, project_scope: project_scope} =
+ editor_fixture(conn, snapshot_attrs)
+
+ insert_api_credential!(
+ user.id,
+ project_scope.organization_id,
+ "openai_api_key",
+ "OpenAI Production"
+ )
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ render_hook(view, "resolve_field_options", %{
+ "node_id" => model_step_id,
+ "field_key" => "credential_ref",
+ "q" => "production"
+ })
+
+ assert_reply(view, %{options: [%{"display_name" => "OpenAI Production"}]})
+
+ assert_push_event(view, "credential_results", %{
+ node_id: ^model_step_id,
+ field_key: "credential_ref",
+ q: "production",
+ options: [%{"display_name" => "OpenAI Production"}]
+ })
+ end
+
+ test "resolve_field_options replies with resource mapper metadata", %{conn: conn} do
+ append_step =
+ WorkflowsFixtures.step(%{type_id: "google_sheets_append_row", name: "Append Row"})
+
+ snapshot_attrs = WorkflowsFixtures.snapshot_attrs(%{steps: [append_step]})
+
+ %{conn: conn, definition: definition} = editor_fixture(conn, snapshot_attrs)
+
+ {:ok, view, _html} = live_editor(conn, definition)
+
+ render_hook(view, "resolve_field_options", %{
+ "node_id" => append_step.id,
+ "field_key" => "values",
+ "params" => %{"mode" => "sheets"}
+ })
+
+ assert_reply(view, %{
+ options: [],
+ meta: %{
+ depends_on: depends_on,
+ resource_mapper: %{
+ "kind" => "google_sheets.row_values",
+ "lookups" => %{
+ "primary_resource" => %{"mode" => "sheets"},
+ "schema_resource" => %{"mode" => "tables"}
+ }
+ }
+ }
+ })
+
+ assert "credential_ref" in depends_on
+ assert "spreadsheet_id" in depends_on
+ end
+
+ defp editor_fixture(conn, snapshot_attrs \\ nil) do
+ user = Fizz.AccountsFixtures.user_fixture()
+ organization_scope = Fizz.AccountsFixtures.organization_scope_fixture(user: user)
+
+ project =
+ Fizz.AccountsFixtures.project_fixture(organization_scope, %{
+ name: "Workflow Project #{System.unique_integer([:positive])}"
+ })
+
+ project_scope =
+ organization_scope
+ |> Scope.with_project(project)
+ |> Scope.with_project_role(:admin)
+
+ {:ok, %{definition: definition, draft: initial_draft}} =
+ Workflows.create_definition(project_scope, %{
+ name: "Workflow #{System.unique_integer([:positive])}",
+ description: "Editor LiveView"
+ })
+
+ draft =
+ case snapshot_attrs do
+ nil ->
+ initial_draft
+
+ attrs ->
+ {:ok, saved_draft} = Workflows.save_draft(project_scope, initial_draft, attrs)
+ saved_draft
+ end
+
+ %{
+ conn: log_in_user(conn, user),
+ user: user,
+ project_scope: project_scope,
+ definition: definition,
+ draft: draft
+ }
+ end
+
+ defp collaborator_fixture(project_scope) do
+ user = Fizz.AccountsFixtures.user_fixture()
+
+ organization_scope =
+ Fizz.AccountsFixtures.organization_scope_fixture(
+ user: user,
+ organization_id: project_scope.organization_id,
+ organization_role: :member
+ )
+
+ {:ok, _membership} =
+ Accounts.add_project_member(project_scope, project_scope.project.id, user, %{role: :member})
+
+ collaborator_scope =
+ organization_scope
+ |> Scope.with_project(project_scope.project)
+ |> Scope.with_project_role(:member)
+
+ %{
+ conn: log_in_user(build_conn(), user),
+ user: user,
+ project_scope: collaborator_scope
+ }
+ end
+
+ defp live_editor(conn, definition) do
+ {:ok, view, html} =
+ live(conn, ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit")
+
+ register_editor_cleanup(view)
+ {:ok, view, html}
+ end
+
+ defp live_revisions(conn, definition) do
+ {:ok, view, html} =
+ live(conn, ~p"/projects/#{definition.project_id}/workflows/#{definition.id}/edit/revisions")
+
+ register_revision_cleanup(view)
+ {:ok, view, html}
+ end
+
+ defp register_editor_cleanup(view) do
+ version_id = view_version_id(view)
+
+ on_exit(fn ->
+ stop_draft_session(version_id)
+ end)
+ end
+
+ defp register_revision_cleanup(view) do
+ version_id = revision_view_version_id(view)
+
+ on_exit(fn ->
+ stop_draft_session(version_id)
+ end)
+ end
+
+ defp view_version_id(view) do
+ vue = get_vue(view, id: "workflow-editor")
+ vue.props["workflow"]["draft"]["id"]
+ end
+
+ defp revision_view_version_id(view) do
+ vue = get_vue(view, id: "workflow-revision-viewer")
+ vue.props["workflow"]["draft"]["id"]
+ end
+
+ defp stop_draft_session(version_id) do
+ case Registry.lookup(Fizz.Workflows.DraftSessionRegistry, version_id) do
+ [{pid, _value}] ->
+ try do
+ GenServer.stop(pid, :normal)
+ catch
+ :exit, _reason -> :ok
+ end
+
+ [] ->
+ :ok
+ end
+ end
+
+ defp preview_expression(view, step_id, field_key, expression) do
+ view
+ |> element("#workflow-editor")
+ |> render_hook("editor_command", %{
+ "type" => "preview_expression",
+ "payload" => %{
+ "step_id" => step_id,
+ "field_key" => field_key,
+ "expression" => expression
+ }
+ })
+
+ key = preview_key(step_id, field_key)
+ timer_ref = preview_timer_ref(view, key)
+
+ send(view.pid, {:preview_expression, key, step_id, expression, timer_ref})
+ render(view)
+ end
+
+ defp preview_key(step_id, field_key), do: "#{step_id}:#{field_key}"
+
+ defp preview_timer_ref(view, key) do
+ live_socket(view).assigns.preview_timers[key]
+ end
+
+ defp preview_value(view, key) do
+ expression_previews(view)[key]
+ end
+
+ defp expression_previews(view) do
+ live_socket(view).assigns.expression_previews
+ end
+
+ defp collaborator_presence(view, user_id) do
+ live_socket(view).assigns.presences
+ |> Enum.find(fn presence ->
+ get_in(presence, [:user, :id]) == user_id
+ end)
+ end
+
+ defp put_step_executions(view, step_executions) do
+ :sys.replace_state(view.pid, fn state ->
+ put_in(state.socket.assigns.step_executions, step_executions)
+ end)
+ end
+
+ defp put_execution(view, execution) do
+ :sys.replace_state(view.pid, fn state ->
+ put_in(state.socket.assigns.execution, execution)
+ end)
+ end
+
+ defp live_socket(view), do: :sys.get_state(view.pid).socket
+
+ defp wait_for_run_status(scope, run_id, expected_status, attempts \\ 100)
+
+ defp wait_for_run_status(scope, run_id, expected_status, attempts) when attempts > 0 do
+ case Workflows.get_run(scope, run_id) do
+ {:ok, %{status: ^expected_status} = run} ->
+ {:ok, run}
+
+ _ ->
+ receive do
+ after
+ 20 -> wait_for_run_status(scope, run_id, expected_status, attempts - 1)
+ end
+ end
+ end
+
+ defp wait_for_run_status(_scope, _run_id, _expected_status, 0) do
+ flunk("run did not reach the expected status")
+ end
+
+ defp wait_for_worker_exit(run_id, attempts \\ 100)
+
+ defp wait_for_worker_exit(run_id, attempts) when attempts > 0 do
+ case Fizz.Workflows.Runner.Worker.lookup(run_id) do
+ nil ->
+ :ok
+
+ _pid ->
+ receive do
+ after
+ 20 -> wait_for_worker_exit(run_id, attempts - 1)
+ end
+ end
+ end
+
+ defp wait_for_worker_exit(_run_id, 0) do
+ flunk("worker did not exit")
+ end
+
+ defp wait_until(fun, attempts \\ 50)
+
+ defp wait_until(fun, attempts) when attempts > 0 do
+ case fun.() do
+ true ->
+ :ok
+
+ _other ->
+ receive do
+ after
+ 20 -> wait_until(fun, attempts - 1)
+ end
+ end
+ end
+
+ defp wait_until(_fun, 0) do
+ flunk("condition was not met in time")
+ end
+
+ defp insert_api_credential!(user_id, organization_id, provider, provider_label) do
+ unique = System.unique_integer([:positive])
+
+ %ApiCredential{}
+ |> ApiCredential.changeset(%{
+ user_id: user_id,
+ workos_organization_id: organization_id,
+ provider: provider,
+ provider_label: provider_label,
+ vault_object_id: "vault_obj_#{unique}",
+ vault_object_name: "vault_name_#{unique}"
+ })
+ |> Fizz.Repo.insert!()
+ end
+
+ defp restore_env(app, key, nil), do: Application.delete_env(app, key)
+ defp restore_env(app, key, value), do: Application.put_env(app, key, value)
+end
diff --git a/test/fizz_web/live/workflow_execution_live_test.exs b/test/fizz_web/live/workflow_execution_live_test.exs
deleted file mode 100644
index 21aa1b3..0000000
--- a/test/fizz_web/live/workflow_execution_live_test.exs
+++ /dev/null
@@ -1,205 +0,0 @@
-defmodule FizzWeb.WorkflowExecutionLiveTest do
- use FizzWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership}
- alias Fizz.Executions
- alias Fizz.Repo
- alias Fizz.Workflows
-
- defmodule WorkOSHTTPStub do
- def request(opts) do
- case {opts[:method], opts[:url]} do
- {:get, "/user_management/organization_memberships"} ->
- {:ok,
- %Req.Response{
- status: 200,
- body: %{
- "data" => [
- %{
- "status" => "active",
- "role" => %{"slug" => "owner"}
- }
- ]
- }
- }}
-
- _ ->
- {:ok, %Req.Response{status: 200, body: %{}}}
- end
- end
- end
-
- setup %{conn: conn} do
- previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
- previous_workos_client = Application.get_env(:workos, WorkOS.Client)
-
- Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPStub)
-
- Application.put_env(:workos, WorkOS.Client,
- api_key: "test_api_key",
- client_id: "test_client_id",
- client: Fizz.Accounts.WorkOS.ReqClient
- )
-
- on_exit(fn ->
- Application.put_env(:fizz, :workos_http_client_module, previous_http_client)
-
- case previous_workos_client do
- nil -> Application.delete_env(:workos, WorkOS.Client)
- value -> Application.put_env(:workos, WorkOS.Client, value)
- end
- end)
-
- user = Fizz.AccountsFixtures.user_fixture()
- workspace = workspace_fixture!(user)
- scope = scoped_workspace_access(user, workspace)
- conn = log_in_user(conn, user)
-
- %{conn: conn, user: user, workspace: workspace, scope: scope}
- end
-
- test "workflow index loads for authenticated workspace user", %{
- conn: conn,
- workspace: workspace
- } do
- {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows")
-
- assert has_element?(view, "#workflows")
- assert has_element?(view, "#workflow-create-button")
- end
-
- test "workflow index creates a workflow and navigates to edit", %{
- conn: conn,
- workspace: workspace
- } do
- {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows")
-
- assert {:error, {:live_redirect, %{to: to}}} =
- view
- |> element("#workflow-create-button")
- |> render_click()
-
- assert to =~ "/workspaces/#{workspace.id}/workflows/"
- assert String.ends_with?(to, "/edit")
- end
-
- test "workflow show renders workspace-scoped navigation links", %{
- conn: conn,
- workspace: workspace,
- scope: scope
- } do
- workflow = workflow_fixture!(scope)
- execution = execution_fixture!(scope, workflow)
-
- {:ok, view, _html} = live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}")
-
- assert has_element?(view, "a[href='/workspaces/#{workspace.id}/workflows']")
-
- assert has_element?(
- view,
- "a[href='/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit']"
- )
-
- assert has_element?(
- view,
- "a[href='/workspaces/#{workspace.id}/workflows/#{workflow.id}/execution/#{execution.id}']"
- )
- end
-
- test "execution show renders key sections and workflow links", %{
- conn: conn,
- workspace: workspace,
- scope: scope
- } do
- workflow = workflow_fixture!(scope)
- execution = execution_fixture!(scope, workflow)
- _step_execution = step_execution_fixture!(scope, execution)
-
- {:ok, view, _html} =
- live(
- conn,
- ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/execution/#{execution.id}"
- )
-
- assert has_element?(view, "#execution-back-link")
- assert has_element?(view, "#execution-workflow-link")
- assert has_element?(view, "#execution-debug-link")
- assert has_element?(view, "#execution-edit-link")
- assert has_element?(view, "#execution-step-list")
- end
-
- defp workspace_fixture!(user) do
- unique = System.unique_integer([:positive])
-
- workspace =
- %Workspace{}
- |> Workspace.changeset(%{
- name: "Workflow Workspace #{unique}",
- slug: "workflow-workspace-#{unique}",
- workos_organization_id: "org_#{unique}"
- })
- |> Repo.insert!()
-
- %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id}
- |> WorkspaceMembership.changeset(%{role: :admin})
- |> Repo.insert!()
-
- workspace
- end
-
- defp scoped_workspace_access(user, workspace) do
- Scope.for_user(user)
- |> Scope.with_organization_id(workspace.workos_organization_id)
- |> Scope.with_organization_role(:owner)
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
- end
-
- defp workflow_fixture!(scope) do
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "Order Intake #{System.unique_integer([:positive])}",
- description: "Handles inbound events"
- })
-
- {:ok, _draft} =
- Workflows.update_workflow_draft(scope, workflow, %{
- steps: [],
- connections: [],
- groups: []
- })
-
- workflow
- end
-
- defp execution_fixture!(scope, workflow) do
- {:ok, execution} =
- Executions.create_execution(scope, %{
- workflow_id: workflow.id,
- status: :running,
- execution_type: :preview,
- started_at: DateTime.utc_now(),
- trigger: %{
- type: :manual,
- data: %{"source" => "live_test"}
- }
- })
-
- execution
- end
-
- defp step_execution_fixture!(scope, execution) do
- {:ok, step_execution} =
- Executions.create_step_execution(scope, %{
- execution_id: execution.id,
- step_id: "fetch_orders",
- step_type_id: "http_request",
- input_data: %{"page" => 1},
- metadata: %{"test" => true}
- })
-
- step_execution
- end
-end
diff --git a/test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs b/test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs
deleted file mode 100644
index df44f3f..0000000
--- a/test/fizz_web/live/workflow_live/edit_add_step_auto_connect_test.exs
+++ /dev/null
@@ -1,255 +0,0 @@
-defmodule FizzWeb.WorkflowLive.EditAddStepAutoConnectTest do
- use FizzWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership}
- alias Fizz.Collaboration.EditSession.Server
- alias Fizz.Repo
- alias Fizz.Workflows
-
- defmodule WorkOSHTTPStub do
- def request(opts) do
- case {opts[:method], opts[:url]} do
- {:get, "/user_management/organization_memberships"} ->
- {:ok,
- %Req.Response{
- status: 200,
- body: %{
- "data" => [
- %{
- "status" => "active",
- "role" => %{"slug" => "owner"}
- }
- ]
- }
- }}
-
- _ ->
- {:ok, %Req.Response{status: 200, body: %{}}}
- end
- end
- end
-
- setup %{conn: conn} do
- previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
- previous_workos_client = Application.get_env(:workos, WorkOS.Client)
-
- Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPStub)
-
- Application.put_env(:workos, WorkOS.Client,
- api_key: "test_api_key",
- client_id: "test_client_id",
- client: Fizz.Accounts.WorkOS.ReqClient
- )
-
- on_exit(fn ->
- Application.put_env(:fizz, :workos_http_client_module, previous_http_client)
-
- case previous_workos_client do
- nil -> Application.delete_env(:workos, WorkOS.Client)
- value -> Application.put_env(:workos, WorkOS.Client, value)
- end
- end)
-
- user = Fizz.AccountsFixtures.user_fixture()
- workspace = workspace_fixture!(user)
- scope = scoped_workspace_access(user, workspace)
- workflow = workflow_fixture!(scope)
- conn = log_in_user(conn, user)
-
- %{conn: conn, workspace: workspace, workflow: workflow}
- end
-
- test "add_step with source auto_connect links existing source to new step", %{
- conn: conn,
- workspace: workspace,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit")
-
- initial_step_ids = MapSet.new(["source", "agent"])
-
- render_hook(view, "editor_command", %{
- "type" => "add_step",
- "payload" => %{
- "type_id" => "math",
- "position" => %{"x" => 320, "y" => 240},
- "auto_connect" => %{
- "source_step_id" => "source",
- "source_output" => "main"
- }
- }
- })
-
- _ = :sys.get_state(view.pid)
-
- assert {:ok, %{type: :full_sync, draft: draft}} = Server.get_sync_state(workflow.id)
-
- new_step_id = draft |> step_ids() |> find_new_step_id(initial_step_ids)
- assert is_binary(new_step_id)
-
- assert has_connection?(draft, fn conn ->
- connection_field(conn, :source_step_id) == "source" and
- connection_field(conn, :target_step_id) == new_step_id and
- connection_field(conn, :source_output) == "main" and
- connection_field(conn, :target_input) == "main"
- end)
- end
-
- test "add_step with target slot auto_connect links new subnode into target slot", %{
- conn: conn,
- workspace: workspace,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit")
-
- initial_step_ids = MapSet.new(["source", "agent"])
-
- render_hook(view, "editor_command", %{
- "type" => "add_step",
- "payload" => %{
- "type_id" => "openai_model",
- "position" => %{"x" => 420, "y" => 180},
- "auto_connect" => %{
- "target_step_id" => "agent",
- "target_input" => "model"
- }
- }
- })
-
- _ = :sys.get_state(view.pid)
-
- assert {:ok, %{type: :full_sync, draft: draft}} = Server.get_sync_state(workflow.id)
-
- new_step_id = draft |> step_ids() |> find_new_step_id(initial_step_ids)
- assert is_binary(new_step_id)
-
- assert has_connection?(draft, fn conn ->
- connection_field(conn, :source_step_id) == new_step_id and
- connection_field(conn, :target_step_id) == "agent" and
- connection_field(conn, :source_output) == "main" and
- connection_field(conn, :target_input) == "model"
- end)
- end
-
- test "invalid slot auto_connect keeps new step and shows warning flash", %{
- conn: conn,
- workspace: workspace,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit")
-
- initial_step_ids = MapSet.new(["source", "agent"])
-
- render_hook(view, "editor_command", %{
- "type" => "add_step",
- "payload" => %{
- "type_id" => "math",
- "position" => %{"x" => 480, "y" => 260},
- "auto_connect" => %{
- "target_step_id" => "agent",
- "target_input" => "model"
- }
- }
- })
-
- _ = :sys.get_state(view.pid)
-
- assert {:ok, %{type: :full_sync, draft: draft}} = Server.get_sync_state(workflow.id)
-
- new_step_id = draft |> step_ids() |> find_new_step_id(initial_step_ids)
- assert is_binary(new_step_id)
-
- refute has_connection?(draft, fn conn ->
- connection_field(conn, :source_step_id) == new_step_id and
- connection_field(conn, :target_step_id) == "agent" and
- connection_field(conn, :target_input) == "model"
- end)
-
- assert render(view) =~ "Step added but auto-connect failed"
- end
-
- defp step_ids(draft) do
- draft.steps
- |> List.wrap()
- |> Enum.map(fn step -> connection_field(step, :id) end)
- |> Enum.reject(&is_nil/1)
- end
-
- defp find_new_step_id(step_ids, initial_step_ids) do
- Enum.find(step_ids, fn step_id -> not MapSet.member?(initial_step_ids, step_id) end)
- end
-
- defp has_connection?(draft, predicate) do
- draft.connections
- |> List.wrap()
- |> Enum.any?(predicate)
- end
-
- defp connection_field(map, key) when is_map(map) do
- Map.get(map, key) || Map.get(map, Atom.to_string(key))
- end
-
- defp workspace_fixture!(user) do
- unique = System.unique_integer([:positive])
-
- workspace =
- %Workspace{}
- |> Workspace.changeset(%{
- name: "LiveView Auto Connect Workspace #{unique}",
- slug: "liveview-auto-connect-workspace-#{unique}",
- workos_organization_id: "org_#{unique}"
- })
- |> Repo.insert!()
-
- %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id}
- |> WorkspaceMembership.changeset(%{role: :admin})
- |> Repo.insert!()
-
- workspace
- end
-
- defp scoped_workspace_access(user, workspace) do
- Scope.for_user(user)
- |> Scope.with_organization_id(workspace.workos_organization_id)
- |> Scope.with_organization_role(:owner)
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
- end
-
- defp workflow_fixture!(scope) do
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "LiveView Auto Connect #{System.unique_integer([:positive])}",
- description: "auto connect test"
- })
-
- {:ok, _draft} =
- Workflows.update_workflow_draft(scope, workflow, %{
- steps: [
- %{
- id: "source",
- type_id: "math",
- name: "Source",
- config: %{},
- position: %{x: 80, y: 120}
- },
- %{
- id: "agent",
- type_id: "ai_agent",
- name: "Agent",
- config: %{},
- position: %{x: 280, y: 120}
- }
- ],
- connections: [],
- groups: []
- })
-
- workflow
- end
-end
diff --git a/test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs b/test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs
deleted file mode 100644
index 3201dcb..0000000
--- a/test/fizz_web/live/workflow_live/edit_commit_drag_layout_test.exs
+++ /dev/null
@@ -1,194 +0,0 @@
-defmodule FizzWeb.WorkflowLive.EditCommitDragLayoutTest do
- use FizzWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- alias Fizz.Accounts.{Scope, Workspace, WorkspaceMembership}
- alias Fizz.Collaboration.EditSession.Server
- alias Fizz.Repo
- alias Fizz.Workflows
-
- defmodule WorkOSHTTPStub do
- def request(opts) do
- case {opts[:method], opts[:url]} do
- {:get, "/user_management/organization_memberships"} ->
- {:ok,
- %Req.Response{
- status: 200,
- body: %{
- "data" => [
- %{
- "status" => "active",
- "role" => %{"slug" => "owner"}
- }
- ]
- }
- }}
-
- _ ->
- {:ok, %Req.Response{status: 200, body: %{}}}
- end
- end
- end
-
- setup %{conn: conn} do
- previous_http_client = Application.get_env(:fizz, :workos_http_client_module)
- previous_workos_client = Application.get_env(:workos, WorkOS.Client)
-
- Application.put_env(:fizz, :workos_http_client_module, WorkOSHTTPStub)
-
- Application.put_env(:workos, WorkOS.Client,
- api_key: "test_api_key",
- client_id: "test_client_id",
- client: Fizz.Accounts.WorkOS.ReqClient
- )
-
- on_exit(fn ->
- Application.put_env(:fizz, :workos_http_client_module, previous_http_client)
-
- case previous_workos_client do
- nil -> Application.delete_env(:workos, WorkOS.Client)
- value -> Application.put_env(:workos, WorkOS.Client, value)
- end
- end)
-
- user = Fizz.AccountsFixtures.user_fixture()
- workspace = workspace_fixture!(user)
- scope = scoped_workspace_access(user, workspace)
- workflow = workflow_fixture!(scope)
- conn = log_in_user(conn, user)
-
- %{conn: conn, user: user, workspace: workspace, scope: scope, workflow: workflow}
- end
-
- test "editor_command commit_drag_layout applies one coherent draft commit", %{
- conn: conn,
- workspace: workspace,
- workflow: workflow
- } do
- {:ok, view, _html} =
- live(conn, ~p"/workspaces/#{workspace.id}/workflows/#{workflow.id}/edit")
-
- payload = %{
- "txn_id" => "txn-liveview-1",
- "base_seq" => 0,
- "groups" => [
- %{
- "group_id" => "group_a",
- "position" => %{"x" => 130, "y" => 140, "width" => 430, "height" => 310}
- }
- ],
- "step_positions" => %{
- "step_a" => %{"x" => 45, "y" => 55},
- "step_b" => %{"x" => 200, "y" => 210}
- },
- "group_id_by_step_id" => %{
- "step_b" => nil
- }
- }
-
- render_hook(view, "editor_command", %{
- "type" => "commit_drag_layout",
- "payload" => payload
- })
-
- _ = :sys.get_state(view.pid)
-
- assert {:ok, %{type: :full_sync, draft: draft, seq: seq}} = Server.get_sync_state(workflow.id)
- assert seq > 0
- assert group_position_for(draft, "group_a") == %{x: 130, y: 140, width: 430, height: 310}
- assert step_position_for(draft, "step_a") == %{x: 45, y: 55}
- assert step_position_for(draft, "step_b") == %{x: 200, y: 210}
- assert group_step_ids_for(draft, "group_a") == ["step_a"]
- end
-
- defp group_position_for(draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> Map.get(group, :id) == group_id end)
- |> Map.get(:position)
- end
-
- defp group_step_ids_for(draft, group_id) do
- draft.groups
- |> List.wrap()
- |> Enum.find(fn group -> Map.get(group, :id) == group_id end)
- |> Map.get(:step_ids)
- end
-
- defp step_position_for(draft, step_id) do
- draft.steps
- |> List.wrap()
- |> Enum.find(fn step -> Map.get(step, :id) == step_id end)
- |> Map.get(:position)
- end
-
- defp workspace_fixture!(user) do
- unique = System.unique_integer([:positive])
-
- workspace =
- %Workspace{}
- |> Workspace.changeset(%{
- name: "LiveView Edit Workspace #{unique}",
- slug: "liveview-edit-workspace-#{unique}",
- workos_organization_id: "org_#{unique}"
- })
- |> Repo.insert!()
-
- %WorkspaceMembership{workspace_id: workspace.id, user_id: user.id}
- |> WorkspaceMembership.changeset(%{role: :admin})
- |> Repo.insert!()
-
- workspace
- end
-
- defp scoped_workspace_access(user, workspace) do
- Scope.for_user(user)
- |> Scope.with_organization_id(workspace.workos_organization_id)
- |> Scope.with_organization_role(:owner)
- |> Scope.with_workspace(workspace)
- |> Scope.with_workspace_role(:admin)
- end
-
- defp workflow_fixture!(scope) do
- {:ok, workflow} =
- Workflows.create_workflow(scope, %{
- name: "LiveView Commit Layout #{System.unique_integer([:positive])}",
- description: "liveview test"
- })
-
- {:ok, _draft} =
- Workflows.update_workflow_draft(scope, workflow, %{
- steps: [
- %{
- id: "step_a",
- type_id: "math",
- name: "Step A",
- config: %{},
- position: %{x: 60, y: 60}
- },
- %{
- id: "step_b",
- type_id: "math",
- name: "Step B",
- config: %{},
- position: %{x: 140, y: 110}
- }
- ],
- connections: [],
- groups: [
- %{
- id: "group_a",
- name: "Group A",
- step_ids: ["step_a", "step_b"],
- output_step_id: "step_a",
- position: %{x: 100, y: 100, width: 320, height: 240},
- color: nil,
- collapsed: false
- }
- ]
- })
-
- workflow
- end
-end
diff --git a/test/fizz_web/live/workflow_live/edit_step_projection_test.exs b/test/fizz_web/live/workflow_live/edit_step_projection_test.exs
deleted file mode 100644
index 0acb58a..0000000
--- a/test/fizz_web/live/workflow_live/edit_step_projection_test.exs
+++ /dev/null
@@ -1,111 +0,0 @@
-defmodule FizzWeb.WorkflowLive.EditStepProjectionTest do
- use ExUnit.Case, async: true
-
- alias Fizz.Runtime.Serializer
- alias FizzWeb.WorkflowLive.Edit.EditStepProjection
-
- test "preserves a failed summary row when itemized cancellations arrive" do
- execution_id = Ecto.UUID.generate()
-
- initial = [
- running_step_execution(execution_id, 0),
- running_step_execution(execution_id, 1)
- ]
-
- updated =
- initial
- |> EditStepProjection.apply_event(execution_id, :step_failed, %{
- execution_id: execution_id,
- step_id: "fan_out_step",
- status: :failed,
- completed_at: ~U[2026-03-01 00:00:02Z],
- error: %{"type" => "step_failure"}
- })
- |> EditStepProjection.apply_event(execution_id, :step_cancelled, %{
- execution_id: execution_id,
- step_id: "fan_out_step",
- status: :cancelled,
- item_index: 0,
- items_total: 2,
- completed_at: ~U[2026-03-01 00:00:03Z]
- })
- |> EditStepProjection.apply_event(execution_id, :step_cancelled, %{
- execution_id: execution_id,
- step_id: "fan_out_step",
- status: :cancelled,
- item_index: 1,
- items_total: 2,
- completed_at: ~U[2026-03-01 00:00:03Z]
- })
-
- assert %{status: :failed, item_index: nil} =
- Enum.find(updated, &is_nil(Map.get(&1, :item_index)))
-
- assert 2 == Enum.count(updated, &(Map.get(&1, :status) == :cancelled))
- refute Enum.any?(updated, &(Map.get(&1, :status) == :running))
- end
-
- test "keeps failed status when a later cancelled event targets the same summary row" do
- execution_id = Ecto.UUID.generate()
-
- updated =
- [
- %{
- id: "#{execution_id}:fan_out_step:1",
- execution_id: execution_id,
- step_id: "fan_out_step",
- status: :failed,
- attempt: 1,
- item_index: nil,
- error: %{"type" => "step_failure"},
- completed_at: ~U[2026-03-01 00:00:02Z],
- metadata: %{}
- }
- ]
- |> EditStepProjection.apply_event(execution_id, :step_cancelled, %{
- execution_id: execution_id,
- step_id: "fan_out_step",
- status: :cancelled,
- completed_at: ~U[2026-03-01 00:00:03Z]
- })
-
- assert [%{status: :failed}] = updated
- end
-
- test "normalizes sanitized runtime timestamps for fan-out item events" do
- execution_id = Ecto.UUID.generate()
- started_at = ~U[2026-03-01 00:00:00.123456Z]
- completed_at = ~U[2026-03-01 00:00:02.654321Z]
-
- [step_execution] =
- EditStepProjection.apply_event([], execution_id, :step_completed, %{
- "execution_id" => execution_id,
- "step_id" => "fan_out_step",
- "status" => "completed",
- "item_index" => 0,
- "items_total" => 2,
- "started_at" => Serializer.sanitize(started_at),
- "completed_at" => Serializer.sanitize(completed_at),
- "duration_us" => 2_530_865
- })
-
- assert step_execution.started_at == DateTime.to_iso8601(started_at)
- assert step_execution.completed_at == DateTime.to_iso8601(completed_at)
- assert step_execution.duration_us == 2_530_865
- end
-
- defp running_step_execution(execution_id, item_index) do
- %{
- id: "#{execution_id}:fan_out_step:#{item_index}:1",
- execution_id: execution_id,
- step_id: "fan_out_step",
- step_type_id: "fan_out",
- status: :running,
- attempt: 1,
- item_index: item_index,
- items_total: 2,
- started_at: ~U[2026-03-01 00:00:00Z],
- metadata: %{}
- }
- end
-end
diff --git a/test/fizz_web/live/workflow_live_test.exs b/test/fizz_web/live/workflow_live_test.exs
deleted file mode 100644
index 822e936..0000000
--- a/test/fizz_web/live/workflow_live_test.exs
+++ /dev/null
@@ -1,48 +0,0 @@
-defmodule FizzWeb.WorkflowLiveTest do
- use FizzWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- test "workflow index requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
-
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces/#{workspace_id}/workflows")
- end
-
- test "workflow show requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
- workflow_id = Ecto.UUID.generate()
-
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}")
- end
-
- test "workflow edit requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
- workflow_id = Ecto.UUID.generate()
-
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/edit")
- end
-
- test "workflow revisions requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
- workflow_id = Ecto.UUID.generate()
-
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/revisions")
- end
-
- test "execution show requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
- workflow_id = Ecto.UUID.generate()
- execution_id = Ecto.UUID.generate()
-
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(
- conn,
- ~p"/workspaces/#{workspace_id}/workflows/#{workflow_id}/execution/#{execution_id}"
- )
- end
-end
diff --git a/test/fizz_web/live/workspaces_live_test.exs b/test/fizz_web/live/workspaces_live_test.exs
index 862bb28..4fbc591 100644
--- a/test/fizz_web/live/workspaces_live_test.exs
+++ b/test/fizz_web/live/workspaces_live_test.exs
@@ -4,14 +4,9 @@ defmodule FizzWeb.WorkspacesLiveTest do
import Phoenix.LiveViewTest
test "workspaces index requires authentication", %{conn: conn} do
- assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces")
- end
-
- test "workspace show requires authentication", %{conn: conn} do
- workspace_id = Ecto.UUID.generate()
+ project_id = Ecto.UUID.generate()
assert {:error, {:redirect, %{to: "/auth/workos"}}} =
- live(conn, ~p"/workspaces/#{workspace_id}")
+ live(conn, ~p"/projects/#{project_id}/workspaces")
end
end
diff --git a/test/runic/workflow_log_rehydration_test.exs b/test/runic/workflow_log_rehydration_test.exs
new file mode 100644
index 0000000..a98105e
--- /dev/null
+++ b/test/runic/workflow_log_rehydration_test.exs
@@ -0,0 +1,215 @@
+defmodule Runic.WorkflowLogRehydrationTest do
+ use ExUnit.Case, async: false
+
+ alias Runic.Runner
+ alias Runic.Runner.Store.ETS, as: ETSStore
+ alias Runic.Workflow
+ alias Runic.Workflow.Events.{FanOutFactEmitted, FactProduced}
+ alias Runic.Workflow.{Fact, FactRef, FactResolver, Rehydration}
+
+ require Runic
+
+ test "lean replay keeps FactProduced metadata on FactRef and resolve_hot restores it" do
+ runner_name = unique_runner_name()
+ start_supervised!({ETSStore, runner_name: runner_name})
+ {:ok, store_state} = ETSStore.init_store(runner_name: runner_name)
+
+ workflow =
+ Runic.workflow(
+ name: :echo_workflow,
+ steps: [Runic.step(fn item -> item end, name: :echo)]
+ )
+
+ %{echo: echo_step} = Workflow.components(workflow)
+ output_hash = System.unique_integer([:positive])
+
+ events =
+ Workflow.build_log(workflow) ++
+ [
+ %FactProduced{
+ hash: output_hash,
+ value: nil,
+ ancestry: {echo_step.hash, System.unique_integer([:positive])},
+ producer_label: :produced,
+ weight: 1,
+ meta: %{item_index: 0, items_total: 3}
+ }
+ ]
+
+ lean_workflow = Workflow.from_events(events, nil, fact_mode: :ref)
+
+ assert %FactRef{meta: %{item_index: 0, items_total: 3}} =
+ Map.fetch!(lean_workflow.graph.vertices, output_hash)
+
+ assert :ok = ETSStore.save_fact(output_hash, "value", store_state)
+
+ resolver = FactResolver.new({ETSStore, store_state})
+
+ {rehydrated_workflow, _resolver} =
+ Rehydration.resolve_hot(lean_workflow, MapSet.new([output_hash]), resolver)
+
+ assert %Fact{value: "value", meta: %{item_index: 0, items_total: 3}} =
+ Map.fetch!(rehydrated_workflow.graph.vertices, output_hash)
+ end
+
+ test "runner workflow logs emit per-item fan-out entries and snapshot replay preserves them" do
+ runner = start_runner!()
+ workflow_id = unique_workflow_id()
+
+ run_to_completion(runner, workflow_id, split_echo_workflow(), [1, 2, 3])
+
+ fan_out_events = fan_out_events(runner, workflow_id)
+
+ assert Enum.map(fan_out_events, & &1.emitted_value) == [1, 2, 3]
+ assert Enum.map(fan_out_events, & &1.item_index) == [0, 1, 2]
+ assert Enum.map(fan_out_events, & &1.items_total) == [3, 3, 3]
+ assert fan_out_events |> Enum.map(& &1.emitted_fact_hash) |> Enum.uniq() |> length() == 3
+
+ assert {:ok, live_workflow} = Runner.get_workflow(runner, workflow_id)
+
+ Enum.each(fan_out_events, fn event ->
+ assert_fact_vertex(
+ Map.fetch!(live_workflow.graph.vertices, event.emitted_fact_hash),
+ event.emitted_value,
+ event.item_index,
+ event.items_total
+ )
+ end)
+
+ restored_workflow =
+ live_workflow
+ |> Workflow.event_log()
+ |> Workflow.from_events()
+
+ Enum.each(fan_out_events, fn event ->
+ assert_fact_vertex(
+ Map.fetch!(restored_workflow.graph.vertices, event.emitted_fact_hash),
+ event.emitted_value,
+ event.item_index,
+ event.items_total
+ )
+ end)
+ end
+
+ test "runner resume rehydrates duplicate fan-out items with distinct hashes and metadata" do
+ runner = start_runner!()
+ workflow_id = unique_workflow_id()
+
+ run_to_completion(runner, workflow_id, split_echo_workflow(), [1, 1, 1])
+
+ fan_out_events = fan_out_events(runner, workflow_id)
+
+ assert Enum.map(fan_out_events, & &1.emitted_value) == [1, 1, 1]
+ assert Enum.map(fan_out_events, & &1.item_index) == [0, 1, 2]
+ assert fan_out_events |> Enum.map(& &1.emitted_fact_hash) |> Enum.uniq() |> length() == 3
+
+ assert :ok = Runner.stop(runner, workflow_id)
+ assert {:ok, _pid} = Runner.resume(runner, workflow_id, rehydration: :full)
+ assert {:ok, workflow} = Runner.get_workflow(runner, workflow_id)
+
+ Enum.each(fan_out_events, fn event ->
+ assert_fact_vertex(
+ Map.fetch!(workflow.graph.vertices, event.emitted_fact_hash),
+ event.emitted_value,
+ event.item_index,
+ event.items_total
+ )
+ end)
+ end
+
+ test "workflow log replay preserves split reduce aggregate outputs" do
+ runner = start_runner!()
+ workflow_id = unique_workflow_id()
+ base_workflow = split_collect_workflow()
+
+ run_to_completion(runner, workflow_id, base_workflow, [1, 2, 3])
+
+ assert {:ok, live_workflow} = Runner.get_workflow(runner, workflow_id)
+ assert Workflow.raw_productions(live_workflow, :collect) == [[11, 12, 13]]
+
+ restored_workflow =
+ live_workflow
+ |> Workflow.event_log()
+ |> Workflow.from_events()
+
+ assert Workflow.raw_productions(restored_workflow, :collect) == [[11, 12, 13]]
+
+ restored_from_base =
+ live_workflow
+ |> Workflow.event_log()
+ |> Workflow.from_events(base_workflow)
+
+ assert Workflow.raw_productions(restored_from_base, :collect) == [[11, 12, 13]]
+ end
+
+ defp split_echo_workflow do
+ Runic.workflow(
+ name: :split_echo,
+ steps: [
+ {Runic.map(fn item -> item end, name: :split),
+ [Runic.step(fn item -> item end, name: :echo)]}
+ ]
+ )
+ end
+
+ defp split_collect_workflow do
+ map_op = Runic.map(fn item -> item + 10 end, name: :split)
+ reduce_op = Runic.reduce([], fn item, acc -> acc ++ [item] end, name: :collect, map: :split)
+
+ Workflow.new(name: :split_collect)
+ |> Workflow.add(map_op)
+ |> Workflow.add(reduce_op, to: :split)
+ end
+
+ defp start_runner! do
+ runner = unique_runner_name()
+ start_supervised!({Runner, name: runner, store: ETSStore})
+ runner
+ end
+
+ defp run_to_completion(runner, workflow_id, workflow, input) do
+ parent = self()
+
+ assert {:ok, _pid} =
+ Runner.start_workflow(runner, workflow_id, workflow,
+ on_complete: fn completed_workflow_id, _workflow ->
+ send(parent, {:workflow_complete, completed_workflow_id})
+ end
+ )
+
+ assert :ok = Runner.run(runner, workflow_id, input)
+ assert_receive {:workflow_complete, ^workflow_id}, 2_000
+ end
+
+ defp fan_out_events(runner, workflow_id) do
+ runner
+ |> stream_events(workflow_id)
+ |> Enum.filter(&match?(%FanOutFactEmitted{}, &1))
+ |> Enum.sort_by(& &1.item_index)
+ end
+
+ defp stream_events(runner, workflow_id) do
+ {store_mod, store_state} = Runner.get_store(runner)
+ assert {:ok, stream} = store_mod.stream(workflow_id, store_state)
+ Enum.to_list(stream)
+ end
+
+ defp assert_fact_vertex(
+ %Fact{value: value, meta: meta},
+ expected_value,
+ expected_index,
+ expected_total
+ ) do
+ assert value == expected_value
+ assert Map.get(meta, :item_index) == expected_index
+ assert Map.get(meta, :items_total) == expected_total
+ end
+
+ defp unique_runner_name do
+ Module.concat([__MODULE__, "Runner#{System.unique_integer([:positive])}"])
+ end
+
+ defp unique_workflow_id do
+ "workflow-#{System.unique_integer([:positive])}"
+ end
+end
diff --git a/test/support/catalog_validation/acme_docs.ex b/test/support/catalog_validation/acme_docs.ex
new file mode 100644
index 0000000..e7a4e67
--- /dev/null
+++ b/test/support/catalog_validation/acme_docs.ex
@@ -0,0 +1,24 @@
+defmodule Fizz.TestSupport.CatalogValidation.AcmeDocs do
+ @behaviour Fizz.Integrations.Contracts.Integration
+
+ @impl true
+ def id, do: "acme_docs"
+
+ @impl true
+ def display_name, do: "Acme Docs"
+
+ @impl true
+ def provider_id, do: "google_oauth"
+
+ @impl true
+ def actions, do: ["acme_docs_action"]
+
+ @impl true
+ def triggers, do: [Fizz.TestSupport.CatalogValidation.AcmeDocsTrigger]
+
+ @impl true
+ def step_modules, do: []
+
+ @impl true
+ def required_scopes(_operation), do: []
+end
diff --git a/test/support/catalog_validation/acme_docs_trigger.ex b/test/support/catalog_validation/acme_docs_trigger.ex
new file mode 100644
index 0000000..e4023d4
--- /dev/null
+++ b/test/support/catalog_validation/acme_docs_trigger.ex
@@ -0,0 +1,18 @@
+defmodule Fizz.TestSupport.CatalogValidation.AcmeDocsTrigger do
+ @behaviour Fizz.Triggers.Source
+
+ @impl true
+ def source_key(_params, _context), do: "acme_docs"
+
+ @impl true
+ def init_cursor(_params, _context), do: {:ok, %{}}
+
+ @impl true
+ def poll(_params, cursor, _context), do: {:ok, %{events: [], cursor: cursor || %{}}}
+
+ @impl true
+ def commit(_params, _checkpoint, _context), do: :ok
+
+ @impl true
+ def event_id(event), do: Map.fetch!(event, "id")
+end
diff --git a/test/support/catalog_validation/invalid_resource_mapper_reference_step.ex b/test/support/catalog_validation/invalid_resource_mapper_reference_step.ex
new file mode 100644
index 0000000..850829f
--- /dev/null
+++ b/test/support/catalog_validation/invalid_resource_mapper_reference_step.ex
@@ -0,0 +1,25 @@
+defmodule Fizz.TestSupport.CatalogValidation.InvalidResourceMapperReferenceStep do
+ use Fizz.Integrations.Steps.Definition,
+ id: "invalid_resource_mapper_reference_step",
+ name: "Invalid Resource Mapper Reference Step",
+ category: "Test",
+ description: "Invalid step using resource mapper references to missing fields.",
+ icon: "hero-table-cells",
+ kind: :action
+
+ @behaviour Fizz.Workflows.StepExecutor
+
+ @fields [
+ %Fizz.Fields.Definition{
+ key: "values",
+ type: :resource_mapper,
+ resource_mapper: %{
+ "kind" => "test.row_values",
+ "fields" => %{"primary_resource" => "missing_resource"}
+ }
+ }
+ ]
+
+ @impl true
+ def execute(_config, _input, _context), do: {:ok, %{}}
+end
diff --git a/test/support/catalog_validation/invalid_resource_metadata_step.ex b/test/support/catalog_validation/invalid_resource_metadata_step.ex
new file mode 100644
index 0000000..9a4bab9
--- /dev/null
+++ b/test/support/catalog_validation/invalid_resource_metadata_step.ex
@@ -0,0 +1,22 @@
+defmodule Fizz.TestSupport.CatalogValidation.InvalidResourceMetadataStep do
+ use Fizz.Integrations.Steps.Definition,
+ id: "invalid_resource_metadata_step",
+ name: "Invalid Resource Metadata Step",
+ category: "Test",
+ description: "Invalid step using malformed resource metadata.",
+ icon: "hero-table-cells",
+ kind: :action
+
+ @behaviour Fizz.Workflows.StepExecutor
+
+ @fields [
+ %Fizz.Fields.Definition{
+ key: "resource",
+ type: :resource_locator,
+ resource_locator: "not a map"
+ }
+ ]
+
+ @impl true
+ def execute(_config, _input, _context), do: {:ok, %{}}
+end
diff --git a/test/support/catalog_validation/missing_icon_step.ex b/test/support/catalog_validation/missing_icon_step.ex
new file mode 100644
index 0000000..0934393
--- /dev/null
+++ b/test/support/catalog_validation/missing_icon_step.ex
@@ -0,0 +1,14 @@
+defmodule Fizz.TestSupport.CatalogValidation.MissingIconStep do
+ use Fizz.Integrations.Steps.Definition,
+ id: "missing_icon_step",
+ name: "Missing Icon Step",
+ category: "Test",
+ description: "Invalid step missing an icon.",
+ icon: "",
+ kind: :action
+
+ @behaviour Fizz.Workflows.StepExecutor
+
+ @impl true
+ def execute(_config, _input, _context), do: {:ok, %{}}
+end
diff --git a/test/support/catalog_validation/unknown_provider_integration.ex b/test/support/catalog_validation/unknown_provider_integration.ex
new file mode 100644
index 0000000..ed8a4ff
--- /dev/null
+++ b/test/support/catalog_validation/unknown_provider_integration.ex
@@ -0,0 +1,24 @@
+defmodule Fizz.TestSupport.CatalogValidation.UnknownProviderIntegration do
+ @behaviour Fizz.Integrations.Contracts.Integration
+
+ @impl true
+ def id, do: "unknown_provider_docs"
+
+ @impl true
+ def display_name, do: "Unknown Provider Docs"
+
+ @impl true
+ def provider_id, do: "missing_oauth"
+
+ @impl true
+ def actions, do: []
+
+ @impl true
+ def triggers, do: []
+
+ @impl true
+ def step_modules, do: []
+
+ @impl true
+ def required_scopes(_operation), do: []
+end
diff --git a/test/support/catalog_validation/unsupported_component_step.ex b/test/support/catalog_validation/unsupported_component_step.ex
new file mode 100644
index 0000000..ecac85f
--- /dev/null
+++ b/test/support/catalog_validation/unsupported_component_step.ex
@@ -0,0 +1,18 @@
+defmodule Fizz.TestSupport.CatalogValidation.UnsupportedComponentStep do
+ use Fizz.Integrations.Steps.Definition,
+ id: "unsupported_component_step",
+ name: "Unsupported Component Step",
+ category: "Test",
+ description: "Invalid step using an unsupported field component.",
+ icon: "hero-bolt",
+ kind: :action
+
+ @behaviour Fizz.Workflows.StepExecutor
+
+ @fields [
+ %Fizz.Fields.Definition{key: "value", type: :string, component: "bespoke"}
+ ]
+
+ @impl true
+ def execute(_config, _input, _context), do: {:ok, %{}}
+end
diff --git a/test/support/executors/failing_webhook_trigger.ex b/test/support/executors/failing_webhook_trigger.ex
new file mode 100644
index 0000000..a93b31d
--- /dev/null
+++ b/test/support/executors/failing_webhook_trigger.ex
@@ -0,0 +1,34 @@
+defmodule Fizz.TestSupport.Executors.FailingWebhookTrigger do
+ use Fizz.Integrations.Steps.Definition,
+ id: "failing_webhook_trigger",
+ name: "Failing Webhook Trigger",
+ category: "Test",
+ description: "Webhook trigger used by tests to force normalize errors",
+ icon: "hero-bolt",
+ kind: :trigger
+
+ @behaviour Fizz.Workflows.StepExecutor
+
+ alias Fizz.Triggers.RegistrationSpec
+
+ @impl true
+ def registration_spec(_config, _context) do
+ {:ok,
+ %RegistrationSpec{
+ kind: :webhook,
+ params: %{
+ "signature_header" => "x-hub-signature-256",
+ "signature_algorithm" => "hmac-sha256"
+ }
+ }}
+ end
+
+ @impl true
+ def match?(_config, _incoming_event), do: true
+
+ @impl true
+ def normalize_event(_config, _raw_event), do: {:error, :invalid_payload}
+
+ @impl true
+ def execute(_config, input, _ctx), do: {:ok, input}
+end
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
index 24b9a00..5996cda 100644
--- a/test/support/fixtures/accounts_fixtures.ex
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -7,9 +7,9 @@ defmodule Fizz.AccountsFixtures do
alias Fizz.Accounts
alias Fizz.Accounts.{Scope, User}
- def unique_user_email, do: "user#{System.unique_integer([:positive])}@example.com"
+ def unique_user_email, do: "user-#{unique_suffix()}@example.com"
- def unique_workos_user_id, do: "user_workos_#{System.unique_integer([:positive])}"
+ def unique_workos_user_id, do: "user_workos_#{unique_suffix()}"
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
@@ -64,13 +64,17 @@ defmodule Fizz.AccountsFixtures do
|> Scope.with_organization_role(organization_role)
end
- def workspace_fixture(scope, attrs \\ %{}) do
- {:ok, workspace} =
- Accounts.create_workspace(
+ def project_fixture(scope, attrs \\ %{}) do
+ {:ok, project} =
+ Accounts.create_project(
scope,
- Map.merge(%{name: "Workspace #{System.unique_integer()}"}, attrs)
+ Map.merge(%{name: "Project #{unique_suffix()}"}, attrs)
)
- workspace
+ project
+ end
+
+ defp unique_suffix do
+ Ecto.UUID.generate()
end
end
diff --git a/test/support/integration_step_case.ex b/test/support/integration_step_case.ex
new file mode 100644
index 0000000..1feb42f
--- /dev/null
+++ b/test/support/integration_step_case.ex
@@ -0,0 +1,50 @@
+defmodule Fizz.IntegrationStepCase do
+ @moduledoc """
+ Test helpers for provider-owned step definitions and skeleton executors.
+ """
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ import Fizz.IntegrationStepCase
+
+ alias Fizz.Integrations.Steps.Type, as: StepType
+ end
+ end
+
+ alias Fizz.Integrations.Steps.Registry, as: Registry
+ alias Fizz.Integrations.Steps.Type, as: StepType
+
+ @spec step_type!(String.t()) :: StepType.t()
+ def step_type!(step_type_id) when is_binary(step_type_id) do
+ case Registry.get(step_type_id) do
+ {:ok, %StepType{} = step_type} ->
+ step_type
+
+ {:error, :not_found} ->
+ raise ArgumentError, "unknown step type #{inspect(step_type_id)}"
+ end
+ end
+
+ @spec execute_step(String.t(), map(), map(), map()) :: {:ok, map()} | {:error, term()}
+ def execute_step(step_type_id, config \\ %{}, input \\ %{}, context \\ %{}) do
+ step_type = step_type!(step_type_id)
+ {:ok, module} = StepType.executor_module(step_type)
+
+ module.execute(config, input, context)
+ end
+
+ @spec field!(StepType.t(), String.t()) :: Fizz.Fields.Definition.t()
+ def field!(%StepType{} = step_type, key) when is_binary(key) do
+ Enum.find(step_type.fields, fn field -> field.key == key end) ||
+ raise ArgumentError, "step #{step_type.id} does not define field #{inspect(key)}"
+ end
+
+ @spec schema_property!(StepType.t(), String.t()) :: map()
+ def schema_property!(%StepType{} = step_type, key) when is_binary(key) do
+ step_type.config_schema
+ |> Map.fetch!("properties")
+ |> Map.fetch!(key)
+ end
+end
diff --git a/test/support/triggers/raising_source.ex b/test/support/triggers/raising_source.ex
new file mode 100644
index 0000000..4246670
--- /dev/null
+++ b/test/support/triggers/raising_source.ex
@@ -0,0 +1,26 @@
+defmodule Fizz.Triggers.RaisingSource do
+ @moduledoc false
+
+ @behaviour Fizz.Triggers.Source
+
+ @impl true
+ def source_key(_params, _context), do: "test:raising-source"
+
+ @impl true
+ def init_cursor(_params, _context), do: {:ok, %{"initialized" => false}}
+
+ @impl true
+ def poll(_params, _cursor, _context) do
+ if pid = Process.whereis(Fizz.Triggers.RaisingSourceTest) do
+ send(pid, {:raising_source_polled, self()})
+ end
+
+ raise "raising source poll failed"
+ end
+
+ @impl true
+ def commit(_params, _checkpoint, _context), do: :ok
+
+ @impl true
+ def event_id(event), do: event["id"]
+end
diff --git a/test/support/workflow_compiler_scenario_helper.ex b/test/support/workflow_compiler_scenario_helper.ex
new file mode 100644
index 0000000..3a08c11
--- /dev/null
+++ b/test/support/workflow_compiler_scenario_helper.ex
@@ -0,0 +1,57 @@
+defmodule Fizz.Workflows.CompilerScenarioHelper do
+ @moduledoc false
+
+ alias Fizz.Workflows.Compiler
+ alias Fizz.Workflows.Embeds.{Connection, Step}
+ alias Fizz.Workflows.WorkflowDefinitionVersion
+ alias Runic.Workflow
+
+ def step(attrs) do
+ %Step{
+ id: Map.get(attrs, :id, Ecto.UUID.generate()),
+ type_id: Map.fetch!(attrs, :type_id),
+ name: Map.get(attrs, :name, attrs.type_id),
+ config: Map.get(attrs, :config, %{}),
+ position: Map.get(attrs, :position, %{}),
+ notes: Map.get(attrs, :notes)
+ }
+ end
+
+ def connection(attrs) do
+ %Connection{
+ id: Map.get(attrs, :id, Ecto.UUID.generate()),
+ source_step_id: Map.fetch!(attrs, :source_step_id),
+ source_output: Map.get(attrs, :source_output, "main"),
+ target_step_id: Map.fetch!(attrs, :target_step_id),
+ target_input: Map.get(attrs, :target_input, "main")
+ }
+ end
+
+ def version(steps, connections) do
+ %WorkflowDefinitionVersion{
+ id: Ecto.UUID.generate(),
+ steps: steps,
+ connections: connections,
+ step_groups: [],
+ viewport: %{},
+ settings: %{}
+ }
+ end
+
+ def compile!(%WorkflowDefinitionVersion{} = version) do
+ case Compiler.compile(version) do
+ {:ok, workflow, compiled_hash} -> {workflow, compiled_hash}
+ {:error, errors} -> raise "expected workflow to compile, got: #{inspect(errors)}"
+ end
+ end
+
+ def react(%Workflow{} = workflow, input) do
+ workflow
+ |> Workflow.plan_eagerly(input)
+ |> Workflow.react_until_satisfied()
+ end
+
+ def productions(%Workflow{} = workflow, step_id) do
+ Workflow.raw_productions(workflow, step_id)
+ end
+end
diff --git a/test/support/workflows_fixtures.ex b/test/support/workflows_fixtures.ex
new file mode 100644
index 0000000..6f76c8a
--- /dev/null
+++ b/test/support/workflows_fixtures.ex
@@ -0,0 +1,103 @@
+defmodule Fizz.WorkflowsFixtures do
+ @moduledoc false
+
+ import Fizz.AccountsFixtures
+
+ alias Fizz.Accounts.Scope
+ alias Fizz.Workflows
+
+ def project_scope_fixture do
+ user = user_fixture()
+ organization_scope = organization_scope_fixture(user: user)
+
+ project =
+ project_fixture(organization_scope, %{name: "Project #{System.unique_integer([:positive])}"})
+
+ organization_scope
+ |> Scope.with_project(project)
+ |> Scope.with_project_role(:admin)
+ end
+
+ def published_version_fixture(scope, snapshot_attrs \\ valid_snapshot_attrs()) do
+ {:ok, %{definition: definition, draft: draft}} =
+ Workflows.create_definition(scope, %{
+ name: "Workflow #{System.unique_integer([:positive])}",
+ description: "Workflow definition"
+ })
+
+ {:ok, saved_draft} = Workflows.save_draft(scope, draft, snapshot_attrs)
+ {:ok, version} = Workflows.publish_draft(scope, saved_draft)
+
+ %{definition: definition, draft: saved_draft, version: version}
+ end
+
+ def valid_snapshot_attrs do
+ entry_step = step(%{type_id: "debug", name: "Entry"})
+ debug_step = step(%{type_id: "debug", name: "Debug"})
+
+ snapshot_attrs(%{
+ steps: [entry_step, debug_step],
+ connections: [
+ connection(%{source_step_id: entry_step.id, target_step_id: debug_step.id})
+ ]
+ })
+ end
+
+ def long_running_snapshot_attrs(duration_ms \\ 1_000) do
+ entry_step = step(%{type_id: "debug", name: "Entry"})
+
+ wait_step =
+ step(%{
+ type_id: "wait",
+ name: "Wait",
+ config: %{"duration" => duration_ms, "unit" => "milliseconds"}
+ })
+
+ snapshot_attrs(%{
+ steps: [entry_step, wait_step],
+ connections: [
+ connection(%{source_step_id: entry_step.id, target_step_id: wait_step.id})
+ ]
+ })
+ end
+
+ def snapshot_attrs(overrides) do
+ Map.merge(
+ %{
+ steps: [],
+ connections: [],
+ step_groups: [],
+ viewport: %{"x" => 0, "y" => 0, "zoom" => 1.0},
+ settings: %{}
+ },
+ overrides
+ )
+ end
+
+ def step(attrs) do
+ Map.merge(
+ %{
+ id: Ecto.UUID.generate(),
+ type_id: "debug",
+ name: "Step #{System.unique_integer([:positive])}",
+ config: %{},
+ position: %{"x" => 100, "y" => 100},
+ notes: nil
+ },
+ attrs
+ )
+ end
+
+ def connection(attrs) do
+ Map.merge(
+ %{
+ id: Ecto.UUID.generate(),
+ source_step_id: Ecto.UUID.generate(),
+ source_output: "main",
+ target_step_id: Ecto.UUID.generate(),
+ target_input: "main"
+ },
+ attrs
+ )
+ end
+end
diff --git a/vendor/runic/LICENSE b/vendor/runic/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/vendor/runic/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/vendor/runic/README.md b/vendor/runic/README.md
new file mode 100644
index 0000000..d1a48a5
--- /dev/null
+++ b/vendor/runic/README.md
@@ -0,0 +1,403 @@
+
+
+# Runic
+
+Runic is a tool for modeling programs as data driven workflows that can be composed together at runtime.
+
+Runic components connect together in a `Runic.Workflow` supporting lazily evaluated concurrent execution.
+
+Runic Workflows are modeled as a decorated dataflow graph (a DAG - "directed acyclic graph") compiled from components such as steps, rules, pipelines, and state machines and more allowing coordinated interaction of disparate parts.
+
+## Installation
+
+If [available in Hex](https://hex.pm/docs/publish), the package can be installed
+by adding `runic` to your list of dependencies in `mix.exs`:
+
+```elixir
+def deps do
+ [
+ {:runic, "~> 0.1.0-alpha"}
+ ]
+end
+```
+
+Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
+and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
+be found at .
+
+## Concepts
+
+Data flow dependencies between Lambda expressions, common in ETL pipelines, can be built with `%Step{}` components.
+
+A Lambda Steps is a simple `input -> output` function.
+
+```elixir
+require Runic
+
+step = Runic.step(fn x -> x + 1 end)
+```
+
+Steps are composable in a workflow:
+
+```elixir
+workflow = Runic.workflow(
+ name: "example pipeline workflow",
+ steps: [
+ Runic.step(fn x -> x + 1 end), #A
+ Runic.step(fn x -> x * 2 end), #B
+ Runic.step(fn x -> x - 1 end) #C
+ ]
+)
+```
+
+This produces a workflow graph where R is the entrypoint or "root" of the tree:
+
+```mermaid
+graph TD;
+ R-->A;
+ R-->B;
+ R-->C;
+```
+
+In Runic, inputs flow through a workflow as a `%Fact{}`. During workflow evaluation various steps are traversed to and invoked producing more Facts.
+
+```elixir
+alias Runic.Workflow
+
+workflow
+|> Workflow.react_until_satisfied(2)
+|> Workflow.raw_productions()
+
+[3, 4, 1]
+```
+
+A core benefit Runic workflows are modeling pipelines that aren't just linear. For example:
+
+```elixir
+defmodule TextProcessing do
+ def tokenize(text) do
+ text
+ |> String.downcase()
+ |> String.split(~R/[^[:alnum:]\-]/u, trim: true)
+ end
+
+ def count_words(list_of_words) do
+ list_of_words
+ |> Enum.reduce(Map.new(), fn word, map ->
+ Map.update(map, word, 1, &(&1 + 1))
+ end)
+ end
+
+ def count_uniques(word_count) do
+ Enum.count(word_count)
+ end
+
+ def first_word(list_of_words) do
+ List.first(list_of_words)
+ end
+
+ def last_word(list_of_words) do
+ List.last(list_of_words)
+ end
+end
+```
+
+Notice we have 3 functions that expect a `list_of_words`. In Elixir if we wanted to evaluate each output we can pipe them together the pipeline `|>` operator...
+
+```elixir
+import TextProcessing
+
+word_count =
+ "anybody want a peanut?"
+ |> tokenize()
+ |> count_words()
+
+first_word =
+ "anybody want a peanut?"
+ |> tokenize()
+ |> first_word()
+
+last_word =
+ "anybody want a peanut?"
+ |> tokenize()
+ |> last_word()
+```
+
+However we're now evaluating linearly: using the common `tokenize/1` function 3 times for the same input text.
+
+This could be problematic if `tokenize/1` is expensive - we'd prefer to run `tokenize/1` just once and then fed into the rest of our pipeline.
+
+With Runic we can compose all of these steps into one workflow and evaluate them together.
+
+```elixir
+text_processing_workflow =
+ Runic.workflow(
+ name: "basic text processing example",
+ steps: [
+ {Runic.step(&tokenize/1),
+ [
+ {Runic.step(&count_words/1),
+ [
+ Runic.step(&count_uniques/1)
+ ]},
+ Runic.step(&first_word/1),
+ Runic.step(&last_word/1)
+ ]}
+ ]
+ )
+```
+
+Our text processing workflow graph now looks something like this:
+
+```mermaid
+graph TD;
+ R-->tokenize;
+ tokenize-->first_word;
+ tokenize-->last_word;
+ tokenize-->count_words;
+ count_words-->count_uniques;
+```
+
+Now Runic can traverse over the graph of dataflow connections only evaluating `tokenize/1` once for all three dependent steps.
+
+```elixir
+alias Runic.Workflow
+
+text_processing_workflow
+|> Workflow.react_until_satisfied("anybody want a peanut?")
+|> Workflow.raw_productions()
+
+[
+ ["anybody", "want", "a", "peanut"],
+ "anybody",
+ "peanut",
+ 4,
+ %{"a" => 1, "anybody" => 1, "peanut" => 1, "want" => 1}
+]
+```
+
+Beyond steps, Runic has support for Rules, Joins, State Machines, FSMs, Aggregates, Sagas, and ProcessManagers for more complex control flow and stateful evaluation.
+
+The `Runic.Workflow.Invokable` protocol is what allows for extension of Runic's runtime supporting nodes with different execution properties and evaluation.
+
+The `Runic.Component` protocol supports extension of modeling new components that can be added and connected with other components in Runic workflows.
+
+## Runtime Workflow Composition
+
+Workflows can be composed dynamically at runtime:
+
+```elixir
+require Runic
+alias Runic.Workflow
+
+# Using Workflow.add/3 for dynamic composition
+workflow = Runic.workflow()
+ |> Workflow.add(Runic.step(fn x -> x + 1 end, name: :add))
+ |> Workflow.add(Runic.step(fn x -> x * 2 end, name: :double), to: :add)
+
+# Merge two workflows together
+workflow1 = Runic.workflow(steps: [Runic.step(fn x -> x + 1 end)])
+workflow2 = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+combined = Workflow.merge(workflow1, workflow2)
+
+# Join multiple parent nodes
+workflow = workflow
+ |> Workflow.add(Runic.step(fn a, b -> a + b end, name: :join), to: [:branch_a, :branch_b])
+```
+
+See `Runic.Workflow` module documentation for adding components to workflows and running them.
+
+## Three-Phase Execution Model
+
+Runic's Invokable protocol enforces a three-phase execution model designed for parallel execution and external scheduling:
+
+1. **Prepare** - Extract minimal context from the workflow into `%Runnable{}` structs
+2. **Execute** - Run runnables containing work functions and their inputs in isolation (can be parallelized)
+3. **Apply** - Reduce results back into the workflow so next steps can be determined
+
+### Parallel Execution
+
+For workflows where nodes can execute concurrently:
+
+```elixir
+alias Runic.Workflow
+
+# Execute runnables in parallel with configurable concurrency
+workflow
+|> Workflow.react_until_satisfied(input, async: true, max_concurrency: 8) # Task.async_stream options
+|> Workflow.raw_productions()
+```
+
+### External Scheduler Integration
+
+For custom schedulers, worker pools, or distributed execution:
+
+```elixir
+defmodule MyApp.WorkflowScheduler do
+ use GenServer
+ alias Runic.Workflow
+ alias Runic.Workflow.Invokable
+
+# Phase 1: Prepare runnables for dispatch
+ def handle_cast({:run, input}, %{workflow: workflow} = state) do
+ workflow =
+ workflow
+ |> Workflow.plan_eagerly(input)
+ |> dispatch_tasks()
+
+ {:noreply, %{state | workflow: workflow}}
+ end
+
+ # Phase 2: Execute (dispatch to async worker pool, queue, external service, etc.)
+ defp dispatch_tasks(workflow) do
+ {workflow, runnables} = Workflow.prepare_for_dispatch(workflow)
+
+ Enum.map(runnables, fn runnable ->
+ Task.async(fn ->
+ # consider logging, error handling, retries here
+ Invokable.execute(runnable.node, runnable)
+ end)
+ end)
+
+ workflow
+ end
+
+ # Phase 3: Apply results back to workflow by handling async task callbacks with excecuted runnable
+ def handle_info({ref, executed_runnable}, %{workflow: workflow} = state) do
+ new_workflow =
+ Enum.reduce(executed, workflow, fn {:ok, runnable}, wrk ->
+ Workflow.apply_runnable(wrk, runnable)
+ end)
+
+ workflow =
+ if Workflow.is_runnable?(new_workflow) do
+ dispatch_tasks(workflow)
+ end
+
+ {:noreply, %{state | workflow: workflow}}
+ end
+end
+```
+
+Key APIs for external scheduling:
+
+- `Workflow.prepare_for_dispatch/1` - Returns `{workflow, [%Runnable{}]}` for dispatch
+- `Workflow.apply_runnable/2` - Applies a completed runnable back to the workflow
+- `Invokable.execute/2` - Executes a runnable in isolation (no workflow access)
+
+In summary, the `Runic` module provides high level functions and macros for building Runic Components
+ such as Steps, Rules, Workflows, and Accumulators.
+
+The `Runic.Workflow` module is for connecting components together and running them with inputs.
+
+Runic was designed to be used with custom process topologies and libraries such as GenStage, Broadway, and Flow without coupling you to one runtime model or a limited set of adapters.
+
+Runic has first class support for dynamic runtime composition of workflows.
+
+Runic is useful in problems where a developer cannot know upfront the logic or data flow in compiled code such as expert systems, user DSLs like Excel spreadsheets, low/no-code tools, or dynamic data pipelines.
+
+If the runtime modification of a workflow or complex parallel dataflow evaluation isn't something your use case requires you might not need Runic.
+
+Runic Workflows are essentially a dataflow based virtual machine running within Elixir and will not be faster than compiled Elixir code. If you know the flow of the program upfront during development you might not need Runic.
+
+### Runtime Context
+
+Components can declare dependencies on external runtime values using `context/1`:
+
+```elixir
+# Steps can reference external values
+step = Runic.step(fn _x -> context(:api_key) end, name: :call_llm)
+
+# Rules can use context in conditions
+rule = Runic.rule name: :gated do
+ given(val: v)
+ where(v > context(:threshold))
+ then(fn %{val: v} -> {:ok, v} end)
+end
+
+# Accumulators, map, and reduce also support context/1
+acc = Runic.accumulator(0, fn x, s -> s + x * context(:factor) end, name: :scaled)
+map = Runic.map(fn x -> x * context(:multiplier) end, name: :mult_map)
+
+# Provide defaults for optional context keys
+step = Runic.step(fn _x -> context(:model, default: "gpt-4") end, name: :call_llm)
+
+# Provide values at runtime
+workflow
+|> Workflow.put_run_context(%{
+ call_llm: %{api_key: "sk-..."},
+ _global: %{workspace_id: "ws1"}
+})
+|> Workflow.react_until_satisfied(input)
+```
+
+Context values are scoped by component name, not part of the workflow hash, and not serialized — making them safe for secrets and connection handles. Keys with defaults are satisfied without explicit `run_context` entries. See `Workflow.required_context_keys/1` and `Workflow.validate_run_context/2` for introspection.
+
+## Scheduler Policies
+
+Runic workflows support declarative per-node scheduling policies for retries, timeouts, backoff, fallbacks, and failure handling — without modifying the `Invokable` protocol or existing component structs.
+
+Policies are stored on the workflow as a list of `{matcher, policy_map}` rules resolved at execution time. The first matching rule wins:
+
+```elixir
+alias Runic.Workflow
+alias Runic.Workflow.SchedulerPolicy
+
+workflow =
+ workflow
+ |> Workflow.add_scheduler_policy(:call_llm, %{
+ max_retries: 3,
+ backoff: :exponential,
+ timeout_ms: 30_000
+ })
+ |> Workflow.append_scheduler_policy(:default, %{timeout_ms: 10_000})
+
+# Policies are applied automatically during react/react_until_satisfied
+workflow |> Workflow.react_until_satisfied(input)
+
+# Runtime overrides can be passed as options (prepended with higher priority)
+workflow |> Workflow.react_until_satisfied(input,
+ scheduler_policies: [{:flaky_step, %{max_retries: 5}}]
+)
+```
+
+Policy matchers support exact name atoms, regex (`{:name, ~r/^llm_/}`), type matching (`{:type, Step}`), custom predicates (`fn node -> ... end`), and `:default` catch-alls. Backoff strategies include `:none`, `:linear`, `:exponential`, and `:jitter`.
+
+See `Runic.Workflow.SchedulerPolicy` for the full policy struct and matcher documentation, and `Runic.Workflow.PolicyDriver` for execution driver details.
+
+## Built-in Runner
+
+`Runic.Runner` provides batteries-included workflow execution infrastructure: a supervision tree with a `DynamicSupervisor` for workers, `Task.Supervisor` for fault-isolated dispatch, `Registry` for lookup, and pluggable persistence via `Runic.Runner.Store`.
+
+```elixir
+# Start a runner in your supervision tree
+{:ok, _} = Runic.Runner.start_link(name: MyApp.Runner)
+
+# Start and run a workflow
+{:ok, _pid} = Runic.Runner.start_workflow(MyApp.Runner, :order_123, workflow)
+:ok = Runic.Runner.run(MyApp.Runner, :order_123, input)
+
+# Query results
+{:ok, results} = Runic.Runner.get_results(MyApp.Runner, :order_123)
+
+# Resume from persisted state after a crash
+{:ok, _pid} = Runic.Runner.resume(MyApp.Runner, :order_123)
+```
+
+Workers automatically integrate with scheduler policies, dispatch runnables to supervised tasks with configurable `max_concurrency`, and support checkpointing strategies (`:every_cycle`, `:on_complete`, `{:every_n, n}`, `:manual`).
+
+Built-in store adapters: `Runic.Runner.Store.ETS` (default, in-memory) and `Runic.Runner.Store.Mnesia` (disk-persistent, distributed). Custom adapters implement the `Runic.Runner.Store` behaviour.
+
+Telemetry events are emitted under `[:runic, :runner, ...]` for workflow lifecycle, runnable dispatch/completion, and store operations. See `Runic.Runner.Telemetry` for the full event catalog.
+
+## Guides
+
+For quick reference and best practices:
+
+- [**Cheatsheet**](guides/cheatsheet.md) - Quick reference for all core APIs
+- [**Usage Rules**](guides/usage-rules.md) - Core concepts, do's/don'ts, and patterns
+- [**Protocols**](guides/protocols.md) - Extending Runic with custom components and execution behavior
+- [**Building a Workflow Scheduler**](guides/scheduling.md) - From simple spawned processes to production GenServer schedulers
+- [**Durable Execution**](guides/durable-execution.md) - Persistence, crash recovery, and checkpointing with the Runner
+- [**State-Based Components**](guides/state-based-components.md) - FSMs, Aggregates, Sagas, ProcessManagers and when to use each
+
diff --git a/vendor/runic/lib/closure.ex b/vendor/runic/lib/closure.ex
new file mode 100644
index 0000000..c1bda40
--- /dev/null
+++ b/vendor/runic/lib/closure.ex
@@ -0,0 +1,279 @@
+defmodule Runic.Closure do
+ @moduledoc """
+ Serializable closure representation for Runic components.
+
+ A Closure combines the source AST, runtime bindings, and compile-time
+ metadata into a single serializable structure. This allows components
+ to be reconstructed from logs using `term_to_binary/1` and `binary_to_term/1`.
+
+ ## Fields
+
+ - `:source` - The original quoted AST for the closure
+ - `:bindings` - Map of variable names to their captured values
+ - `:metadata` - `%Runic.ClosureMetadata{}` for environment reconstruction
+ - `:hash` - Content-addressable hash of the closure
+
+ ## Examples
+
+ outer_var = 42
+
+ closure = Runic.Closure.new(
+ quote(do: fn x -> x + outer_var end),
+ %{outer_var: 42},
+ __ENV__
+ )
+
+ # Closures are serializable
+ binary = :erlang.term_to_binary(closure)
+ roundtrip = :erlang.binary_to_term(binary)
+
+ # Can be evaluated in a different context
+ {fun, _} = Runic.Closure.eval(closure)
+ fun.(10) # => 52
+ """
+
+ alias Runic.ClosureMetadata
+ alias Runic.Workflow.Components
+
+ @type t :: %__MODULE__{
+ source: Macro.t(),
+ bindings: map(),
+ metadata: ClosureMetadata.t() | nil,
+ hash: integer() | nil
+ }
+
+ @derive {Inspect, only: [:hash, :bindings]}
+ defstruct [
+ # Quoted AST
+ :source,
+ # Map of var => value
+ :bindings,
+ # %ClosureMetadata{}
+ :metadata,
+ # Content hash
+ :hash
+ ]
+
+ @doc """
+ Creates a new Closure from source AST, bindings, and caller environment.
+
+ Validates that all bindings are serializable before creating the closure.
+ Raises `ArgumentError` if any binding contains non-serializable values.
+ """
+ def new(source, bindings, caller_env_or_metadata)
+
+ def new(source, bindings, %Macro.Env{} = caller) when is_map(bindings) do
+ # Validate bindings are serializable
+ validate_bindings!(bindings)
+
+ # Extract metadata from caller environment
+ metadata = ClosureMetadata.from_caller(caller)
+
+ # Compute hash from source and bindings
+ hash = Components.fact_hash({source, bindings})
+
+ %__MODULE__{
+ source: source,
+ bindings: bindings,
+ metadata: metadata,
+ hash: hash
+ }
+ end
+
+ def new(source, bindings, %ClosureMetadata{} = metadata) when is_map(bindings) do
+ # Validate bindings are serializable
+ validate_bindings!(bindings)
+
+ # Compute hash from source and bindings
+ hash = Components.fact_hash({source, bindings})
+
+ %__MODULE__{
+ source: source,
+ bindings: bindings,
+ metadata: metadata,
+ hash: hash
+ }
+ end
+
+ def new(source, bindings, nil) when is_map(bindings) do
+ # No metadata case - for closures without environment dependencies
+ validate_bindings!(bindings)
+
+ hash = Components.fact_hash({source, bindings})
+
+ %__MODULE__{
+ source: source,
+ bindings: bindings,
+ metadata: nil,
+ hash: hash
+ }
+ end
+
+ @doc """
+ Evaluates a closure, returning the result and updated bindings.
+
+ This reconstructs the evaluation environment from the closure's metadata
+ and evaluates the source AST with the stored bindings.
+
+ ## Options
+
+ - `:base_env` - Override the base environment for evaluation
+ """
+ def eval(%__MODULE__{} = closure, opts \\ []) do
+ # Build evaluation environment from metadata
+ env =
+ if closure.metadata do
+ ClosureMetadata.to_eval_env(closure.metadata, opts)
+ else
+ build_minimal_env()
+ end
+
+ # Convert bindings map to keyword list for evaluation
+ binding_list = Map.to_list(closure.bindings)
+
+ # Evaluate the source with bindings
+ # Note: This works because the original macro expansion in Runic.step
+ # already rewrites the AST to use the binding values
+ Code.eval_quoted(closure.source, binding_list, env)
+ end
+
+ @doc """
+ Validates that a value can be serialized with `term_to_binary/1`.
+
+ Returns `:ok` if serializable, `{:error, reason}` otherwise.
+
+ Note: PIDs, references, and ports can technically be serialized but they
+ are not valid across sessions/nodes, so we reject them.
+ """
+ def validate_value(value) do
+ cond do
+ # Reject runtime-specific values even though they serialize
+ is_pid(value) ->
+ {:error, detailed_error(value)}
+
+ is_reference(value) ->
+ {:error, detailed_error(value)}
+
+ is_port(value) ->
+ {:error, detailed_error(value)}
+
+ is_function(value) ->
+ # Check if it's an external function (those are OK)
+ info = Function.info(value)
+
+ case Keyword.get(info, :type) do
+ :external ->
+ :ok
+
+ _ ->
+ {:error, detailed_error(value)}
+ end
+
+ true ->
+ # Try serialization roundtrip
+ try do
+ binary = :erlang.term_to_binary(value)
+ roundtrip = :erlang.binary_to_term(binary)
+
+ if value == roundtrip do
+ :ok
+ else
+ {:error, "Value does not roundtrip through serialization"}
+ end
+ rescue
+ ArgumentError ->
+ {:error, "Value cannot be serialized"}
+ end
+ end
+ end
+
+ @doc """
+ Validates that all bindings in a map are serializable.
+
+ Raises `ArgumentError` if any binding is not serializable.
+ """
+ def validate_bindings!(bindings) when is_map(bindings) do
+ Enum.each(bindings, fn {key, value} ->
+ case validate_value(value) do
+ :ok ->
+ :ok
+
+ {:error, reason} ->
+ raise ArgumentError, """
+ Cannot serialize binding `#{key}` in closure: #{reason}
+
+ Value: #{inspect(value, limit: 3)}
+
+ Only serializable values are supported in runtime bindings.
+ Use module functions (&Module.function/arity) instead of closures.
+ """
+ end
+ end)
+
+ :ok
+ end
+
+ # Provide detailed error messages for common non-serializable types
+ defp detailed_error(value) when is_function(value) do
+ case Function.info(value) do
+ [module: m, name: f, arity: a, type: :external] ->
+ "Anonymous function (use {m}.#{f}/#{a} instead)"
+
+ info when is_list(info) ->
+ case Keyword.get(info, :type) do
+ :local ->
+ "Anonymous closure cannot be serialized (captures local variables)"
+
+ _ ->
+ "Anonymous function cannot be serialized"
+ end
+
+ _ ->
+ "Anonymous function cannot be serialized"
+ end
+ end
+
+ defp detailed_error(value) when is_pid(value) do
+ "PIDs cannot be serialized across sessions"
+ end
+
+ defp detailed_error(value) when is_reference(value) do
+ "References cannot be serialized"
+ end
+
+ defp detailed_error(value) when is_port(value) do
+ "Ports cannot be serialized"
+ end
+
+ defp detailed_error(_value) do
+ "Value type is not serializable"
+ end
+
+ # Build a minimal evaluation environment
+ # We cache the environment in the process dictionary to avoid recreating it
+ defp build_minimal_env do
+ case Process.get({__MODULE__, :base_env}) do
+ nil ->
+ # Create a clean environment with necessary imports
+ {result, _} =
+ Code.eval_string(
+ """
+ require Runic
+ import Runic
+ alias Runic.Workflow
+ __ENV__
+ """,
+ [],
+ file: "nofile",
+ line: 1
+ )
+
+ env = result |> Macro.Env.prune_compile_info() |> Code.env_for_eval()
+ Process.put({__MODULE__, :base_env}, env)
+ env
+
+ env ->
+ env
+ end
+ end
+end
diff --git a/vendor/runic/lib/closure_metadata.ex b/vendor/runic/lib/closure_metadata.ex
new file mode 100644
index 0000000..9dce081
--- /dev/null
+++ b/vendor/runic/lib/closure_metadata.ex
@@ -0,0 +1,146 @@
+defmodule Runic.ClosureMetadata do
+ @moduledoc """
+ Serializable metadata for reconstructing closures from logs.
+
+ This struct captures the minimal compile-time environment information needed
+ to reconstruct a closure in a different runtime context. Unlike `%Macro.Env{}`,
+ this struct only contains serializable data (atoms, lists, module names).
+
+ ## Fields
+
+ - `:imports` - List of module names that were imported in the calling context
+ - `:aliases` - List of `{short_alias, full_module}` tuples
+ - `:requires` - List of module names that were required
+
+ ## Examples
+
+ iex> metadata = Runic.ClosureMetadata.from_caller(__ENV__)
+ %Runic.ClosureMetadata{
+ imports: [Enum, String],
+ aliases: [{MyAlias, My.Full.Module}],
+ requires: [Logger]
+ }
+
+ iex> env = Runic.ClosureMetadata.to_eval_env(metadata)
+ # Returns a %Macro.Env{} suitable for Code.eval_quoted/3
+ """
+
+ @type t :: %__MODULE__{
+ imports: [module()] | nil,
+ aliases: [{atom(), module()}] | nil,
+ requires: [module()] | nil,
+ module: module() | nil
+ }
+
+ @derive {Inspect, only: [:imports, :aliases, :requires, :module]}
+ defstruct [
+ # [module_name]
+ :imports,
+ # [{short, full}]
+ :aliases,
+ # [module_name]
+ :requires,
+ # module name where closure was defined
+ :module
+ ]
+
+ @doc """
+ Extracts serializable metadata from a `%Macro.Env{}` struct.
+
+ Only captures module names and alias information - no functions,
+ no compile-time state, no context that cannot be serialized.
+ """
+ def from_caller(%Macro.Env{} = env) do
+ %__MODULE__{
+ imports: extract_imports(env),
+ aliases: env.aliases,
+ requires: env.requires,
+ module: env.module
+ }
+ end
+
+ @doc """
+ Reconstructs a `%Macro.Env{}` suitable for evaluating quoted code.
+
+ This creates a minimal evaluation environment with the captured
+ imports, aliases, and requires restored. Uses best-effort approach
+ for imports - if a module isn't loaded, it's skipped.
+
+ ## Options
+
+ - `:base_env` - Base environment to start from (defaults to current __ENV__)
+ """
+ def to_eval_env(%__MODULE__{} = meta, opts \\ []) do
+ base_env = Keyword.get(opts, :base_env, build_base_env())
+
+ env = base_env
+
+ # Use the module from metadata if available
+ env = if meta.module, do: %{env | module: meta.module}, else: env
+
+ # Apply requires
+ env = %{env | requires: meta.requires}
+
+ # Apply aliases
+ env = %{env | aliases: meta.aliases}
+
+ # Apply imports (best effort - skip modules that aren't loaded)
+ env =
+ Enum.reduce(meta.imports, env, fn mod, acc ->
+ case Code.ensure_loaded(mod) do
+ {:module, ^mod} ->
+ case Macro.Env.define_import(acc, [], mod) do
+ {:ok, new_env} -> new_env
+ {:error, _reason} -> acc
+ end
+
+ _ ->
+ acc
+ end
+ end)
+
+ # Convert to eval environment
+ env
+ |> Macro.Env.prune_compile_info()
+ |> Code.env_for_eval()
+ end
+
+ # Extract imported module names from environment
+ # Only captures module names, not the full function/macro lists
+ defp extract_imports(env) do
+ imported_modules =
+ (env.functions ++ env.macros)
+ |> Enum.map(fn {mod, _funs} -> mod end)
+ |> Enum.uniq()
+
+ imported_modules
+ end
+
+ # Build a base environment for evaluation
+ # We cache the environment in the process dictionary to avoid recreating it
+ defp build_base_env do
+ case Process.get({__MODULE__, :base_env}) do
+ nil ->
+ # Create a clean environment with necessary imports
+ # We eval in the Elixir module context to get a clean slate
+ {result, _} =
+ Code.eval_string(
+ """
+ require Runic
+ import Runic
+ alias Runic.Workflow
+ __ENV__
+ """,
+ [],
+ file: "nofile",
+ line: 1
+ )
+
+ Process.put({__MODULE__, :base_env}, result)
+ result
+
+ env ->
+ env
+ end
+ end
+end
diff --git a/vendor/runic/lib/runic.ex b/vendor/runic/lib/runic.ex
new file mode 100644
index 0000000..cf47352
--- /dev/null
+++ b/vendor/runic/lib/runic.ex
@@ -0,0 +1,7046 @@
+defmodule Runic do
+ @external_resource "README.md"
+ @moduledoc "README.md" |> File.read!() |> String.split("") |> Enum.fetch!(1)
+
+ alias Runic.Closure
+ alias Runic.ClosureMetadata
+ alias Runic.Workflow.CompilationUtils
+ alias Runic.Workflow.Accumulator
+ alias Runic.Workflow.Aggregate
+ alias Runic.Workflow.StateMachine
+ alias Runic.Workflow.Saga
+ alias Runic.Workflow.FSM
+ alias Runic.Workflow.ProcessManager
+ alias Runic.Workflow
+ alias Runic.Workflow.Step
+ alias Runic.Workflow.Condition
+ alias Runic.Workflow.Rule
+ alias Runic.Workflow.Components
+ alias Runic.Workflow.Conjunction
+ alias Runic.Workflow.FanOut
+ alias Runic.Workflow.FanIn
+ alias Runic.Workflow.Join
+
+ defp default_component_name(component_kind, hash) do
+ "#{component_kind}_#{hash}"
+ end
+
+ @doc """
+ Creates a `%Step{}`: a basic lambda expression that can be added to a workflow.
+
+ Steps are the fundamental building blocks of Runic workflows, representing
+ input → output transformations. Each step wraps a function and can be composed
+ with other steps to form data processing pipelines.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> step = Runic.step(fn x -> x * 2 end)
+ iex> step.work.(5)
+ 10
+
+ ## Arities
+
+ Steps support 0, 1, or 2-arity functions:
+
+ iex> require Runic
+ iex> zero_arity = Runic.step(fn -> 42 end)
+ iex> zero_arity.work.()
+ 42
+
+ iex> require Runic
+ iex> one_arity = Runic.step(fn x -> x + 1 end)
+ iex> one_arity.work.(10)
+ 11
+
+ iex> require Runic
+ iex> two_arity = Runic.step(fn a, b -> a + b end)
+ iex> two_arity.work.(3, 4)
+ 7
+
+ Note: 2-arity steps only execute when the workflow receives a 2-element list as input.
+
+ ## Captured Variables with `^`
+
+ Use the pin operator `^` to capture outer scope variables. This is essential for:
+ - Content-addressable hashing (each bound value produces unique hashes)
+ - Serialization with `build_log/1` and recovery with `from_log/1`
+ - Dynamic workflow construction at runtime
+
+ iex> require Runic
+ iex> multiplier = 3
+ iex> step = Runic.step(fn x -> x * ^multiplier end)
+ iex> step.closure.bindings[:multiplier]
+ 3
+ iex> step.work.(10)
+ 30
+
+ Without `^`, Elixir's normal closure mechanism captures the variable, but Runic
+ cannot track, hash, or serialize it - the workflow will fail after persistence.
+
+ ## Captured Functions
+
+ Steps can wrap module functions using capture syntax:
+
+ iex> require Runic
+ iex> step = Runic.step(&String.upcase/1)
+ iex> step.work.("hello")
+ "HELLO"
+
+ ## Options
+
+ - `:name` - An atom or string identifier for referencing this step in workflows
+ - `:work` - The function to execute (alternative to passing as first argument)
+ - `:inputs` - Reserved for future schema-based type compatibility
+ - `:outputs` - Reserved for future schema-based type compatibility
+
+ iex> require Runic
+ iex> step = Runic.step(fn x -> x * 2 end, name: :doubler)
+ iex> step.name
+ :doubler
+
+ iex> require Runic
+ iex> step = Runic.step(name: :tripler, work: fn x -> x * 3 end)
+ iex> step.name
+ :tripler
+
+ ## In Workflows
+
+ Steps can be connected in pipelines using the workflow DSL:
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(
+ ...> name: "pipeline",
+ ...> steps: [
+ ...> {Runic.step(fn x -> x + 1 end, name: :add_one),
+ ...> [Runic.step(fn x -> x * 2 end, name: :double)]}
+ ...> ]
+ ...> )
+ iex> results = workflow |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions()
+ iex> Enum.sort(results)
+ [6, 12]
+
+ """
+ defmacro step({:fn, _, _} = work) do
+ source =
+ quote do
+ Runic.step(unquote(work))
+ end
+
+ {rewritten_work, work_bindings} = traverse_expression(work, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_work, meta_refs} = maybe_compile_meta_work(work, rewritten_work, __CALLER__)
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+
+ variable_bindings =
+ work_bindings
+ |> Enum.uniq()
+
+ # Build closure source with FULL component creation using rewritten work
+ # This way when evaluated, it returns a Step struct, not just a function
+ closure_source =
+ quote do
+ Runic.step(unquote(rewritten_work))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ # If we have bindings, compute hash at runtime to include binding values
+ # Otherwise compute at compile time
+ if Enum.empty?(variable_bindings) do
+ step_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+
+ quote do
+ Step.new(
+ work: unquote(final_work),
+ closure: unquote(closure),
+ name: unquote(default_component_name("step", step_hash)),
+ hash: unquote(step_hash),
+ work_hash: unquote(work_hash),
+ inputs: nil,
+ outputs: nil,
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ else
+ # For content addressability, normalize the work AST before hashing
+ normalized_work = normalize_ast(rewritten_work)
+
+ quote do
+ closure = unquote(closure)
+ # Step hash is based on the closure (full component creation)
+ step_hash = closure.hash
+ # Work hash is based on NORMALIZED AST + bindings for deterministic content addressing
+ work_hash =
+ Components.fact_hash({unquote(Macro.escape(normalized_work)), closure.bindings})
+
+ Step.new(
+ work: unquote(final_work),
+ closure: closure,
+ name: unquote(default_component_name("step", "_")) <> "_#{step_hash}",
+ hash: step_hash,
+ work_hash: work_hash,
+ inputs: nil,
+ outputs: nil,
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ end
+ end
+
+ defmacro step({:&, _, _} = work) do
+ source =
+ quote do
+ Runic.step(unquote(work))
+ end
+
+ step_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+
+ # For captured functions, the closure source is the full step creation
+ closure_source = source
+ closure = build_closure(closure_source, [], __CALLER__)
+
+ quote do
+ Step.new(
+ work: unquote(work),
+ closure: unquote(closure),
+ name: unquote(default_component_name("step", step_hash)),
+ hash: unquote(step_hash),
+ work_hash: unquote(work_hash),
+ inputs: nil,
+ outputs: nil
+ )
+ end
+ end
+
+ defmacro step(opts) when is_list(opts) or is_map(opts) do
+ {rewritten_opts, opts_bindings} =
+ if is_list(opts), do: traverse_options(opts, __CALLER__), else: {opts, []}
+
+ work = rewritten_opts[:work]
+
+ source =
+ quote do
+ Runic.step(unquote(opts))
+ end
+
+ {rewritten_work, work_bindings} = traverse_expression(work, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_work, meta_refs} = maybe_compile_meta_work(work, rewritten_work, __CALLER__)
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+
+ variable_bindings =
+ (work_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ # Build closure source with FULL component creation
+ closure_source =
+ quote do
+ Runic.step(unquote(rewritten_opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ # If we have bindings, compute hash at runtime
+ # Otherwise compute at compile time
+ if Enum.empty?(variable_bindings) do
+ step_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+ step_name = rewritten_opts[:name] || default_component_name("step", step_hash)
+
+ quote do
+ Step.new(
+ work: unquote(final_work),
+ closure: unquote(closure),
+ name: unquote(step_name),
+ hash: unquote(step_hash),
+ work_hash: unquote(work_hash),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ else
+ base_name = rewritten_opts[:name]
+ normalized_work = normalize_ast(rewritten_work)
+
+ quote do
+ closure = unquote(closure)
+ step_hash = closure.hash
+
+ work_hash =
+ Components.fact_hash({unquote(Macro.escape(normalized_work)), closure.bindings})
+
+ step_name =
+ if unquote(base_name) do
+ unquote(base_name)
+ else
+ unquote(default_component_name("step", "_")) <> "_#{step_hash}"
+ end
+
+ Step.new(
+ work: unquote(final_work),
+ closure: closure,
+ name: step_name,
+ hash: step_hash,
+ work_hash: work_hash,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ end
+ end
+
+ defmacro step(work, opts) do
+ {rewritten_opts, opts_bindings} =
+ if is_list(opts), do: traverse_options(opts, __CALLER__), else: {opts, []}
+
+ validate_port_schema(rewritten_opts[:inputs], "step")
+ validate_port_schema(rewritten_opts[:outputs], "step")
+
+ source =
+ quote do
+ Runic.step(unquote(work), unquote(opts))
+ end
+
+ {rewritten_work, work_bindings} = traverse_expression(work, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_work, meta_refs} = maybe_compile_meta_work(work, rewritten_work, __CALLER__)
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+
+ variable_bindings =
+ (work_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ # Build closure source with FULL component creation
+ closure_source =
+ quote do
+ Runic.step(unquote(rewritten_work), unquote(opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ # If we have bindings, compute hash at runtime
+ # Otherwise compute at compile time
+ if Enum.empty?(variable_bindings) do
+ step_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+ step_name = rewritten_opts[:name] || default_component_name("step", step_hash)
+
+ quote do
+ Step.new(
+ work: unquote(final_work),
+ closure: unquote(closure),
+ name: unquote(step_name),
+ hash: unquote(step_hash),
+ work_hash: unquote(work_hash),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ else
+ base_name = rewritten_opts[:name]
+ normalized_work = normalize_ast(rewritten_work)
+
+ quote do
+ closure = unquote(closure)
+ step_hash = closure.hash
+
+ work_hash =
+ Components.fact_hash({unquote(Macro.escape(normalized_work)), closure.bindings})
+
+ step_name =
+ if unquote(base_name) do
+ unquote(base_name)
+ else
+ unquote(default_component_name("step", "_")) <> "_#{step_hash}"
+ end
+
+ Step.new(
+ work: unquote(final_work),
+ closure: closure,
+ name: step_name,
+ hash: step_hash,
+ work_hash: work_hash,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ end
+ end
+
+ @doc """
+ Creates a `%Condition{}`: a standalone conditional expression.
+
+ Conditions represent the left-hand side (predicate) of a rule. They can be
+ reused across multiple rules when the same condition is expensive or shared.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> cond = Runic.condition(fn x -> x > 10 end)
+ iex> cond.work.(15)
+ true
+ iex> cond.work.(5)
+ false
+
+ ## With Module Function Capture
+
+ iex> require Runic
+ iex> cond = Runic.condition({Kernel, :is_integer, 1})
+ iex> cond.work.(42)
+ true
+
+ ## With Name Option
+
+ iex> require Runic
+ iex> cond = Runic.condition(fn x -> x > 10 end, name: :big_number)
+ iex> cond.name
+ :big_number
+
+ ## Captured Variables with `^`
+
+ Use the pin operator `^` to capture outer scope variables:
+
+ iex> require Runic
+ iex> threshold = 10
+ iex> cond = Runic.condition(fn x -> x > ^threshold end)
+ iex> cond.closure.bindings[:threshold]
+ 10
+ iex> cond.work.(15)
+ true
+
+ ## Use Cases
+
+ - **Expensive checks**: When a condition involves costly operations (e.g., API calls,
+ database queries), define it once and reference it in multiple rules
+ - **Stateful conditions**: Use `state_of/1` to create conditions that depend on
+ accumulator or state machine state
+ - **Reusability**: Share predicates across rules for consistency
+
+ Note: Conditions should be pure and deterministic - they should not execute side effects.
+ """
+ defmacro condition({:fn, _, _} = work) do
+ source =
+ quote do
+ Runic.condition(unquote(work))
+ end
+
+ {rewritten_work, work_bindings} = traverse_expression(work, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_work, meta_refs} = maybe_compile_meta_work(work, rewritten_work, __CALLER__)
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+ arity = if meta_refs != [], do: 2, else: condition_arity_from_fn_ast(work)
+
+ variable_bindings =
+ work_bindings
+ |> Enum.uniq()
+
+ closure_source =
+ quote do
+ Runic.condition(unquote(rewritten_work))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ if Enum.empty?(variable_bindings) do
+ condition_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+
+ quote do
+ Condition.new(
+ work: unquote(final_work),
+ closure: unquote(closure),
+ name: unquote(default_component_name("condition", condition_hash)),
+ hash: unquote(condition_hash),
+ work_hash: unquote(work_hash),
+ arity: unquote(arity),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ else
+ normalized_work = normalize_ast(rewritten_work)
+
+ quote do
+ closure = unquote(closure)
+ condition_hash = closure.hash
+
+ work_hash =
+ Components.fact_hash({unquote(Macro.escape(normalized_work)), closure.bindings})
+
+ Condition.new(
+ work: unquote(final_work),
+ closure: closure,
+ name: unquote(default_component_name("condition", "_")) <> "_#{condition_hash}",
+ hash: condition_hash,
+ work_hash: work_hash,
+ arity: unquote(arity),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ end
+ end
+
+ defmacro condition({:&, _, _} = work) do
+ source =
+ quote do
+ Runic.condition(unquote(work))
+ end
+
+ condition_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+
+ closure_source = source
+ closure = build_closure(closure_source, [], __CALLER__)
+
+ quote do
+ work_fn = unquote(work)
+ arity = Function.info(work_fn, :arity) |> elem(1)
+
+ Condition.new(
+ work: work_fn,
+ closure: unquote(closure),
+ name: unquote(default_component_name("condition", condition_hash)),
+ hash: unquote(condition_hash),
+ work_hash: unquote(work_hash),
+ arity: arity
+ )
+ end
+ end
+
+ defmacro condition(name) when is_atom(name) do
+ quote do
+ %Runic.Workflow.ConditionRef{name: unquote(name)}
+ end
+ end
+
+ defmacro condition({:{}, _, [m, f, a]}) do
+ quote do
+ work_fn = Function.capture(unquote(m), unquote(f), unquote(a))
+
+ Condition.new(
+ work: work_fn,
+ hash: Components.work_hash(work_fn),
+ arity: unquote(a)
+ )
+ end
+ end
+
+ defmacro condition(work, opts) do
+ {rewritten_opts, opts_bindings} =
+ if is_list(opts), do: traverse_options(opts, __CALLER__), else: {opts, []}
+
+ case work do
+ {:fn, _, _} ->
+ source =
+ quote do
+ Runic.condition(unquote(work), unquote(opts))
+ end
+
+ {rewritten_work, work_bindings} = traverse_expression(work, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_work, meta_refs} = maybe_compile_meta_work(work, rewritten_work, __CALLER__)
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+ arity = if meta_refs != [], do: 2, else: condition_arity_from_fn_ast(work)
+
+ variable_bindings =
+ (work_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ closure_source =
+ quote do
+ Runic.condition(unquote(rewritten_work), unquote(opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ if Enum.empty?(variable_bindings) do
+ condition_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+
+ condition_name =
+ rewritten_opts[:name] || default_component_name("condition", condition_hash)
+
+ quote do
+ Condition.new(
+ work: unquote(final_work),
+ closure: unquote(closure),
+ name: unquote(condition_name),
+ hash: unquote(condition_hash),
+ work_hash: unquote(work_hash),
+ arity: unquote(arity),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ else
+ base_name = rewritten_opts[:name]
+ normalized_work = normalize_ast(rewritten_work)
+
+ quote do
+ closure = unquote(closure)
+ condition_hash = closure.hash
+
+ work_hash =
+ Components.fact_hash({unquote(Macro.escape(normalized_work)), closure.bindings})
+
+ condition_name =
+ if unquote(base_name) do
+ unquote(base_name)
+ else
+ unquote(default_component_name("condition", "_")) <> "_#{condition_hash}"
+ end
+
+ Condition.new(
+ work: unquote(final_work),
+ closure: closure,
+ name: condition_name,
+ hash: condition_hash,
+ work_hash: work_hash,
+ arity: unquote(arity),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+ end
+
+ {:&, _, _} ->
+ source =
+ quote do
+ Runic.condition(unquote(work), unquote(opts))
+ end
+
+ condition_hash = Components.fact_hash(source)
+ work_hash = Components.fact_hash(work)
+ closure = build_closure(source, [], __CALLER__)
+
+ condition_name =
+ rewritten_opts[:name] || default_component_name("condition", condition_hash)
+
+ quote do
+ work_fn = unquote(work)
+ arity = Function.info(work_fn, :arity) |> elem(1)
+
+ Condition.new(
+ work: work_fn,
+ closure: unquote(closure),
+ name: unquote(condition_name),
+ hash: unquote(condition_hash),
+ work_hash: unquote(work_hash),
+ arity: arity
+ )
+ end
+
+ _ ->
+ quote do
+ Runic.__condition_runtime__(unquote(work), unquote(rewritten_opts))
+ end
+ end
+ end
+
+ @doc false
+ def __condition_runtime__(fun, opts \\ []) when is_function(fun) do
+ arity = Function.info(fun, :arity) |> elem(1)
+
+ Condition.new(
+ work: fun,
+ hash: Components.work_hash(fun),
+ arity: arity,
+ name: opts[:name]
+ )
+ end
+
+ defp condition_arity_from_fn_ast({:fn, _, clauses}) do
+ case clauses do
+ [{:->, _, [args, _]} | _] ->
+ args
+ |> List.flatten()
+ |> Enum.count()
+
+ _ ->
+ 1
+ end
+ end
+
+ @doc """
+ Converts a Runic component into a `%Workflow{}` via the `Transmutable` protocol.
+
+ Components like steps, rules, state machines, etc. can be transmuted into
+ standalone workflows for evaluation or composition.
+
+ ## Examples
+
+ iex> require Runic
+ iex> step = Runic.step(fn x -> x * 2 end)
+ iex> workflow = Runic.transmute(step)
+ iex> workflow.__struct__
+ Runic.Workflow
+
+ iex> require Runic
+ iex> rule = Runic.rule(fn x when x > 0 -> :positive end)
+ iex> workflow = Runic.transmute(rule)
+ iex> workflow.__struct__
+ Runic.Workflow
+
+ ## Use Cases
+
+ - Preparing components for standalone evaluation
+ - Converting natural representations (e.g., a single rule) into evaluable workflows
+ - Composing heterogeneous components by first converting to workflows, then merging
+ """
+ def transmute(component) do
+ component
+ |> Runic.Transmutable.transmute()
+ |> do_transmute()
+ end
+
+ defp do_transmute(%Workflow{} = workflow), do: workflow
+ defp do_transmute(component), do: transmute(component)
+
+ @doc """
+ Creates a `%Workflow{}` from component options.
+
+ Workflows are directed acyclic graphs (DAGs) of steps, rules, and other components
+ connected through dataflow semantics. They enable lazy or eager evaluation and can
+ be composed, persisted, and distributed.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(
+ ...> name: :simple,
+ ...> steps: [Runic.step(fn x -> x * 2 end)]
+ ...> )
+ iex> workflow |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions()
+ [10]
+
+ ## Options
+
+ - `:name` - Identifier for the workflow (atom or string)
+ - `:steps` - List of steps, with optional pipeline syntax for parent-child relationships
+ - `:rules` - List of conditional rules to add
+ - `:before_hooks` - Debug hooks called before step execution
+ - `:after_hooks` - Debug hooks called after step execution
+ - `:input_ports` - Port contract for workflow boundary inputs (keyword list of port schemas)
+ - `:output_ports` - Port contract for workflow boundary outputs (keyword list of port schemas)
+
+ ## Pipeline Syntax
+
+ Use tuples `{parent, [children]}` to define step dependencies:
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> pipeline = Runic.workflow(
+ ...> name: :pipeline,
+ ...> steps: [
+ ...> {Runic.step(fn x -> x + 1 end, name: :add_one),
+ ...> [Runic.step(fn x -> x * 2 end, name: :double),
+ ...> Runic.step(fn x -> x * 3 end, name: :triple)]}
+ ...> ]
+ ...> )
+ iex> results = pipeline |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions()
+ iex> Enum.sort(results)
+ [6, 12, 18]
+
+ ## With Rules
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(
+ ...> name: :rule_example,
+ ...> rules: [
+ ...> Runic.rule(fn x when is_integer(x) and x > 10 -> :large end),
+ ...> Runic.rule(fn x when is_integer(x) and x <= 10 -> :small end)
+ ...> ]
+ ...> )
+ iex> workflow |> Workflow.plan_eagerly(15) |> Workflow.react_until_satisfied() |> Workflow.raw_productions()
+ [:large]
+
+ ## Hooks
+
+ Hooks receive `(step, workflow, fact)` and must return the workflow.
+ Use them for debugging, logging, or dynamic workflow modification:
+
+ require Runic
+ alias Runic.Workflow
+
+ Runic.workflow(
+ name: :with_hooks,
+ steps: [Runic.step(fn x -> x * 2 end, name: :double)],
+ after_hooks: [
+ double: [
+ fn _step, workflow, fact ->
+ IO.puts("Produced: \#{inspect(fact.value)}")
+ workflow
+ end
+ ]
+ ]
+ )
+
+ ## Boundary Ports
+
+ Declare input and output ports to make a workflow composable as a typed component.
+ Workflows without ports return empty contracts and are connectable to anything.
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(
+ name: :price_calculator,
+ steps: [
+ {Runic.step(fn order -> order.items end, name: :parse_order),
+ [Runic.step(fn items -> Enum.sum(Enum.map(items, & &1.price)) end, name: :calculate_total)]}
+ ],
+ input_ports: [
+ order: [type: :map, doc: "Order to price", to: :parse_order]
+ ],
+ output_ports: [
+ total: [type: :float, doc: "Calculated total", from: :calculate_total]
+ ]
+ )
+
+ # Ports are surfaced via the Component protocol
+ Runic.Component.inputs(workflow)
+ # => [order: [type: :map, doc: "Order to price", to: :parse_order]]
+
+ Port options: `:type`, `:doc`, `:cardinality`, `:required`, `:to` (input binding), `:from` (output binding).
+ The `:to` and `:from` options reference internal component names and are validated at build time.
+ """
+ def workflow(opts \\ []) do
+ name = opts[:name]
+ steps = opts[:steps]
+ rules = opts[:rules]
+ before_hooks = opts[:before_hooks]
+ after_hooks = opts[:after_hooks]
+ input_ports = opts[:input_ports]
+ output_ports = opts[:output_ports]
+
+ workflow =
+ Workflow.new(name)
+ |> Workflow.add_steps(steps)
+ |> Workflow.add_rules(rules)
+ |> Workflow.add_before_hooks(before_hooks)
+ |> Workflow.add_after_hooks(after_hooks)
+
+ set_boundary_ports(workflow, input_ports, output_ports)
+ end
+
+ defp set_boundary_ports(workflow, nil, nil), do: workflow
+
+ defp set_boundary_ports(workflow, input_ports, output_ports) do
+ workflow = if input_ports, do: %{workflow | input_ports: input_ports}, else: workflow
+ workflow = if output_ports, do: %{workflow | output_ports: output_ports}, else: workflow
+
+ validate_boundary_bindings(workflow)
+ end
+
+ defp validate_boundary_bindings(workflow) do
+ if workflow.input_ports do
+ Enum.each(workflow.input_ports, fn {port_name, port_opts} ->
+ if to = Keyword.get(port_opts, :to) do
+ unless Workflow.get_component(workflow, to) do
+ raise ArgumentError,
+ "Workflow input port #{inspect(port_name)} references component #{inspect(to)} " <>
+ "via :to, but no component with that name exists in the workflow"
+ end
+ end
+ end)
+ end
+
+ if workflow.output_ports do
+ Enum.each(workflow.output_ports, fn {port_name, port_opts} ->
+ if from = Keyword.get(port_opts, :from) do
+ unless Workflow.get_component(workflow, from) do
+ raise ArgumentError,
+ "Workflow output port #{inspect(port_name)} references component #{inspect(from)} " <>
+ "via :from, but no component with that name exists in the workflow"
+ end
+ end
+ end)
+ end
+
+ workflow
+ end
+
+ @doc """
+ Creates a `%Rule{}`: a conditional reaction for pattern-matched execution.
+
+ Rules have two phases: a **condition** (left-hand side) that must match,
+ and a **reaction** (right-hand side) that executes when matched. This
+ separation enables efficient evaluation of many rules together.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow.Rule
+ iex> rule = Runic.rule(fn x when is_integer(x) and x > 0 -> :positive end)
+ iex> Rule.check(rule, 5)
+ true
+ iex> Rule.check(rule, -1)
+ false
+ iex> Rule.run(rule, 5)
+ :positive
+
+ ## Guard Clauses
+
+ Rules support full Elixir guard expressions:
+
+ iex> require Runic
+ iex> alias Runic.Workflow.Rule
+ iex> rule = Runic.rule(fn x when is_binary(x) and byte_size(x) > 5 -> :long_string end)
+ iex> Rule.check(rule, "hello!")
+ true
+ iex> Rule.check(rule, "hi")
+ false
+
+ ## Pattern Matching
+
+ iex> require Runic
+ iex> alias Runic.Workflow.Rule
+ iex> rule = Runic.rule(fn %{status: :pending} -> :process end)
+ iex> Rule.check(rule, %{status: :pending, id: 1})
+ true
+ iex> Rule.check(rule, %{status: :done})
+ false
+
+ ## Separated Condition and Reaction
+
+ For expensive conditions shared by multiple rules, or clearer organization:
+
+ iex> require Runic
+ iex> alias Runic.Workflow.Rule
+ iex> rule = Runic.rule(
+ ...> name: :expensive_check,
+ ...> condition: fn x -> rem(x, 2) == 0 end,
+ ...> reaction: fn x -> x * 2 end
+ ...> )
+ iex> Rule.check(rule, 4)
+ true
+ iex> Rule.run(rule, 4)
+ 8
+
+ Also supports `:if` / `:do` aliases:
+
+ Runic.rule(
+ name: :my_rule,
+ if: fn x -> x > 10 end,
+ do: fn x -> :large end
+ )
+
+ ## Multi-Arity Rules
+
+ Rules can require multiple inputs (provided as a list):
+
+ iex> require Runic
+ iex> alias Runic.Workflow.Rule
+ iex> rule = Runic.rule(fn a, b when is_integer(a) and is_integer(b) -> a + b end)
+ iex> Rule.check(rule, [3, 4])
+ true
+ iex> Rule.run(rule, [3, 4])
+ 7
+
+ ## In Workflows
+
+ Rules are evaluated in the planning/match phase before execution:
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(
+ ...> name: :classifier,
+ ...> rules: [
+ ...> Runic.rule(fn x when x > 100 -> :xlarge end, name: :xlarge),
+ ...> Runic.rule(fn x when x > 10 and x <= 100 -> :large end, name: :large),
+ ...> Runic.rule(fn x when x > 0 and x <= 10 -> :small end, name: :small)
+ ...> ]
+ ...> )
+ iex> workflow |> Workflow.plan_eagerly(50) |> Workflow.react_until_satisfied() |> Workflow.raw_productions()
+ [:large]
+
+ ## Given/Where/Then DSL
+
+ For complex rules with pattern destructuring, use the explicit DSL with
+ three clauses:
+
+ - **`given`** — a pattern-matching clause that destructures the input and binds
+ variables. The binding name becomes the key in the bindings map passed to `then`.
+ - **`where`** — a boolean expression evaluated at runtime. Unlike `when` guards,
+ `where` supports **any** Elixir expression including function calls like
+ `String.starts_with?/2`, `Enum.member?/2`, etc.
+ - **`then`** — a function that receives the bindings map and produces a result.
+ The bindings from `given` are available as keys.
+
+ Note: Use `where` instead of `when` because `when` is a reserved Elixir keyword.
+
+ ### Do/End Block Form
+
+ require Runic
+
+ Runic.rule do
+ given(order: %{status: status, total: total})
+ where(status == :pending and total > 100)
+ then(fn %{order: order, total: total} -> {:apply_discount, order, total * 0.9} end)
+ end
+
+ ### Named Do/End Block Form
+
+ Pass options before the `do` block to name the rule:
+
+ require Runic
+
+ Runic.rule name: :premium_discount do
+ given(order: %{customer: %{tier: tier}, total: total})
+ where(tier == :premium and total > 50)
+ then(fn %{order: order} -> {:apply_discount, order} end)
+ end
+
+ ### Keyword Form
+
+ The same rule can be written as a keyword list:
+
+ require Runic
+
+ Runic.rule(
+ name: :threshold_check,
+ given: [value: v],
+ where: v > 100,
+ then: fn %{value: v} -> {:over_threshold, v} end
+ )
+
+ Direct map patterns are also supported in the keyword form:
+
+ Runic.rule(
+ name: :map_pattern,
+ given: %{x: x, y: y},
+ where: x + y > 10,
+ then: fn %{x: x, y: y} -> {:sum, x + y} end
+ )
+
+ ### Non-Guard Expressions in `where`
+
+ Unlike `when` guards, `where` supports any boolean expression:
+
+ require Runic
+
+ Runic.rule do
+ given(name: name)
+ where(String.starts_with?(name, "prefix_"))
+ then(fn %{name: n} -> {:matched, n} end)
+ end
+
+ ### Capturing External Variables with `^`
+
+ Use the pin operator `^` to capture variables from the surrounding scope.
+ This is essential when dynamically constructing rules in loops or functions:
+
+ require Runic
+ alias Runic.Workflow
+
+ threshold = 100
+
+ rule =
+ Runic.rule do
+ given(value: v)
+ where(v > ^threshold)
+ then(fn %{value: v} -> {:over_threshold, v} end)
+ end
+
+ Workflow.new()
+ |> Workflow.add(rule)
+ |> Workflow.react_until_satisfied(150)
+ |> Workflow.raw_productions()
+ # => [{:over_threshold, 150}]
+
+ The `^` pin also works in the keyword form and in `condition`/`reaction` style:
+
+ some_values = [:potato, :ham, :tomato]
+
+ Runic.rule(
+ name: "escaped rule",
+ condition: fn val when is_atom(val) -> true end,
+ reaction: fn val ->
+ Enum.map(^some_values, fn x -> {val, x} end)
+ end
+ )
+ """
+ # Handle rule with do block containing given/when/then DSL
+ defmacro rule(opts_or_block)
+
+ defmacro rule([{:do, block}]) do
+ compile_given_when_then_rule(block, [], __CALLER__)
+ end
+
+ defmacro rule(opts) when is_list(opts) do
+ # Phase 3: Detect given/where/then keys and transform to DSL form
+ has_given_where_then = Keyword.has_key?(opts, :given) or Keyword.has_key?(opts, :then)
+
+ has_condition_reaction =
+ Keyword.has_key?(opts, :condition) or Keyword.has_key?(opts, :if) or
+ Keyword.has_key?(opts, :reaction) or Keyword.has_key?(opts, :do)
+
+ # Error if mixing styles
+ if has_given_where_then and has_condition_reaction do
+ raise ArgumentError, """
+ Cannot mix given/where/then style with condition/reaction style in rule macro.
+
+ Use either:
+ Runic.rule(given: pattern, where: condition, then: action)
+ Or:
+ Runic.rule(condition: fn -> ... end, reaction: fn -> ... end)
+ """
+ end
+
+ if has_given_where_then do
+ # Transform keyword opts to synthetic DSL block AST
+ compile_keyword_given_when_then_rule(opts, __CALLER__)
+ else
+ compile_condition_reaction_rule(opts, __CALLER__)
+ end
+ end
+
+ # Catch-all for anonymous function expressions: rule(fn x -> ... end)
+ defmacro rule(expression) do
+ arity = Components.arity_of(expression)
+ {rewritten_expression, expression_bindings} = traverse_expression(expression, __CALLER__)
+
+ variable_bindings =
+ expression_bindings
+ |> Enum.uniq()
+
+ # Validate no meta expressions in guard position (not supported in fn-form rules)
+ validate_no_meta_in_fn_guard!(expression, __CALLER__)
+
+ # Detect meta expressions only in the fn body (not guards/patterns)
+ reaction_body = extract_fn_body(expression)
+ reaction_meta_refs = detect_meta_expressions(reaction_body)
+
+ {workflow, condition_hash, reaction_hash} =
+ if reaction_meta_refs != [] do
+ {final_reaction, _refs} =
+ maybe_compile_meta_work(expression, rewritten_expression, __CALLER__)
+
+ workflow_of_rule_fn_with_meta(
+ rewritten_expression,
+ arity,
+ final_reaction,
+ reaction_meta_refs
+ )
+ else
+ workflow_of_rule(rewritten_expression, arity)
+ end
+
+ source =
+ quote do
+ Runic.rule(unquote(expression))
+ end
+
+ closure_source =
+ quote do
+ Runic.rule(unquote(rewritten_expression))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ if Enum.empty?(variable_bindings) do
+ rule_hash = Components.fact_hash(source)
+ rule_name = default_component_name("rule", rule_hash)
+
+ quote do
+ %Rule{
+ name: unquote(rule_name),
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ hash: unquote(rule_hash),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ closure: unquote(closure)
+ }
+ end
+ else
+ quote do
+ closure = unquote(closure)
+ rule_hash = closure.hash
+ rule_name = unquote(default_component_name("rule", "_")) <> "_#{rule_hash}"
+
+ %Rule{
+ name: rule_name,
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ hash: rule_hash,
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ closure: closure
+ }
+ end
+ end
+ end
+
+ # Original condition/reaction keyword form
+ defp compile_condition_reaction_rule(opts, env) do
+ {rewritten_opts, opts_bindings} = traverse_options(opts, env)
+
+ validate_port_schema(rewritten_opts[:inputs], "rule")
+ validate_port_schema(rewritten_opts[:outputs], "rule")
+
+ name = rewritten_opts[:name]
+ condition = rewritten_opts[:condition] || rewritten_opts[:if]
+ reaction = rewritten_opts[:reaction] || rewritten_opts[:do]
+
+ arity = Components.arity_of(reaction)
+
+ {rewritten_reaction, reaction_bindings} = traverse_expression(reaction, env)
+
+ variable_bindings =
+ (reaction_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ # Detect meta expressions in condition and reaction
+ condition_meta_refs = detect_meta_expressions(condition)
+ reaction_meta_refs = detect_meta_expressions(reaction)
+ has_meta = condition_meta_refs != [] or reaction_meta_refs != []
+
+ # If meta expressions found, rewrite work functions to arity-2
+ {final_condition, condition_meta_refs} =
+ if condition_meta_refs != [] do
+ {rewritten, _refs} = maybe_compile_meta_work(condition, condition, env)
+ {rewritten, condition_meta_refs}
+ else
+ {condition, []}
+ end
+
+ {final_reaction, reaction_meta_refs} =
+ if reaction_meta_refs != [] do
+ {rewritten, _refs} = maybe_compile_meta_work(reaction, rewritten_reaction, env)
+ {rewritten, reaction_meta_refs}
+ else
+ {rewritten_reaction, []}
+ end
+
+ condition_arity = if condition_meta_refs != [], do: 2, else: arity
+
+ {workflow, condition_hash, reaction_hash} =
+ if has_meta do
+ workflow_of_rule_with_meta(
+ {final_condition, final_reaction},
+ arity,
+ condition_arity,
+ condition_meta_refs,
+ reaction_meta_refs
+ )
+ else
+ workflow_of_rule({condition, rewritten_reaction}, arity)
+ end
+
+ source =
+ quote do
+ Runic.rule(unquote(opts))
+ end
+
+ # Build closure with full rule creation
+ closure_source =
+ quote do
+ Runic.rule(unquote(rewritten_opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, env)
+
+ if Enum.empty?(variable_bindings) do
+ rule_hash = Components.fact_hash(source)
+ rule_name = name || default_component_name("rule", rule_hash)
+
+ quote do
+ %Rule{
+ name: unquote(rule_name),
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ hash: unquote(rule_hash),
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ else
+ base_name = name
+
+ quote do
+ closure = unquote(closure)
+ rule_hash = closure.hash
+
+ rule_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("rule", "_")) <> "_#{rule_hash}"
+
+ %Rule{
+ name: rule_name,
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ hash: rule_hash,
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ end
+ end
+
+ # Phase 3: Compile keyword form with given/where/then keys
+ defp compile_keyword_given_when_then_rule(opts, env) do
+ # Extract given/where/then from opts
+ given_expr = Keyword.get(opts, :given)
+ where_expr = Keyword.get(opts, :where, true)
+ then_expr = Keyword.get(opts, :then)
+
+ unless then_expr do
+ raise ArgumentError,
+ "rule with given/where/then style requires a `:then` key with an action function"
+ end
+
+ # Build synthetic block AST for compile_given_when_then_rule
+ # The block looks like: {:__block__, [], [given(...), where(...), then(...)]}
+ statements = []
+
+ statements =
+ if given_expr do
+ [{:given, [], [given_expr]} | statements]
+ else
+ statements
+ end
+
+ statements =
+ if where_expr != true do
+ [{:where, [], [where_expr]} | statements]
+ else
+ statements
+ end
+
+ statements = [{:then, [], [then_expr]} | statements]
+
+ block = {:__block__, [], Enum.reverse(statements)}
+
+ # Extract non-DSL options (name, inputs, outputs)
+ preserved_opts = Keyword.drop(opts, [:given, :where, :then])
+
+ compile_given_when_then_rule(block, preserved_opts, env)
+ end
+
+ # Handle rule name: :foo do given/when/then end
+ defmacro rule(opts, [{:do, block}]) when is_list(opts) do
+ compile_given_when_then_rule(block, opts, __CALLER__)
+ end
+
+ defmacro rule(expression, opts) when is_list(opts) do
+ {rewritten_opts, opts_bindings} = traverse_options(opts, __CALLER__)
+
+ name = rewritten_opts[:name]
+ arity = Components.arity_of(expression)
+
+ # Process the expression to extract pinned variables
+ {rewritten_expression, expression_bindings} = traverse_expression(expression, __CALLER__)
+
+ variable_bindings =
+ (expression_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ # Validate no meta expressions in guard position (not supported in fn-form rules)
+ validate_no_meta_in_fn_guard!(expression, __CALLER__)
+
+ # Detect meta expressions only in the fn body (not guards/patterns)
+ reaction_body = extract_fn_body(expression)
+ reaction_meta_refs = detect_meta_expressions(reaction_body)
+
+ {workflow, condition_hash, reaction_hash} =
+ if reaction_meta_refs != [] do
+ {final_reaction, _refs} =
+ maybe_compile_meta_work(expression, rewritten_expression, __CALLER__)
+
+ workflow_of_rule_fn_with_meta(
+ rewritten_expression,
+ arity,
+ final_reaction,
+ reaction_meta_refs
+ )
+ else
+ workflow_of_rule(rewritten_expression, arity)
+ end
+
+ source =
+ quote do
+ Runic.rule(unquote(expression), unquote(opts))
+ end
+
+ # Build closure source with full rule creation
+ closure_source =
+ quote do
+ Runic.rule(unquote(rewritten_expression), unquote(rewritten_opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ # If we have bindings, compute hash at runtime
+ # Otherwise compute at compile time
+ if Enum.empty?(variable_bindings) do
+ rule_hash = Components.fact_hash(source)
+ rule_name = name || default_component_name("rule", rule_hash)
+
+ quote do
+ %Rule{
+ name: unquote(rule_name),
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ hash: unquote(rule_hash),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ else
+ base_name = name
+
+ quote do
+ closure = unquote(closure)
+ rule_hash = closure.hash
+
+ rule_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("rule", "_")) <> "_#{rule_hash}"
+
+ %Rule{
+ name: rule_name,
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ hash: rule_hash,
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ end
+ end
+
+ defp traverse_expression({:fn, meta, clauses}, env) do
+ # Collect argument names from all clauses
+ # all_arg_vars =
+ # Enum.flat_map(clauses, fn {:->, _meta, [args, _block]} ->
+ # args
+ # |> List.flatten()
+ # |> Enum.flat_map(fn arg -> collect_pattern_vars(arg) end)
+ # end)
+ # |> MapSet.new()
+
+ {rewritten_clauses, bindings_acc} =
+ Enum.reduce(clauses, {[], []}, fn
+ {:->, clause_meta, [args, block]}, {clauses_acc, bindings_acc} ->
+ # Collect variables assigned in the body (these are local, not free)
+ # body_assigned_vars = collect_assigned_vars(block)
+ # local_vars = MapSet.union(all_arg_vars, body_assigned_vars)
+
+ # Process each clause, capturing pinned vars and warning about unpinned
+ {new_block, new_bindings} =
+ Macro.prewalk(block, bindings_acc, fn
+ {:^, pin_meta, [{var, _, ctx} = expr]} = _pinned_ast, acc ->
+ # Pinned variable - capture it
+ new_var = Macro.var(var, ctx)
+ {new_var, [{:=, pin_meta, [new_var, expr]} | acc]}
+
+ otherwise, acc ->
+ {Macro.expand(otherwise, env), acc}
+ end)
+
+ new_clause = {:->, clause_meta, [args, new_block]}
+ {[new_clause | clauses_acc], new_bindings}
+ end)
+
+ rewritten_expression = {:fn, meta, Enum.reverse(rewritten_clauses)}
+ {rewritten_expression, bindings_acc}
+ end
+
+ defp traverse_expression({:&, _, _} = expression, _env) do
+ {expression, []}
+ end
+
+ # Fallback for arbitrary expressions (e.g., where clauses with boolean expressions)
+ # Phase 1: Support pin operators in any expression, not just function bodies
+ defp traverse_expression(expression, env) do
+ traverse_any_expression(expression, env)
+ end
+
+ # Traverse any AST to find and rewrite pinned variables (^var syntax)
+ # Returns {rewritten_ast, bindings} where bindings are assignments to capture pinned vars
+ defp traverse_any_expression(ast, env) do
+ {rewritten_ast, bindings} =
+ Macro.prewalk(ast, [], fn
+ {:^, pin_meta, [{var, _, ctx} = _expr]} = _pinned_ast, acc ->
+ # Pinned variable - capture it
+ new_var = Macro.var(var, ctx)
+ {new_var, [{:=, pin_meta, [new_var, {var, [], ctx}]} | acc]}
+
+ otherwise, acc ->
+ {Macro.expand(otherwise, env), acc}
+ end)
+
+ {rewritten_ast, bindings}
+ end
+
+ # Traverse keyword options looking for pinned variables (^var syntax)
+ defp traverse_options(opts, _env) when is_list(opts) do
+ {rewritten_opts, bindings_acc} =
+ Enum.reduce(opts, {[], []}, fn
+ {key, {:^, pin_meta, [{var, _, ctx} = expr]}}, {opts_acc, bindings_acc} ->
+ new_var = Macro.var(var, ctx)
+ binding_assignment = {:=, pin_meta, [new_var, expr]}
+ new_opt = {key, new_var}
+ {[new_opt | opts_acc], [binding_assignment | bindings_acc]}
+
+ {key, {var, meta, ctx} = expr}, {opts_acc, bindings_acc}
+ when is_atom(var) and is_atom(ctx) and var != :_ ->
+ new_var = Macro.var(var, ctx)
+ binding_assignment = {:=, meta, [new_var, expr]}
+ {[{key, new_var} | opts_acc], [binding_assignment | bindings_acc]}
+
+ {key, value}, {opts_acc, bindings_acc} ->
+ {[{key, value} | opts_acc], bindings_acc}
+ end)
+
+ {Enum.reverse(rewritten_opts), bindings_acc}
+ end
+
+ defp traverse_options(opts, _env), do: {opts, []}
+
+ @doc """
+ Creates a `%StateMachine{}`: stateful workflows with reducers and conditional reactors.
+
+ State machines combine an accumulator with rules that react to state changes.
+ The reducer processes inputs in context of accumulated state, and reactors
+ conditionally execute based on the new state.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> counter = Runic.state_machine(
+ ...> name: :counter,
+ ...> init: 0,
+ ...> reducer: fn x, acc -> acc + x end
+ ...> )
+ iex> workflow = Workflow.new() |> Workflow.add(counter)
+ iex> results = workflow |> Workflow.plan_eagerly(5) |> Workflow.react_until_satisfied() |> Workflow.raw_productions()
+ iex> Enum.sort(results)
+ [0, 5]
+
+ ## Options
+
+ - `:name` - Identifier for the state machine (required for referencing)
+ - `:init` - Initial state (literal value, function, or `{M, F, A}` tuple)
+ - `:reducer` - Function `(input, state) -> new_state` for state transitions
+ - `:reactors` - List of rules that react to state changes
+ - `:inputs` / `:outputs` - Reserved for future schema-based type compatibility
+
+ ## With Reactors
+
+ Reactors are rules that fire when the accumulated state matches their conditions:
+
+ require Runic
+ alias Runic.Workflow
+
+ threshold_sm = Runic.state_machine(
+ name: :threshold_monitor,
+ init: 0,
+ reducer: fn x, acc -> acc + x end,
+ reactors: [
+ fn state when state > 100 -> :threshold_exceeded end,
+ fn state when state > 50 -> :warning end
+ ]
+ )
+
+ ## Lock/Unlock Example
+
+ require Runic
+
+ lock = Runic.state_machine(
+ name: :lock,
+ init: %{code: "secret", state: :locked},
+ reducer: fn
+ :lock, state ->
+ %{state | state: :locked}
+ {:unlock, code}, %{code: code, state: :locked} = state ->
+ %{state | state: :unlocked}
+ _, state ->
+ state
+ end,
+ reactors: [
+ fn %{state: :unlocked} -> :access_granted end,
+ fn %{state: :locked} -> :access_denied end
+ ]
+ )
+
+ ## Block DSL with `handle`/`react` (Form 2)
+
+ For state machines with complex state and event-driven transitions, the
+ block DSL provides a more expressive form. Each `handle` clause bundles
+ an event match, input pattern, state binding, and state transformation
+ into a named, addressable sub-component. `react` clauses observe state
+ without modifying it.
+
+ Runic.state_machine name: :cart, init: %{items: [], total: 0} do
+ handle :add_item, %{item: item}, state do
+ %{state | items: [item | state.items], total: state.total + item.price}
+ end
+
+ handle :checkout, _, state when state.items != [] do
+ %{state | status: :checked_out}
+ end
+
+ react :high_value do
+ fn %{total: t} when t > 1000 -> {:vip_alert, t} end
+ end
+ end
+
+ ### `handle` clause semantics
+
+ handle event_pattern, input_match, state_var [when state_guard] do
+ body # must return next state
+ end
+
+ - `event_pattern` — atom or pattern matched against the incoming fact's
+ event type discriminator.
+ - `input_match` — pattern match on the event payload / fact value.
+ - `state_var` — binds the current state via `state_of(:sm_name)` meta_ref.
+ - `when state_guard` — optional guard on current state.
+ - `body` — returns the next state value, fed to the accumulator.
+
+ Each `handle` compiles to a named Rule:
+ `:"_"` (e.g., `:cart_add_item`).
+
+ ### `react` clause semantics
+
+ react name do
+ fn state_pattern -> output end
+ end
+
+ - Name is explicitly required (the atom after `react`).
+ - Compiles to a Rule with a `state_of()` condition and a step that
+ produces an output fact.
+ - Does **not** modify state — observation only.
+
+ Both forms produce identical `%StateMachine{}` structs. The `handle`
+ block is sugar for splitting a multi-clause reducer into individually
+ named rules.
+
+ ## Captured Variables
+
+ Use `^` for runtime values in reducers and reactors:
+
+ multiplier = 2
+ Runic.state_machine(
+ name: :scaled_sum,
+ init: 0,
+ reducer: fn x, acc -> acc + x * ^multiplier end
+ )
+ """
+ defmacro state_machine(opts) do
+ {rewritten_opts, opts_bindings} = traverse_options(opts, __CALLER__)
+
+ init = rewritten_opts[:init] || raise ArgumentError, "An `init` function or state is required"
+
+ reducer =
+ Keyword.get(rewritten_opts, :reducer) ||
+ raise ArgumentError, "A reducer function is required"
+
+ reactors = rewritten_opts[:reactors]
+
+ name = rewritten_opts[:name]
+
+ inputs = validate_port_schema(rewritten_opts[:inputs], "state_machine")
+ outputs = validate_port_schema(rewritten_opts[:outputs], "state_machine")
+
+ {rewritten_reducer, reducer_bindings} = traverse_expression(reducer, __CALLER__)
+
+ # Detect meta expressions in reducer for accumulator
+ {final_reducer, reducer_meta_refs} =
+ maybe_compile_meta_reducer(reducer, rewritten_reducer, __CALLER__)
+
+ escaped_reducer_meta_refs = escape_meta_refs(reducer_meta_refs)
+
+ variable_bindings =
+ (reducer_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ bindings = build_bindings(variable_bindings, __CALLER__)
+
+ state_machine_hash = Components.fact_hash({init, rewritten_reducer, reactors})
+ state_machine_name = name || default_component_name("state_machine", state_machine_hash)
+
+ # Build the accumulator AST
+ accumulator_ast =
+ build_state_machine_accumulator(
+ init,
+ final_reducer,
+ state_machine_name,
+ escaped_reducer_meta_refs
+ )
+
+ # Build reactor rules AST
+ reactor_rules_ast = build_reactor_rules(reactors, state_machine_name, __CALLER__)
+
+ # Build derived workflow AST
+ workflow_ast =
+ build_state_machine_workflow(accumulator_ast, reactor_rules_ast, state_machine_name)
+
+ source =
+ quote do
+ Runic.state_machine(unquote(opts))
+ end
+
+ quote do
+ unquote_splicing(Enum.reverse(variable_bindings))
+
+ sm_accumulator = unquote(accumulator_ast)
+ sm_reactor_rules = unquote(reactor_rules_ast)
+
+ %StateMachine{
+ name: unquote(state_machine_name),
+ init: unquote(init),
+ reducer: unquote(rewritten_reducer),
+ reactors: unquote(reactors),
+ accumulator: sm_accumulator,
+ reactor_rules: sm_reactor_rules,
+ workflow: unquote(workflow_ast),
+ source: unquote(Macro.escape(source)),
+ hash: unquote(state_machine_hash),
+ bindings: unquote(bindings),
+ inputs: unquote(inputs),
+ outputs: unquote(outputs)
+ }
+ end
+ end
+
+ @doc """
+ Creates a `%Saga{}`: a sequence of transaction steps with compensating actions.
+
+ Sagas are explicit forward-then-compensate pipelines. Each transaction step
+ must have a corresponding compensate block. On failure, completed steps are
+ compensated in reverse order.
+
+ Compiles to an Accumulator (tracking saga state), forward Rules (one per
+ transaction step), and compensation Rules (one per compensate block).
+
+ ## Usage
+
+ require Runic
+
+ saga = Runic.saga name: :fulfillment do
+ transaction :reserve_inventory do
+ fn _input -> {:ok, :reserved} end
+ end
+ compensate :reserve_inventory do
+ fn %{reserve_inventory: _} -> :released end
+ end
+
+ transaction :charge_payment do
+ fn %{reserve_inventory: _} -> {:ok, :charged} end
+ end
+ compensate :charge_payment do
+ fn %{charge_payment: _} -> :refunded end
+ end
+
+ on_complete fn results -> {:saga_completed, results} end
+ on_abort fn reason, compensated -> {:saga_aborted, reason, compensated} end
+ end
+ """
+ defmacro saga(opts \\ [], do: block) do
+ {name, opts_rest} = Keyword.pop(opts, :name)
+ inputs = Keyword.get(opts_rest, :inputs)
+ outputs = Keyword.get(opts_rest, :outputs)
+
+ unless name, do: raise(ArgumentError, "Saga requires a :name option")
+
+ {transactions, compensations, on_complete, on_abort} = parse_saga_block(block)
+
+ transaction_names = Enum.map(transactions, fn {n, _} -> n end)
+
+ Enum.each(transaction_names, fn tx_name ->
+ unless Enum.any?(compensations, fn {n, _} -> n == tx_name end) do
+ raise ArgumentError,
+ "Saga #{inspect(name)}: transaction #{inspect(tx_name)} has no corresponding compensate block"
+ end
+ end)
+
+ compensation_names = Enum.map(compensations, fn {n, _} -> n end)
+
+ saga_hash =
+ Components.fact_hash(
+ {name, transaction_names, compensation_names, on_complete != nil, on_abort != nil}
+ )
+
+ step_names = transaction_names
+
+ init_ast = build_saga_init(step_names)
+
+ reducer_ast = build_saga_reducer_with_steps(transactions, compensations, step_names)
+
+ accumulator_ast = build_saga_accumulator(init_ast, reducer_ast, name)
+
+ terminal_rules_ast = build_saga_terminal_rules(on_complete, on_abort, name)
+
+ workflow_ast =
+ build_saga_workflow_simple(accumulator_ast, terminal_rules_ast, name)
+
+ source =
+ quote do
+ Runic.saga(unquote(opts), do: unquote(Macro.escape(block)))
+ end
+
+ quote do
+ saga_accumulator = unquote(accumulator_ast)
+ saga_terminal_rules = unquote(terminal_rules_ast)
+
+ %Saga{
+ name: unquote(name),
+ steps: unquote(Macro.escape(Enum.zip(transactions, compensations))),
+ on_complete: unquote(Macro.escape(on_complete)),
+ on_abort: unquote(Macro.escape(on_abort)),
+ accumulator: saga_accumulator,
+ forward_rules: saga_terminal_rules,
+ compensation_rules: [],
+ workflow: unquote(workflow_ast),
+ source: unquote(Macro.escape(source)),
+ hash: unquote(saga_hash),
+ inputs: unquote(inputs),
+ outputs: unquote(outputs)
+ }
+ end
+ end
+
+ defp parse_saga_block({:__block__, _, exprs}), do: parse_saga_exprs(exprs)
+ defp parse_saga_block(single_expr), do: parse_saga_exprs([single_expr])
+
+ defp parse_saga_exprs(exprs) do
+ transactions =
+ Enum.flat_map(exprs, fn
+ {:transaction, _, [step_name, [do: body]]} -> [{step_name, body}]
+ _ -> []
+ end)
+
+ compensations =
+ Enum.flat_map(exprs, fn
+ {:compensate, _, [step_name, [do: body]]} -> [{step_name, body}]
+ _ -> []
+ end)
+
+ on_complete =
+ Enum.find_value(exprs, fn
+ {:on_complete, _, [fn_ast]} -> fn_ast
+ _ -> nil
+ end)
+
+ on_abort =
+ Enum.find_value(exprs, fn
+ {:on_abort, _, [fn_ast]} -> fn_ast
+ _ -> nil
+ end)
+
+ {transactions, compensations, on_complete, on_abort}
+ end
+
+ defp build_saga_init(step_names) do
+ first_step = List.first(step_names)
+
+ quote do
+ fn ->
+ %{
+ status: :pending,
+ current_step: unquote(first_step),
+ results: %{},
+ failure_reason: nil,
+ compensated: [],
+ step_order: unquote(step_names)
+ }
+ end
+ end
+ end
+
+ defp build_saga_reducer_with_steps(transactions, compensations, _step_names) do
+ tx_map_entries =
+ Enum.map(transactions, fn {step_name, body} ->
+ quote do
+ {unquote(step_name), unquote(body)}
+ end
+ end)
+
+ comp_map_entries =
+ Enum.map(compensations, fn {step_name, body} ->
+ quote do
+ {unquote(step_name), unquote(body)}
+ end
+ end)
+
+ quote generated: true do
+ fn _input, state ->
+ tx_fns = Map.new([unquote_splicing(tx_map_entries)])
+ comp_fns = Map.new([unquote_splicing(comp_map_entries)])
+
+ run_saga_steps = fn run_saga_steps, current_state ->
+ case current_state.status do
+ status when status in [:pending, :running] ->
+ step_name = current_state.current_step
+
+ if step_name == nil do
+ current_state
+ else
+ tx_fn = Map.get(tx_fns, step_name)
+
+ if tx_fn do
+ result =
+ try do
+ tx_fn.(current_state.results)
+ rescue
+ e -> {:error, e}
+ end
+
+ next_state =
+ case result do
+ {:ok, value} ->
+ new_results = Map.put(current_state.results, step_name, value)
+ step_order = current_state.step_order
+ current_idx = Enum.find_index(step_order, &(&1 == step_name))
+ next_idx = if current_idx, do: current_idx + 1, else: nil
+
+ if next_idx && next_idx < length(step_order) do
+ next_step = Enum.at(step_order, next_idx)
+
+ %{
+ current_state
+ | results: new_results,
+ current_step: next_step,
+ status: :running
+ }
+ else
+ %{
+ current_state
+ | results: new_results,
+ current_step: nil,
+ status: :completed
+ }
+ end
+
+ {:error, reason} ->
+ %{
+ current_state
+ | status: :compensating,
+ failure_reason: {step_name, reason},
+ current_step: nil
+ }
+
+ other ->
+ new_results = Map.put(current_state.results, step_name, other)
+ step_order = current_state.step_order
+ current_idx = Enum.find_index(step_order, &(&1 == step_name))
+ next_idx = if current_idx, do: current_idx + 1, else: nil
+
+ if next_idx && next_idx < length(step_order) do
+ next_step = Enum.at(step_order, next_idx)
+
+ %{
+ current_state
+ | results: new_results,
+ current_step: next_step,
+ status: :running
+ }
+ else
+ %{
+ current_state
+ | results: new_results,
+ current_step: nil,
+ status: :completed
+ }
+ end
+ end
+
+ run_saga_steps.(run_saga_steps, next_state)
+ else
+ current_state
+ end
+ end
+
+ :compensating ->
+ completed_step_names = Map.keys(current_state.results)
+
+ to_compensate =
+ Enum.filter(completed_step_names, &(&1 not in current_state.compensated))
+
+ compensated_state =
+ Enum.reduce(to_compensate, current_state, fn sn, acc_state ->
+ comp_fn = Map.get(comp_fns, sn)
+
+ if comp_fn do
+ try do
+ comp_fn.(acc_state.results)
+ rescue
+ _ -> :compensation_error
+ end
+ end
+
+ new_compensated = [sn | acc_state.compensated]
+ all_done = Enum.all?(completed_step_names, &(&1 in new_compensated))
+
+ if all_done do
+ %{acc_state | compensated: new_compensated, status: :aborted}
+ else
+ %{acc_state | compensated: new_compensated}
+ end
+ end)
+
+ compensated_state
+
+ _ ->
+ current_state
+ end
+ end
+
+ run_saga_steps.(run_saga_steps, state)
+ end
+ end
+ end
+
+ defp build_saga_accumulator(init_ast, reducer_ast, saga_name) do
+ acc_hash = Components.fact_hash({init_ast, reducer_ast, saga_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(init_ast),
+ reducer: unquote(reducer_ast),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(saga_name)}_accumulator",
+ meta_refs: []
+ }
+ end
+ end
+
+ defp build_saga_terminal_rules(on_complete, on_abort, saga_name) do
+ rules = []
+
+ rules =
+ if on_complete do
+ rules ++ [build_saga_on_complete_rule(on_complete, saga_name)]
+ else
+ rules
+ end
+
+ rules =
+ if on_abort do
+ rules ++ [build_saga_on_abort_rule(on_abort, saga_name)]
+ else
+ rules
+ end
+
+ quote do
+ [unquote_splicing(rules)]
+ end
+ end
+
+ defp build_saga_on_complete_rule(on_complete_fn, saga_name) do
+ acc_ref = :__saga_accumulator__
+
+ condition_fn =
+ quote generated: true do
+ fn _input ->
+ saga_state = state_of(unquote(acc_ref))
+ saga_state.status == :completed
+ end
+ end
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, saga_name, :on_complete})
+
+ reaction_fn =
+ quote generated: true do
+ fn _input ->
+ saga_state = state_of(unquote(acc_ref))
+ handler = unquote(on_complete_fn)
+ handler.(saga_state.results)
+ end
+ end
+
+ reaction_meta_refs = detect_meta_expressions(reaction_fn)
+ rewritten_reaction = rewrite_meta_refs_in_ast(reaction_fn, reaction_meta_refs)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ final_reaction =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_reaction).(input)
+ end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, saga_name, :on_complete})
+
+ quote generated: true do
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(final_reaction),
+ hash: unquote(reaction_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+
+ rule_name = :"#{unquote(saga_name)}_on_complete"
+
+ rule_workflow =
+ Workflow.new(rule_name)
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: rule_name,
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ defp build_saga_on_abort_rule(on_abort_fn, saga_name) do
+ acc_ref = :__saga_accumulator__
+
+ condition_fn =
+ quote generated: true do
+ fn _input ->
+ saga_state = state_of(unquote(acc_ref))
+ saga_state.status == :aborted
+ end
+ end
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, saga_name, :on_abort})
+
+ reaction_fn =
+ quote generated: true do
+ fn _input ->
+ saga_state = state_of(unquote(acc_ref))
+ handler = unquote(on_abort_fn)
+ handler.(saga_state.failure_reason, saga_state.compensated)
+ end
+ end
+
+ reaction_meta_refs = detect_meta_expressions(reaction_fn)
+ rewritten_reaction = rewrite_meta_refs_in_ast(reaction_fn, reaction_meta_refs)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ final_reaction =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_reaction).(input)
+ end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, saga_name, :on_abort})
+
+ quote generated: true do
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(final_reaction),
+ hash: unquote(reaction_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+
+ rule_name = :"#{unquote(saga_name)}_on_abort"
+
+ rule_workflow =
+ Workflow.new(rule_name)
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: rule_name,
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ defp build_saga_workflow_simple(accumulator_ast, terminal_rules_ast, saga_name) do
+ quote generated: true do
+ acc = unquote(accumulator_ast)
+ terminal_rules = unquote(terminal_rules_ast)
+
+ base_wrk =
+ Workflow.new(unquote(saga_name))
+ |> Workflow.add_step(acc)
+ |> Workflow.register_component(acc)
+
+ Enum.reduce(terminal_rules, base_wrk, fn rule, wrk ->
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(acc, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ end)
+ end
+ end
+
+ @doc """
+ Creates an `%Aggregate{}`: a CQRS/ES aggregate that validates commands against
+ current state, produces domain events, and folds events into state.
+
+ Compiles to an Accumulator (event fold) plus Rules (command handlers).
+
+ ## Usage
+
+ require Runic
+
+ agg = Runic.aggregate name: :counter do
+ state 0
+
+ command :increment do
+ emit fn _state -> {:incremented, 1} end
+ end
+
+ command :decrement do
+ where fn state -> state > 0 end
+ emit fn _state -> {:decremented, 1} end
+ end
+
+ event {:incremented, n}, state do
+ state + n
+ end
+
+ event {:decremented, n}, state do
+ state - n
+ end
+ end
+
+ ## Options
+
+ - `:name` - Identifier for the aggregate (required)
+
+ ## DSL
+
+ - `state initial_value` - Sets the initial aggregate state
+ - `command :name do ... end` - Defines a command handler
+ - `where fn state -> bool end` - Optional guard on current state
+ - `emit fn state -> event end` - Produces domain events
+ - `event pattern, state_var do body end` - Defines an event handler (reducer clause)
+ """
+ defmacro aggregate(opts, [{:do, block}]) when is_list(opts) do
+ name = Keyword.get(opts, :name)
+
+ unless name, do: raise(ArgumentError, "Aggregate requires a :name option")
+ unless block, do: raise(ArgumentError, "Aggregate requires a do block")
+
+ {initial_state, command_handlers, event_handlers} = parse_aggregate_block(block)
+
+ # Strip AST metadata for deterministic hashing regardless of source location
+ clean_for_hash = fn ast ->
+ Macro.prewalk(ast, fn
+ {name, _meta, ctx} when is_atom(name) -> {name, [], ctx}
+ other -> other
+ end)
+ end
+
+ clean_cmd_handlers =
+ Enum.map(command_handlers, fn {n, w, e} ->
+ {n, w && clean_for_hash.(w), clean_for_hash.(e)}
+ end)
+
+ clean_evt_handlers =
+ Enum.map(event_handlers, fn {p, s, b} ->
+ {clean_for_hash.(p), clean_for_hash.(s), clean_for_hash.(b)}
+ end)
+
+ agg_hash = Components.fact_hash({name, initial_state, clean_cmd_handlers, clean_evt_handlers})
+
+ reducer_ast = build_aggregate_reducer(event_handlers)
+
+ accumulator_ast = build_aggregate_accumulator(initial_state, reducer_ast, name)
+
+ command_rules_ast = build_aggregate_command_rules(command_handlers, name)
+
+ workflow_ast = build_aggregate_workflow(accumulator_ast, command_rules_ast, name)
+
+ source =
+ quote do
+ Runic.aggregate(unquote(opts), do: unquote(Macro.escape(block)))
+ end
+
+ # Store command/event handler counts for introspection rather than raw AST
+ # (raw AST contains unresolvable variable references)
+ num_command_handlers = length(command_handlers)
+ num_event_handlers = length(event_handlers)
+
+ quote do
+ agg_accumulator = unquote(accumulator_ast)
+ agg_command_rules = unquote(command_rules_ast)
+
+ %Aggregate{
+ name: unquote(name),
+ initial_state: unquote(initial_state),
+ command_handlers: unquote(num_command_handlers),
+ event_handlers: unquote(num_event_handlers),
+ accumulator: agg_accumulator,
+ command_rules: agg_command_rules,
+ workflow: unquote(workflow_ast),
+ source: unquote(Macro.escape(source)),
+ hash: unquote(agg_hash)
+ }
+ end
+ end
+
+ defp parse_aggregate_block({:__block__, _, exprs}), do: parse_aggregate_exprs(exprs)
+ defp parse_aggregate_block(single_expr), do: parse_aggregate_exprs([single_expr])
+
+ defp parse_aggregate_exprs(exprs) do
+ initial_state =
+ Enum.find_value(exprs, fn
+ {:state, _, [value]} -> value
+ _ -> nil
+ end)
+
+ command_handlers =
+ Enum.flat_map(exprs, fn
+ {:command, _, [cmd_name, [do: cmd_block]]} ->
+ [parse_command_block(cmd_name, cmd_block)]
+
+ _ ->
+ []
+ end)
+
+ event_handlers =
+ Enum.flat_map(exprs, fn
+ {:event, _, [pattern, state_var, [do: body]]} ->
+ [{pattern, state_var, body}]
+
+ _ ->
+ []
+ end)
+
+ {initial_state, command_handlers, event_handlers}
+ end
+
+ defp parse_command_block(cmd_name, {:__block__, _, exprs}) do
+ where_fn =
+ Enum.find_value(exprs, fn
+ {:where, _, [fn_ast]} -> fn_ast
+ _ -> nil
+ end)
+
+ emit_fn =
+ Enum.find_value(exprs, fn
+ {:emit, _, [fn_ast]} -> fn_ast
+ _ -> nil
+ end) || raise(ArgumentError, "Command #{cmd_name} requires an `emit` function")
+
+ {cmd_name, where_fn, emit_fn}
+ end
+
+ defp parse_command_block(cmd_name, {:emit, _, [fn_ast]}) do
+ {cmd_name, nil, fn_ast}
+ end
+
+ defp parse_command_block(cmd_name, {:where, _, [_fn_ast]}) do
+ raise ArgumentError, "Command #{cmd_name} has a `where` but no `emit` function"
+ end
+
+ defp build_aggregate_reducer(event_handlers) do
+ # Build case clauses from event handler AST.
+ # Each event handler has {pattern, state_var, body} where pattern and state_var
+ # are raw AST nodes from the caller's context. We must reset variable contexts
+ # so they're resolved in the generated code's scope, not the caller's.
+ # We also normalize 2-element tuple patterns to explicit {:{}, [], [...]} form
+ # to prevent Elixir from interpreting {atom, value} as keyword pairs.
+ event_clauses =
+ Enum.flat_map(event_handlers, fn {pattern, state_var, body} ->
+ clean_pattern = pattern |> normalize_tuple_pattern() |> reset_var_context()
+ clean_state_var = reset_var_context(state_var)
+ clean_body = reset_var_context(body)
+
+ clause_body =
+ quote generated: true do
+ unquote(clean_state_var) = current_state
+ unquote(clean_body)
+ end
+
+ quote generated: true do
+ unquote(clean_pattern) -> unquote(clause_body)
+ end
+ end)
+
+ fallback_clause =
+ quote generated: true do
+ _ -> current_state
+ end
+
+ all_clauses = List.flatten(event_clauses ++ [fallback_clause])
+
+ # Build the case AST manually because unquote_splicing inside `case do`
+ # wraps multiple clauses in {:__block__, ...}, but case expects a plain list.
+ # Use var! to ensure the event_value variable matches the fn parameter.
+ quote generated: true do
+ fn var!(event_value, Runic), current_state ->
+ unquote(
+ {:case, [generated: true],
+ [
+ {:var!, [generated: true], [{:event_value, [generated: true], nil}, Runic]},
+ [do: all_clauses]
+ ]}
+ )
+ end
+ end
+ end
+
+ # Normalize 2-element tuple patterns {atom, value} to explicit {:{}, [], [atom, value]}
+ # AST form to prevent Elixir from treating them as keyword pairs in case clauses.
+ defp normalize_tuple_pattern({atom, value}) when is_atom(atom) do
+ {:{}, [], [atom, normalize_tuple_pattern(value)]}
+ end
+
+ defp normalize_tuple_pattern(other), do: other
+
+ # Reset variable contexts in AST to Runic so they resolve in the generated code scope
+ defp reset_var_context(ast) do
+ Macro.prewalk(ast, fn
+ {name, meta, context} when is_atom(name) and is_atom(context) ->
+ {name, Keyword.put(meta, :generated, true), Runic}
+
+ other ->
+ other
+ end)
+ end
+
+ defp build_aggregate_accumulator(initial_state, reducer_ast, agg_name) do
+ literal_init_ast =
+ quote do
+ fn -> unquote(initial_state) end
+ end
+
+ acc_hash = Components.fact_hash({literal_init_ast, reducer_ast, agg_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(literal_init_ast),
+ reducer: unquote(reducer_ast),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(agg_name)}_accumulator",
+ meta_refs: []
+ }
+ end
+ end
+
+ defp build_aggregate_command_rules(command_handlers, agg_name) do
+ rule_asts =
+ Enum.map(command_handlers, fn {cmd_name, where_fn, emit_fn} ->
+ build_aggregate_command_rule(cmd_name, where_fn, emit_fn, agg_name)
+ end)
+
+ quote do
+ [unquote_splicing(rule_asts)]
+ end
+ end
+
+ defp build_aggregate_command_rule(cmd_name, where_fn, emit_fn, agg_name) do
+ acc_ref = :__agg_accumulator__
+
+ condition_fn = build_aggregate_condition(cmd_name, where_fn, acc_ref)
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ # Only use arity-2 wrapper if there are actual meta_refs to resolve
+ has_condition_meta_refs = condition_meta_refs != []
+
+ final_condition =
+ if has_condition_meta_refs do
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+ else
+ rewritten_condition
+ end
+
+ condition_arity = if has_condition_meta_refs, do: 2, else: 1
+
+ condition_hash = Components.fact_hash({condition_fn, agg_name, cmd_name})
+
+ reaction_fn = build_aggregate_reaction(cmd_name, emit_fn, acc_ref)
+
+ reaction_meta_refs = detect_meta_expressions(reaction_fn)
+ rewritten_reaction = rewrite_meta_refs_in_ast(reaction_fn, reaction_meta_refs)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ has_reaction_meta_refs = reaction_meta_refs != []
+
+ final_reaction =
+ if has_reaction_meta_refs do
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_reaction).(input)
+ end
+ end
+ else
+ rewritten_reaction
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, agg_name, cmd_name})
+
+ rule_name =
+ quote do
+ :"#{unquote(agg_name)}_#{unquote(cmd_name)}"
+ end
+
+ quote generated: true do
+ cmd_rule_name = unquote(rule_name)
+
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: unquote(condition_arity),
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(final_reaction),
+ hash: unquote(reaction_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+
+ rule_workflow =
+ Workflow.new(cmd_rule_name)
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: cmd_rule_name,
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ defp build_aggregate_condition(cmd_name, nil, _acc_ref) do
+ quote generated: true do
+ fn input ->
+ case input do
+ unquote(cmd_name) -> true
+ {unquote(cmd_name), _} -> true
+ _ -> false
+ end
+ end
+ end
+ end
+
+ defp build_aggregate_condition(cmd_name, where_fn, acc_ref) do
+ quote generated: true do
+ fn input ->
+ cmd_matches =
+ case input do
+ unquote(cmd_name) -> true
+ {unquote(cmd_name), _} -> true
+ _ -> false
+ end
+
+ if cmd_matches do
+ current_state = state_of(unquote(acc_ref))
+ unquote(where_fn).(current_state)
+ else
+ false
+ end
+ end
+ end
+ end
+
+ defp build_aggregate_reaction(_cmd_name, emit_fn, acc_ref) do
+ quote generated: true do
+ fn input ->
+ current_state = state_of(unquote(acc_ref))
+
+ payload =
+ case input do
+ {_, p} -> p
+ _ -> nil
+ end
+
+ emit_fn = unquote(emit_fn)
+
+ case :erlang.fun_info(emit_fn, :arity) do
+ {_, 1} -> emit_fn.(current_state)
+ {_, 2} -> emit_fn.(current_state, payload)
+ _ -> emit_fn.(current_state)
+ end
+ end
+ end
+ end
+
+ defp build_aggregate_workflow(accumulator_ast, command_rules_ast, agg_name) do
+ quote generated: true do
+ acc = unquote(accumulator_ast)
+ command_rules = unquote(command_rules_ast)
+
+ # Topology:
+ # Root → Accumulator (receives input, initializes state, ignores non-events)
+ # Root → Condition → Reaction → Accumulator (command handler pipeline, events fold into state)
+ # The accumulator must also be connected from root so it initializes and its state
+ # is available via state_of() meta_refs for conditions and reactions.
+ base_wrk =
+ Workflow.new(unquote(agg_name))
+ |> Workflow.add_step(acc)
+ |> Workflow.register_component(acc)
+
+ Enum.reduce(command_rules, base_wrk, fn rule, wrk ->
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.add_step(reaction, acc)
+ |> Workflow.register_component(rule)
+
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ end)
+ end
+ end
+
+ @doc """
+ Creates a `%ProcessManager{}`: a CQRS-oriented process manager that reacts to
+ domain events, maintains coordination state, and emits commands.
+
+ Unlike Saga, ProcessManagers are event-driven and reactive rather than
+ sequential. They subscribe to event patterns from multiple sources and
+ decide what commands to issue based on accumulated state.
+
+ Compiles to an Accumulator (coordination state) plus Rules (event handlers).
+
+ ## Example
+
+ require Runic
+
+ pm = Runic.process_manager name: :fulfillment do
+ state %{order_id: nil, paid: false, shipped: false}
+
+ on {:order_submitted, order_id} do
+ update %{order_id: order_id}
+ emit {:charge_payment, order_id}
+ end
+
+ on {:payment_received, _} do
+ update %{paid: true}
+ end
+
+ on {:shipment_created, _} do
+ update %{shipped: true}
+ end
+
+ complete? fn state -> state.shipped end
+ end
+
+ ## DSL
+
+ - `state initial_value` - Sets the initial process manager state
+ - `on event_pattern do ... end` - Defines an event handler
+ - `update map` - Merges updates into the process state
+ - `emit value` - Produces a command fact as output
+ - `complete? fn state -> bool end` - Completion check (fires when state satisfies predicate)
+ - `timeout :name, duration do ... end` - Declares a timeout (scheduling is the Runner's responsibility)
+
+ ## Options
+
+ - `:name` - Identifier for the process manager (required)
+ """
+ defmacro process_manager(opts, [{:do, block}]) when is_list(opts) do
+ name = Keyword.get(opts, :name)
+ inputs = Keyword.get(opts, :inputs)
+ outputs = Keyword.get(opts, :outputs)
+
+ unless name, do: raise(ArgumentError, "ProcessManager requires a :name option")
+ unless block, do: raise(ArgumentError, "ProcessManager requires a do block")
+
+ {initial_state, event_handlers, timeout_handlers, completion_check} =
+ parse_pm_block(block)
+
+ unless initial_state do
+ raise ArgumentError, "ProcessManager #{inspect(name)} requires a `state` declaration"
+ end
+
+ # Build deterministic hash from structural properties only (not raw AST)
+ # Strip AST metadata from initial_state for deterministic hashing
+ clean_initial_state =
+ Macro.prewalk(initial_state, fn
+ {name, _meta, ctx} when is_atom(name) -> {name, [], ctx}
+ other -> other
+ end)
+
+ handler_signatures =
+ event_handlers
+ |> Enum.with_index()
+ |> Enum.map(fn {{_pattern, updates, emits}, idx} ->
+ {idx, length(updates), length(emits)}
+ end)
+
+ pm_hash =
+ Components.fact_hash(
+ {name, clean_initial_state, handler_signatures, length(timeout_handlers),
+ completion_check != nil}
+ )
+
+ accumulator_ast = build_pm_accumulator(initial_state, event_handlers, name)
+ event_rules_ast = build_pm_event_rules(event_handlers, name)
+ completion_rule_ast = build_pm_completion_rule(completion_check, name)
+ workflow_ast = build_pm_workflow(accumulator_ast, event_rules_ast, completion_rule_ast, name)
+
+ source =
+ quote do
+ Runic.process_manager(unquote(opts), do: unquote(Macro.escape(block)))
+ end
+
+ num_event_handlers = length(event_handlers)
+ num_timeout_handlers = length(timeout_handlers)
+
+ quote do
+ pm_accumulator = unquote(accumulator_ast)
+ pm_event_rules = unquote(event_rules_ast)
+ pm_completion_rule = unquote(completion_rule_ast)
+
+ %ProcessManager{
+ name: unquote(name),
+ initial_state: unquote(Macro.escape(initial_state)),
+ event_handlers: unquote(num_event_handlers),
+ timeout_handlers: unquote(num_timeout_handlers),
+ completion_check: unquote(completion_check != nil),
+ accumulator: pm_accumulator,
+ event_rules: pm_event_rules,
+ completion_rule: pm_completion_rule,
+ workflow: unquote(workflow_ast),
+ source: unquote(Macro.escape(source)),
+ hash: unquote(pm_hash),
+ inputs: unquote(inputs),
+ outputs: unquote(outputs)
+ }
+ end
+ end
+
+ defp parse_pm_block({:__block__, _, exprs}), do: parse_pm_exprs(exprs)
+ defp parse_pm_block(single_expr), do: parse_pm_exprs([single_expr])
+
+ defp parse_pm_exprs(exprs) do
+ initial_state =
+ Enum.find_value(exprs, fn
+ {:state, _, [value]} -> value
+ _ -> nil
+ end)
+
+ event_handlers =
+ Enum.flat_map(exprs, fn
+ {:on, _, [pattern, [do: handler_block]]} ->
+ [
+ {pattern, parse_pm_handler_updates(handler_block),
+ parse_pm_handler_emits(handler_block)}
+ ]
+
+ _ ->
+ []
+ end)
+
+ timeout_handlers =
+ Enum.flat_map(exprs, fn
+ {:timeout, _, [timeout_name, duration, [do: handler_block]]} ->
+ [{timeout_name, duration, handler_block}]
+
+ _ ->
+ []
+ end)
+
+ completion_check =
+ Enum.find_value(exprs, fn
+ {:complete?, _, [fn_ast]} -> fn_ast
+ _ -> nil
+ end)
+
+ {initial_state, event_handlers, timeout_handlers, completion_check}
+ end
+
+ defp parse_pm_handler_updates({:__block__, _, exprs}) do
+ Enum.flat_map(exprs, fn
+ {:update, _, [update_map]} -> [update_map]
+ _ -> []
+ end)
+ end
+
+ defp parse_pm_handler_updates({:update, _, [update_map]}), do: [update_map]
+ defp parse_pm_handler_updates(_), do: []
+
+ defp parse_pm_handler_emits({:__block__, _, exprs}) do
+ Enum.flat_map(exprs, fn
+ {:emit, _, [value]} -> [value]
+ _ -> []
+ end)
+ end
+
+ defp parse_pm_handler_emits({:emit, _, [value]}), do: [value]
+ defp parse_pm_handler_emits(_), do: []
+
+ # Build the accumulator for ProcessManager.
+ # The reducer merges update maps into state and handles timeout events.
+ defp build_pm_accumulator(initial_state, event_handlers, pm_name) do
+ # Build case clauses for each event handler's pattern → state update
+ event_clauses =
+ Enum.flat_map(event_handlers, fn {pattern, updates, _emits} ->
+ clean_pattern = pattern |> normalize_tuple_pattern() |> reset_var_context()
+
+ if updates == [] do
+ # No state update for this event — just pass through
+ []
+ else
+ # Merge all updates into state
+ merged_update =
+ case updates do
+ [single] ->
+ reset_var_context(single)
+
+ multiple ->
+ raise ArgumentError,
+ "ProcessManager event handler should have at most one `update` call, got #{length(multiple)}"
+ end
+
+ clause_body =
+ quote generated: true do
+ Map.merge(current_state, unquote(merged_update))
+ end
+
+ quote generated: true do
+ unquote(clean_pattern) -> unquote(clause_body)
+ end
+ end
+ end)
+
+ fallback_clause =
+ quote generated: true do
+ _ -> current_state
+ end
+
+ all_clauses = List.flatten(event_clauses ++ [fallback_clause])
+
+ reducer_ast =
+ quote generated: true do
+ fn var!(event_value, Runic), current_state ->
+ unquote(
+ {:case, [generated: true],
+ [
+ {:var!, [generated: true], [{:event_value, [generated: true], nil}, Runic]},
+ [do: all_clauses]
+ ]}
+ )
+ end
+ end
+
+ literal_init_ast =
+ quote do
+ fn -> unquote(initial_state) end
+ end
+
+ acc_hash = Components.fact_hash({literal_init_ast, reducer_ast, pm_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(literal_init_ast),
+ reducer: unquote(reducer_ast),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(pm_name)}_accumulator",
+ meta_refs: []
+ }
+ end
+ end
+
+ # Build event handler rules for ProcessManager.
+ # Each `on` block with an `emit` compiles to a Rule that matches the event pattern
+ # and produces command facts.
+ defp build_pm_event_rules(event_handlers, pm_name) do
+ rule_asts =
+ event_handlers
+ |> Enum.with_index()
+ |> Enum.flat_map(fn {{pattern, _updates, emits}, idx} ->
+ if emits == [] do
+ # No commands to emit — no rule needed (state update is handled by accumulator)
+ []
+ else
+ [build_pm_event_rule(pattern, emits, pm_name, idx)]
+ end
+ end)
+
+ quote do
+ [unquote_splicing(rule_asts)]
+ end
+ end
+
+ defp build_pm_event_rule(pattern, emits, pm_name, idx) do
+ acc_ref = :__pm_accumulator__
+ rule_name = :"#{pm_name}_on_#{idx}"
+
+ clean_pattern = pattern |> normalize_tuple_pattern() |> reset_var_context()
+
+ # Condition: match the event pattern
+ condition_fn =
+ quote generated: true do
+ fn input ->
+ case input do
+ unquote(clean_pattern) -> true
+ _ -> false
+ end
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, pm_name, idx})
+
+ # Reaction: emit commands. Use state_of() for state access in emit expressions.
+ emit_values =
+ case emits do
+ [single] ->
+ reset_var_context(single)
+
+ multiple ->
+ # Multiple emits produce a list
+ reset_var_context(multiple)
+ end
+
+ # Check if any emit references state_of()
+ reaction_fn =
+ quote generated: true do
+ fn _input ->
+ _pm_state = state_of(unquote(acc_ref))
+ unquote(emit_values)
+ end
+ end
+
+ reaction_meta_refs = detect_meta_expressions(reaction_fn)
+ rewritten_reaction = rewrite_meta_refs_in_ast(reaction_fn, reaction_meta_refs)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ final_reaction =
+ if reaction_meta_refs != [] do
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_reaction).(input)
+ end
+ end
+ else
+ quote generated: true do
+ fn input ->
+ unquote(rewritten_reaction).(input)
+ end
+ end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, pm_name, idx, :reaction})
+
+ quote generated: true do
+ condition =
+ Condition.new(
+ work: unquote(condition_fn),
+ hash: unquote(condition_hash),
+ arity: 1,
+ meta_refs: []
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(final_reaction),
+ hash: unquote(reaction_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+
+ rule_workflow =
+ Workflow.new(unquote(rule_name))
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: unquote(rule_name),
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ # Build completion rule for ProcessManager.
+ # Fires when the completion check function returns true for the current state.
+ defp build_pm_completion_rule(nil, _pm_name) do
+ quote do: nil
+ end
+
+ defp build_pm_completion_rule(completion_fn, pm_name) do
+ acc_ref = :__pm_accumulator__
+
+ condition_fn =
+ quote generated: true do
+ fn _input ->
+ pm_state = state_of(unquote(acc_ref))
+ check = unquote(completion_fn)
+ check.(pm_state)
+ end
+ end
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, pm_name, :complete})
+
+ reaction_fn =
+ quote generated: true do
+ fn _input ->
+ {:process_completed, unquote(pm_name)}
+ end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, pm_name, :complete_reaction})
+
+ quote generated: true do
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(reaction_fn),
+ hash: unquote(reaction_hash),
+ meta_refs: []
+ )
+
+ rule_name = :"#{unquote(pm_name)}_complete"
+
+ rule_workflow =
+ Workflow.new(rule_name)
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: rule_name,
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ defp build_pm_workflow(accumulator_ast, event_rules_ast, completion_rule_ast, pm_name) do
+ quote generated: true do
+ acc = unquote(accumulator_ast)
+ event_rules = unquote(event_rules_ast)
+ completion_rule = unquote(completion_rule_ast)
+
+ base_wrk =
+ Workflow.new(unquote(pm_name))
+ |> Workflow.add_step(acc)
+ |> Workflow.register_component(acc)
+
+ # Wire event handler rules: conditions receive events from root,
+ # reactions produce commands as output facts
+ wrk =
+ Enum.reduce(event_rules, base_wrk, fn rule, wrk ->
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ end)
+
+ # Wire completion rule if present
+ if completion_rule do
+ condition =
+ Map.get(completion_rule.workflow.graph.vertices, completion_rule.condition_hash)
+
+ reaction = Map.get(completion_rule.workflow.graph.vertices, completion_rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(acc, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(completion_rule)
+
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ else
+ wrk
+ end
+ end
+ end
+
+ @doc """
+ Creates a `%FSM{}`: a finite state machine with discrete states and guarded transitions.
+
+ FSMs compile to an Accumulator (holding the current state atom) plus Rules
+ (one per transition, using `state_of()` to gate on current state). Entry actions
+ are additional rules that fire on state changes.
+
+ ## Example
+
+ require Runic
+
+ fsm = Runic.fsm name: :traffic_light do
+ initial_state :red
+
+ state :red do
+ on :timer, to: :green
+ on :emergency, to: :red
+ on_entry fn -> {:notify, :traffic_stopped} end
+ end
+
+ state :green do
+ on :timer, to: :yellow
+ on :emergency, to: :red
+ end
+
+ state :yellow do
+ on :timer, to: :red
+ on :emergency, to: :red
+ end
+ end
+
+ Each transition compiles to a named Rule: `:"fsm_name_from_state_on_event"`.
+ """
+ defmacro fsm(opts \\ [], do: block) do
+ {name, opts_rest} = Keyword.pop(opts, :name)
+ inputs = Keyword.get(opts_rest, :inputs)
+ outputs = Keyword.get(opts_rest, :outputs)
+
+ {initial_state, states} = parse_fsm_block(block)
+
+ state_names = Enum.map(states, fn {name, _} -> name end)
+
+ validate_fsm!(initial_state, states, state_names)
+
+ fsm_name = name || :"fsm_#{Components.fact_hash({initial_state, states})}"
+
+ fsm_hash = Components.fact_hash({initial_state, states})
+
+ accumulator_ast = build_fsm_accumulator(initial_state, fsm_name)
+
+ transition_rules_ast = build_fsm_transition_rules(states, fsm_name)
+
+ entry_rules_ast = build_fsm_entry_rules(states, fsm_name)
+
+ workflow_ast =
+ build_fsm_workflow(accumulator_ast, transition_rules_ast, entry_rules_ast, fsm_name)
+
+ states_map = Macro.escape(Map.new(states))
+
+ source =
+ quote do
+ Runic.fsm(unquote(opts), do: unquote(Macro.escape(block)))
+ end
+
+ quote do
+ fsm_accumulator = unquote(accumulator_ast)
+ fsm_transition_rules = unquote(transition_rules_ast)
+ fsm_entry_rules = unquote(entry_rules_ast)
+
+ %FSM{
+ name: unquote(fsm_name),
+ initial_state: unquote(initial_state),
+ states: unquote(states_map),
+ accumulator: fsm_accumulator,
+ transition_rules: fsm_transition_rules,
+ entry_rules: fsm_entry_rules,
+ workflow: unquote(workflow_ast),
+ source: unquote(Macro.escape(source)),
+ hash: unquote(fsm_hash),
+ inputs: unquote(inputs),
+ outputs: unquote(outputs)
+ }
+ end
+ end
+
+ defp parse_fsm_block({:__block__, _, exprs}) do
+ parse_fsm_exprs(exprs)
+ end
+
+ defp parse_fsm_block(single_expr) do
+ parse_fsm_exprs([single_expr])
+ end
+
+ defp parse_fsm_exprs(exprs) do
+ initial_state =
+ Enum.find_value(exprs, fn
+ {:initial_state, _, [state]} when is_atom(state) -> state
+ _ -> nil
+ end)
+
+ states =
+ Enum.flat_map(exprs, fn
+ {:state, _, [state_name, [do: state_block]]} when is_atom(state_name) ->
+ [{state_name, parse_state_block(state_block)}]
+
+ {:state, _, [state_name]} when is_atom(state_name) ->
+ [{state_name, %{transitions: [], on_entry: nil}}]
+
+ _ ->
+ []
+ end)
+
+ {initial_state, states}
+ end
+
+ defp parse_state_block({:__block__, _, exprs}) do
+ parse_state_exprs(exprs)
+ end
+
+ defp parse_state_block(single_expr) do
+ parse_state_exprs([single_expr])
+ end
+
+ defp parse_state_exprs(exprs) do
+ transitions =
+ Enum.flat_map(exprs, fn
+ {:on, _, [event, opts]} when is_atom(event) and is_list(opts) ->
+ target = Keyword.fetch!(opts, :to)
+ guard = Keyword.get(opts, :guard)
+ [{event, target, guard}]
+
+ _ ->
+ []
+ end)
+
+ on_entry =
+ Enum.find_value(exprs, fn
+ {:on_entry, _, [fn_ast]} -> fn_ast
+ _ -> nil
+ end)
+
+ %{transitions: transitions, on_entry: on_entry}
+ end
+
+ defp validate_fsm!(initial_state, states, state_names) do
+ unless initial_state do
+ raise ArgumentError, "FSM requires an initial_state declaration"
+ end
+
+ unless initial_state in state_names do
+ raise ArgumentError,
+ "initial_state #{inspect(initial_state)} is not a declared state. Declared states: #{inspect(state_names)}"
+ end
+
+ for {state_name, %{transitions: transitions}} <- states,
+ {_event, target, _guard} <- transitions do
+ unless target in state_names do
+ raise ArgumentError,
+ "transition target #{inspect(target)} from state #{inspect(state_name)} is not a declared state. Declared states: #{inspect(state_names)}"
+ end
+ end
+
+ seen = MapSet.new()
+
+ Enum.reduce(states, seen, fn {state_name, %{transitions: transitions}}, seen ->
+ Enum.reduce(transitions, seen, fn {event, _target, _guard}, seen ->
+ key = {state_name, event}
+
+ if MapSet.member?(seen, key) do
+ raise ArgumentError,
+ "Duplicate transition: state #{inspect(state_name)} already has a transition for event #{inspect(event)}"
+ end
+
+ MapSet.put(seen, key)
+ end)
+ end)
+
+ :ok
+ end
+
+ defp build_fsm_accumulator(initial_state, fsm_name) do
+ init_ast =
+ quote do
+ fn -> unquote(initial_state) end
+ end
+
+ reducer_ast =
+ quote do
+ fn
+ {_event, target_state}, _current_state -> target_state
+ _other, current_state -> current_state
+ end
+ end
+
+ acc_hash = Components.fact_hash({init_ast, reducer_ast, fsm_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(init_ast),
+ reducer: unquote(reducer_ast),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(fsm_name)}_accumulator",
+ meta_refs: []
+ }
+ end
+ end
+
+ defp build_fsm_transition_rules(states, fsm_name) do
+ rule_asts =
+ Enum.flat_map(states, fn {from_state, %{transitions: transitions}} ->
+ Enum.map(transitions, fn {event, target, guard} ->
+ build_fsm_transition_rule(from_state, event, target, guard, fsm_name)
+ end)
+ end)
+
+ quote do
+ [unquote_splicing(rule_asts)]
+ end
+ end
+
+ defp build_fsm_transition_rule(from_state, event, target, guard, fsm_name) do
+ acc_ref = :__fsm_accumulator__
+ rule_name = :"#{fsm_name}_#{from_state}_on_#{event}"
+
+ condition_fn =
+ if guard do
+ quote generated: true do
+ fn input ->
+ state_of(unquote(acc_ref)) == unquote(from_state) and
+ input == unquote(event) and
+ unquote(guard).(input)
+ end
+ end
+ else
+ quote generated: true do
+ fn input ->
+ state_of(unquote(acc_ref)) == unquote(from_state) and
+ input == unquote(event)
+ end
+ end
+ end
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, fsm_name, from_state, event})
+
+ reaction_fn =
+ quote generated: true do
+ fn _input -> {unquote(event), unquote(target)} end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, fsm_name, from_state, event, target})
+
+ quote generated: true do
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(reaction_fn),
+ hash: unquote(reaction_hash),
+ meta_refs: []
+ )
+
+ rule_workflow =
+ Workflow.new(unquote(rule_name))
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: unquote(rule_name),
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ defp build_fsm_entry_rules(states, fsm_name) do
+ entry_asts =
+ states
+ |> Enum.filter(fn {_name, %{on_entry: on_entry}} -> on_entry != nil end)
+ |> Enum.map(fn {state_name, %{on_entry: on_entry_fn}} ->
+ build_fsm_entry_rule(state_name, on_entry_fn, fsm_name)
+ end)
+
+ quote do
+ [unquote_splicing(entry_asts)]
+ end
+ end
+
+ defp build_fsm_entry_rule(state_name, on_entry_fn, fsm_name) do
+ acc_ref = :__fsm_accumulator__
+ rule_name = :"#{fsm_name}_#{state_name}_entry"
+
+ condition_fn =
+ quote generated: true do
+ fn _input ->
+ state_of(unquote(acc_ref)) == unquote(state_name)
+ end
+ end
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, fsm_name, state_name, :entry})
+
+ reaction_fn =
+ quote generated: true do
+ fn _input -> unquote(on_entry_fn).() end
+ end
+
+ reaction_hash = Components.fact_hash({on_entry_fn, fsm_name, state_name, :entry_reaction})
+
+ quote generated: true do
+ entry_condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ entry_reaction =
+ Step.new(
+ work: unquote(reaction_fn),
+ hash: unquote(reaction_hash),
+ meta_refs: []
+ )
+
+ entry_rule_workflow =
+ Workflow.new(unquote(rule_name))
+ |> Workflow.add_step(entry_condition)
+ |> Workflow.add_step(entry_condition, entry_reaction)
+
+ %Rule{
+ name: unquote(rule_name),
+ arity: 1,
+ workflow: entry_rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: entry_condition.hash,
+ reaction_hash: entry_reaction.hash
+ }
+ end
+ end
+
+ defp build_fsm_workflow(accumulator_ast, transition_rules_ast, entry_rules_ast, fsm_name) do
+ quote generated: true do
+ acc = unquote(accumulator_ast)
+ transition_rules = unquote(transition_rules_ast)
+ entry_rules = unquote(entry_rules_ast)
+
+ base_wrk =
+ Workflow.new(unquote(fsm_name))
+ |> Workflow.add_step(acc)
+ |> Workflow.register_component(acc)
+
+ wrk =
+ Enum.reduce(transition_rules, base_wrk, fn rule, wrk ->
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.add_step(reaction, acc)
+ |> Workflow.register_component(rule)
+
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ end)
+
+ Enum.reduce(entry_rules, wrk, fn rule, wrk ->
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(acc, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ end)
+ end
+ end
+
+ @doc """
+ Creates a `%Map{}`: applies a transformation to each element of an enumerable.
+
+ Map operations fan-out an enumerable input into individual elements, apply
+ the transformation to each, and can be followed by a reduce to fan-in results.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> map_op = Runic.map(fn x -> x * 2 end, name: :double)
+ iex> workflow = Workflow.new() |> Workflow.add(map_op)
+ iex> results = workflow |> Workflow.plan_eagerly([1, 2, 3]) |> Workflow.react_until_satisfied() |> Workflow.raw_productions()
+ iex> Enum.sort(results)
+ [2, 4, 6]
+
+ ## Options
+
+ - `:name` - Identifier for referencing in `reduce/3` via `:map` option
+ - `:inputs` / `:outputs` - Reserved for future schema-based type compatibility
+
+ ## With Pipeline
+
+ Map can contain nested pipelines:
+
+ require Runic
+
+ Runic.map(
+ {Runic.step(fn x -> x * 2 end, name: :double),
+ [Runic.step(fn x -> x + 1 end, name: :add_one)]}
+ )
+
+ ## Map-Reduce Pattern
+
+ Connect a reduce to collect mapped results. The reduce's `:map` option links
+ it to the upstream map:
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> map_op = Runic.map(fn x -> x * 2 end, name: :double)
+ iex> reduce_op = Runic.reduce(0, fn x, acc -> x + acc end, name: :sum, map: :double)
+ iex> workflow = Workflow.new()
+ ...> |> Workflow.add(map_op)
+ ...> |> Workflow.add(reduce_op, to: :double)
+ iex> results = workflow
+ ...> |> Workflow.plan_eagerly([1, 2, 3])
+ ...> |> Workflow.react_until_satisfied()
+ ...> |> Workflow.raw_productions(:sum)
+ iex> 12 in results
+ true
+
+ ## How It Works
+
+ Internally, map uses a FanOut component that splits the enumerable into
+ individual facts. Each element is processed independently, enabling
+ parallel execution with the `:async` option on `react/2`.
+ """
+ defmacro map(expression, opts \\ []) do
+ {rewritten_opts, opts_bindings} =
+ if is_list(opts), do: traverse_options(opts, __CALLER__), else: {opts, []}
+
+ validate_port_schema(rewritten_opts[:inputs], "map")
+ validate_port_schema(rewritten_opts[:outputs], "map")
+
+ name = rewritten_opts[:name]
+
+ {rewritten_expression, expression_bindings} =
+ case expression do
+ {:fn, _, _} -> traverse_expression(expression, __CALLER__)
+ _ -> {expression, []}
+ end
+
+ variable_bindings =
+ (expression_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ source =
+ quote do
+ Runic.map(unquote(expression), unquote(opts))
+ end
+
+ closure_source =
+ quote do
+ Runic.map(unquote(rewritten_expression), unquote(rewritten_opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ if Enum.empty?(variable_bindings) do
+ map_hash = Components.fact_hash(source)
+ map_name = name || default_component_name("map", map_hash)
+
+ map_pipeline =
+ pipeline_workflow_of_map_expression(
+ rewritten_expression,
+ map_name
+ )
+
+ quote do
+ %Runic.Workflow.Map{
+ name: unquote(map_name),
+ pipeline: unquote(map_pipeline),
+ hash: unquote(map_hash),
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ else
+ base_name = name
+
+ quote do
+ closure = unquote(closure)
+ map_hash = closure.hash
+
+ map_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("map", "_")) <> "_#{map_hash}"
+
+ map_pipeline =
+ unquote(
+ pipeline_workflow_of_map_expression(
+ rewritten_expression,
+ quote(do: map_name)
+ )
+ )
+
+ %Runic.Workflow.Map{
+ name: map_name,
+ pipeline: map_pipeline,
+ hash: map_hash,
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ end
+ end
+
+ @doc """
+ Creates a `%Reduce{}`: aggregates multiple facts into a single accumulated result.
+
+ Reduce operations fan-in results from a map operation or process an enumerable
+ from a parent step. Unlike `accumulator/3` which processes single values
+ cumulatively, `reduce/3` aggregates over collections.
+
+ ## Basic Usage
+
+ Like `Enum.reduce/3`, process an enumerable from a parent step:
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(
+ name: :sum_range,
+ steps: [
+ {Runic.step(fn -> [1, 2, 3, 4, 5] end, name: :generate),
+ [Runic.reduce(0, fn x, acc -> x + acc end, name: :sum)]}
+ ]
+ )
+
+ ## Options
+
+ - `:name` - Identifier for the reduce component
+ - `:map` - Name of an upstream map component for fan-in (lazy evaluation)
+ - `:inputs` / `:outputs` - Reserved for future schema-based type compatibility
+
+ ## Map-Reduce Pattern (Lazy Evaluation)
+
+ When `:map` is specified, reduce waits for all mapped elements before aggregating.
+ This enables lazy/parallel execution of the map phase:
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> map_op = Runic.map(fn x -> x * 2 end, name: :double)
+ iex> reduce_op = Runic.reduce(0, fn x, acc -> x + acc end, name: :sum, map: :double)
+ iex> workflow = Workflow.new()
+ ...> |> Workflow.add(map_op)
+ ...> |> Workflow.add(reduce_op, to: :double)
+ iex> results = workflow
+ ...> |> Workflow.plan_eagerly([1, 2, 3])
+ ...> |> Workflow.react_until_satisfied()
+ ...> |> Workflow.raw_productions(:sum)
+ iex> 12 in results
+ true
+
+ ## Nested Pipeline with Reduce
+
+ Reduce can follow a pipeline after the map:
+
+ require Runic
+
+ Runic.workflow(
+ steps: [
+ {Runic.map(fn x -> x * 2 end, name: :double),
+ [{Runic.step(fn x -> x + 1 end, name: :add_one),
+ [Runic.reduce(0, fn x, acc -> x + acc end, name: :sum, map: :double)]}]}
+ ]
+ )
+
+ ## Important Notes
+
+ - Reduce operations are inherently sequential and cannot be parallelized
+ unless your reducer has CRDT (commutative) properties
+ - Without `:map`, reduce processes the enumerable eagerly in one invocation
+ - With `:map`, reduce waits for all fan-out elements before reducing
+ """
+ defmacro reduce(acc, reducer_fun, opts \\ []) do
+ {rewritten_opts, opts_bindings} =
+ if is_list(opts), do: traverse_options(opts, __CALLER__), else: {opts, []}
+
+ validate_port_schema(rewritten_opts[:inputs], "reduce")
+ validate_port_schema(rewritten_opts[:outputs], "reduce")
+
+ map_to_reduce = rewritten_opts[:map]
+ name = rewritten_opts[:name]
+
+ {rewritten_reducer_fun, reducer_bindings} = traverse_expression(reducer_fun, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_reducer, meta_refs} =
+ maybe_compile_meta_reducer(reducer_fun, rewritten_reducer_fun, __CALLER__)
+
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+
+ variable_bindings =
+ (reducer_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ source =
+ quote do
+ Runic.reduce(unquote(acc), unquote(reducer_fun), unquote(opts))
+ end
+
+ closure_source =
+ quote do
+ Runic.reduce(unquote(acc), unquote(rewritten_reducer_fun), unquote(rewritten_opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ if Enum.empty?(variable_bindings) do
+ fan_in_hash = Components.fact_hash({acc, rewritten_reducer_fun})
+ reduce_hash = Components.fact_hash(source)
+ reduce_name = name || default_component_name("reduce", reduce_hash)
+
+ quote do
+ %Runic.Workflow.Reduce{
+ name: unquote(reduce_name),
+ hash: unquote(reduce_hash),
+ fan_in: %FanIn{
+ map: unquote(map_to_reduce),
+ init: fn -> unquote(acc) end,
+ reducer: unquote(final_reducer),
+ hash: unquote(fan_in_hash),
+ name: unquote(reduce_name),
+ meta_refs: unquote(escaped_meta_refs)
+ },
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ else
+ base_name = name
+ normalized_reducer = normalize_ast(rewritten_reducer_fun)
+
+ quote do
+ closure = unquote(closure)
+
+ fan_in_hash =
+ Components.fact_hash(
+ {unquote(acc), unquote(Macro.escape(normalized_reducer)), closure.bindings}
+ )
+
+ reduce_hash = closure.hash
+
+ reduce_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("reduce", "_")) <> "_#{reduce_hash}"
+
+ %Runic.Workflow.Reduce{
+ name: reduce_name,
+ hash: reduce_hash,
+ fan_in: %FanIn{
+ map: unquote(map_to_reduce),
+ init: fn -> unquote(acc) end,
+ reducer: unquote(final_reducer),
+ hash: fan_in_hash,
+ name: reduce_name,
+ meta_refs: unquote(escaped_meta_refs)
+ },
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ end
+ end
+
+ # Schema validation helpers
+ @doc """
+ Creates an `%Accumulator{}`: maintains cumulative state across individual inputs.
+
+ Unlike `reduce/3` which aggregates over collections, accumulators process
+ single values and maintain running state across multiple workflow invocations.
+ This makes them ideal for running totals, counters, and stateful computations.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> acc = Runic.accumulator(0, fn x, state -> state + x end, name: :running_sum)
+ iex> workflow = Workflow.new() |> Workflow.add(acc)
+ iex> results = workflow |> Workflow.plan_eagerly(5) |> Workflow.react_until_satisfied() |> Workflow.raw_productions()
+ iex> 5 in results
+ true
+
+ ## Options
+
+ - `:name` - Identifier for the accumulator (useful for referencing in rules)
+ - `:inputs` / `:outputs` - Reserved for future schema-based type compatibility
+
+ ## Difference from Reduce
+
+ | Accumulator | Reduce |
+ |-------------|--------|
+ | Single value per invocation | Aggregates over enumerables |
+ | State persists across invocations | One-shot aggregation |
+ | For running totals/counters | For map-reduce patterns |
+
+ ## Building State Machines
+
+ Connect rules to accumulators to create state-machine-like behavior:
+
+ require Runic
+ alias Runic.Workflow
+
+ counter = Runic.accumulator(0, fn x, acc -> acc + x end, name: :counter)
+ threshold_rule = Runic.rule(
+ condition: fn {state, _input} -> state > 100 end,
+ reaction: fn _ -> :threshold_exceeded end
+ )
+
+ ## Captured Variables
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> multiplier = 2
+ iex> acc = Runic.accumulator(0, fn x, state -> state + x * ^multiplier end, name: :scaled_sum)
+ iex> acc.closure.bindings[:multiplier]
+ 2
+ """
+ defmacro accumulator(init, reducer_fun, opts \\ []) do
+ {rewritten_opts, opts_bindings} =
+ if is_list(opts), do: traverse_options(opts, __CALLER__), else: {opts, []}
+
+ name = rewritten_opts[:name]
+
+ {rewritten_reducer_fun, reducer_bindings} = traverse_expression(reducer_fun, __CALLER__)
+
+ # Detect context/1 meta expressions and rewrite if found
+ {final_reducer, meta_refs} =
+ maybe_compile_meta_reducer(reducer_fun, rewritten_reducer_fun, __CALLER__)
+
+ escaped_meta_refs = escape_meta_refs(meta_refs)
+
+ variable_bindings =
+ (reducer_bindings ++ opts_bindings)
+ |> Enum.uniq()
+
+ source =
+ quote do
+ Runic.accumulator(unquote(init), unquote(reducer_fun), unquote(opts))
+ end
+
+ closure_source =
+ quote do
+ Runic.accumulator(unquote(init), unquote(rewritten_reducer_fun), unquote(rewritten_opts))
+ end
+
+ closure = build_closure(closure_source, variable_bindings, __CALLER__)
+
+ if Enum.empty?(variable_bindings) do
+ accumulator_hash = Components.fact_hash(source)
+ reduce_hash = Components.fact_hash({init, rewritten_reducer_fun})
+ accumulator_name = name || default_component_name("accumulator", accumulator_hash)
+
+ quote do
+ %Accumulator{
+ name: unquote(accumulator_name),
+ init: fn -> unquote(init) end,
+ reducer: unquote(final_reducer),
+ hash: unquote(accumulator_hash),
+ reduce_hash: unquote(reduce_hash),
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ meta_refs: unquote(escaped_meta_refs)
+ }
+ end
+ else
+ base_name = name
+ normalized_reducer = normalize_ast(rewritten_reducer_fun)
+
+ quote do
+ closure = unquote(closure)
+ accumulator_hash = closure.hash
+
+ reduce_hash =
+ Components.fact_hash(
+ {unquote(init), unquote(Macro.escape(normalized_reducer)), closure.bindings}
+ )
+
+ accumulator_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("accumulator", "_")) <> "_#{accumulator_hash}"
+
+ %Accumulator{
+ name: accumulator_name,
+ init: fn -> unquote(init) end,
+ reducer: unquote(final_reducer),
+ hash: accumulator_hash,
+ reduce_hash: reduce_hash,
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ meta_refs: unquote(escaped_meta_refs)
+ }
+ end
+ end
+ end
+
+ # meta-api
+
+ @doc """
+ Used inside Runic macros such as rules to reference the state of another component such as an accumulator
+ or reduce.
+
+ Expands in conjunction with the rest of the expression of the rule's expression to evaluate against the last known state of the component.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+
+ counter = Runic.accumulator(0, fn x, acc -> acc + x end, name: :counter)
+
+ threshold_rule =
+ Runic.rule name: :threshold_check do
+ given(x: x)
+ where(state_of(:counter) > 5)
+ then(fn %{x: x} -> {:above_threshold, x} end)
+ end
+
+ workflow =
+ Workflow.new()
+ |> Workflow.add(counter)
+ |> Workflow.add(threshold_rule, to: :counter)
+ """
+ def state_of(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ Evaluates to true in a condition if the specified step has ever been ran.
+
+ Note that this evaluates to true globally for any prior execution of the workflow, not just within the current invocation.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+
+ rule =
+ Runic.rule name: :after_validation do
+ given(x: x)
+ where(step_ran?(:validator))
+ then(fn %{x: x} -> {:validated, x} end)
+ end
+ """
+ def step_ran?(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ Evaluates to true if the specified step has been executed for the given input fact.
+
+ Considers only input facts for a generation of invokations fed into the root of the workflow.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+
+ rule =
+ Runic.rule name: :scoped_check do
+ given(x: x)
+ where(step_ran?(:validator, x))
+ then(fn %{x: x} -> {:validated, x} end)
+ end
+ """
+ def step_ran?(component_name_or_hash, fact_or_hash),
+ do: doc!([component_name_or_hash, fact_or_hash])
+
+ @doc """
+ Returns the number of facts produced by a given component in the workflow.
+
+ Used inside `where` clauses of rules to gate on how many facts a component has produced.
+
+ ## Examples
+
+ require Runic
+
+ Runic.rule name: :batch_ready do
+ given(x: x)
+ where(fact_count(:items) >= 3)
+ then(fn %{x: x} -> {:process_batch, x} end)
+ end
+ """
+ def fact_count(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ Returns the most recent raw value produced by a component.
+
+ Useful in `where` clauses to compare against the latest output, or in `then`
+ clauses to incorporate another component's latest result.
+
+ ## Examples
+
+ In a `where` clause:
+
+ require Runic
+
+ Runic.rule name: :high_temp_alert do
+ given(x: x)
+ where(latest_value_of(:sensor) > 100)
+ then(fn %{x: x} -> {:alert, x} end)
+ end
+
+ In a `then` clause:
+
+ require Runic
+
+ Runic.rule name: :echo_latest do
+ given(x: x)
+ then(fn %{x: x} -> {:latest, x, latest_value_of(:sensor)} end)
+ end
+ """
+ def latest_value_of(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ Returns the most recent `%Fact{}` struct produced by a component.
+
+ Unlike `latest_value_of/1`, this returns the full `%Fact{}` struct including
+ metadata such as `hash` and `ancestry`, not just the raw value.
+
+ ## Examples
+
+ require Runic
+
+ Runic.rule name: :check_latest_fact do
+ given(x: x)
+ where(latest_fact_of(:processor) != nil)
+ then(fn %{x: x} -> {:ok, x} end)
+ end
+ """
+ def latest_fact_of(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ Returns a list of all raw values produced by a component across all invocations.
+
+ Useful for aggregation in `where` or `then` clauses, e.g. summing all scores.
+
+ ## Examples
+
+ In a `where` clause:
+
+ require Runic
+
+ Runic.rule name: :sum_check do
+ given(x: x)
+ where(Enum.sum(all_values_of(:scores)) > 100)
+ then(fn %{x: x} -> {:high_score, x} end)
+ end
+
+ In a `then` clause:
+
+ require Runic
+
+ Runic.rule name: :sum_all_scores do
+ given(x: _x)
+ then(fn _bindings -> Enum.sum(all_values_of(:scores)) end)
+ end
+ """
+ def all_values_of(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ Returns a list of all `%Fact{}` structs produced by a component across all invocations.
+
+ Unlike `all_values_of/1`, this returns full `%Fact{}` structs with metadata.
+
+ ## Examples
+
+ require Runic
+
+ Runic.rule name: :multi_fact_check do
+ given(x: x)
+ where(length(all_facts_of(:events)) > 0)
+ then(fn %{x: x} -> {:has_events, x} end)
+ end
+ """
+ def all_facts_of(component_name_or_hash), do: doc!([component_name_or_hash])
+
+ @doc """
+ References an external runtime value by key inside Runic macros.
+
+ Used inside `step`, `condition`, `rule`, `accumulator`, `map`, and `reduce`
+ macros to declare a dependency on a value provided via `Workflow.put_run_context/2`
+ or the `:run_context` option on `react_until_satisfied/3`.
+
+ Values are scoped by component name and resolved during the prepare phase.
+ The `_global` key in `run_context` is merged into every component's context.
+
+ Resolves to `nil` when the key is not present in `run_context`.
+ Use `context/2` to provide a default instead.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+
+ step = Runic.step(fn _x -> context(:api_key) end, name: :call_llm)
+
+ rule =
+ Runic.rule name: :gated do
+ given(val: v)
+ where(v > context(:threshold))
+ then(fn %{val: v} -> {:ok, v} end)
+ end
+
+ acc = Runic.accumulator(0, fn x, s -> s + x * context(:factor) end, name: :scaled)
+
+ workflow =
+ Workflow.new()
+ |> Workflow.add(step)
+ |> Workflow.put_run_context(%{call_llm: %{api_key: "sk-..."}})
+
+ Dot access is supported for map-valued context keys:
+
+ Runic.step(fn x -> x + context(:config).pool_size end, name: :pooled)
+ """
+ def context(key), do: doc!([key])
+
+ @doc """
+ References an external runtime value by key with a default fallback.
+
+ Behaves like `context/1` but uses the provided default when the key is not
+ present in `run_context`. The default can be a literal value or a zero-arity
+ function that is called lazily when needed.
+
+ Keys with defaults are not reported as missing by `Workflow.validate_run_context/2`
+ and appear as `{:optional, default}` in `Workflow.required_context_keys/1`.
+
+ Defaults are embedded in the compiled closure and participate in content
+ hashing — two components with different defaults produce different hashes.
+
+ ## Examples
+
+ require Runic
+
+ # Literal default
+ step = Runic.step(fn _x -> context(:model, default: "gpt-4") end, name: :call_llm)
+
+ # Function default — called lazily when key is missing
+ step = Runic.step(
+ fn _x -> context(:api_key, default: fn -> System.get_env("API_KEY") end) end,
+ name: :call_llm
+ )
+
+ # In rule where clauses
+ rule =
+ Runic.rule name: :default_rule do
+ given(val: v)
+ where(v > context(:threshold, default: 100))
+ then(fn %{val: v} -> {:over, v} end)
+ end
+
+ # In accumulator reducers
+ acc = Runic.accumulator(0, fn x, s -> s + x * context(:factor, default: 1) end,
+ name: :scaled
+ )
+ """
+ def context(key, opts), do: doc!([key, opts])
+
+ defp doc!(_) do
+ raise "these Runic meta APIs should not be invoked directly, " <>
+ "they serve for documentation purposes only"
+ end
+
+ # Schema validation helpers
+ @valid_port_options [:type, :doc, :cardinality, :required]
+
+ defp validate_port_schema(nil, _component_type), do: nil
+
+ defp validate_port_schema(schema, component_type) when is_list(schema) do
+ Enum.each(schema, fn {port_name, port_opts} ->
+ unless is_atom(port_name) do
+ raise ArgumentError,
+ "Port name must be an atom, got #{inspect(port_name)} in #{component_type} schema"
+ end
+
+ unless is_list(port_opts) do
+ raise ArgumentError,
+ "Port options for :#{port_name} in #{component_type} must be a keyword list, " <>
+ "got #{inspect(port_opts)}"
+ end
+
+ invalid_opts = Keyword.keys(port_opts) -- @valid_port_options
+
+ unless Enum.empty?(invalid_opts) do
+ raise ArgumentError,
+ "Invalid port options #{inspect(invalid_opts)} for :#{port_name} in #{component_type}. " <>
+ "Valid options are: #{inspect(@valid_port_options)}"
+ end
+ end)
+
+ schema
+ end
+
+ # When schema is a variable reference (AST node), skip compile-time validation
+ defp validate_port_schema(schema, _component_type), do: schema
+
+ # Normalize AST by removing metadata (line numbers, context, etc) for content addressing
+ defp normalize_ast(ast) do
+ Macro.prewalk(ast, fn
+ {form, _meta, args} when is_list(args) ->
+ # Remove all metadata, keep just the form and args
+ {form, [], args}
+
+ {form, _meta, ctx} when is_atom(ctx) or is_nil(ctx) ->
+ # Variable node - remove metadata but keep context
+ {form, [], ctx}
+
+ other ->
+ other
+ end)
+ end
+
+ # Build a Closure struct from rewritten source AST and variable bindings
+ # NOTE: `rewritten_source` should be the result of traverse_expression, not the original source
+ defp build_closure(rewritten_source, variable_bindings, caller_context) do
+ # Normalize the source AST for content addressability (remove line numbers, etc)
+ normalized_source = normalize_ast(rewritten_source)
+
+ if not Enum.empty?(variable_bindings) do
+ quote do
+ unquote_splicing(Enum.reverse(variable_bindings))
+
+ bindings_map = %{
+ unquote_splicing(
+ Enum.map(variable_bindings, fn {:=, _, [{left_var, _, _}, right]} ->
+ {left_var, right}
+ end)
+ )
+ }
+
+ metadata = ClosureMetadata.from_caller(unquote(Macro.escape(caller_context)))
+ # Use the normalized source for content addressability
+ Closure.new(unquote(Macro.escape(normalized_source)), bindings_map, metadata)
+ end
+ else
+ quote do
+ Closure.new(unquote(Macro.escape(normalized_source)), %{}, nil)
+ end
+ end
+ end
+
+ # Deprecated: kept for backward compatibility with old serialized workflows
+ defp build_bindings(variable_bindings, caller_context) do
+ if not Enum.empty?(variable_bindings) do
+ quote do
+ %{
+ unquote_splicing(
+ Enum.map(variable_bindings, fn {:=, _, [{left_var, _, _}, right]} ->
+ {left_var, right}
+ end)
+ ),
+ __caller_context__: unquote(Macro.escape(caller_context))
+ }
+ end
+ else
+ quote do
+ %{}
+ end
+ end
+ end
+
+ # direct to ast pipeline workflow
+ defp pipeline_workflow_of_map_expression(expression, name) do
+ pipeline_workflow_of_map_expression(
+ quote generated: true do
+ require Runic
+ alias Runic.Workflow
+
+ Runic.workflow(name: unquote(name))
+ end,
+ expression,
+ name
+ )
+ end
+
+ defp pipeline_workflow_of_map_expression(wrk_expression, {:fn, _, _} = expression, name) do
+ quote do
+ fan_out_hash =
+ Components.fact_hash({:fan_out, unquote(name), unquote(Macro.escape(expression))})
+
+ fan_out_name = "#{unquote(name)}__fan_out"
+
+ fan_out =
+ %FanOut{
+ hash: fan_out_hash,
+ name: fan_out_name
+ }
+
+ step_hash =
+ Components.fact_hash({:map_step, unquote(name), unquote(Macro.escape(expression))})
+
+ step = Runic.step(work: unquote(expression), hash: step_hash)
+
+ unquote(wrk_expression)
+ |> Workflow.add_step(fan_out)
+ |> Workflow.add_step(fan_out, step)
+
+ # |> Workflow.add(unquote(step), to: unquote(fan_out_hash))
+ end
+ end
+
+ defp pipeline_workflow_of_map_expression(wrk_expression, {:step, _, _} = expression, name) do
+ fan_out_hash = Components.fact_hash({:fan_out, expression})
+
+ fan_out =
+ quote do
+ %FanOut{
+ hash: unquote(fan_out_hash),
+ name: "#{unquote(name)}__fan_out"
+ }
+ end
+
+ step = CompilationUtils.pipeline_step(expression)
+
+ quote do
+ unquote(wrk_expression)
+ |> Workflow.add_step(unquote(fan_out))
+ |> Workflow.add(unquote(step), to: unquote(fan_out_hash))
+ end
+ end
+
+ defp pipeline_workflow_of_map_expression(
+ wrk_expression,
+ {[_ | _] = parent_steps, [_ | _] = dependent_steps} = pipeline_expression,
+ name
+ ) do
+ fan_out_hash = Components.fact_hash({:fan_out, pipeline_expression})
+
+ fan_out =
+ quote do
+ %FanOut{
+ hash: unquote(fan_out_hash),
+ name: unquote(name)
+ }
+ end
+
+ parent_steps_with_hashes =
+ Enum.map(parent_steps, fn step_ast ->
+ {Components.fact_hash(step_ast), CompilationUtils.pipeline_step(step_ast)}
+ end)
+
+ parent_hashes = Enum.map(parent_steps_with_hashes, &elem(&1, 0))
+
+ join_hash = Components.fact_hash(Enum.map(parent_steps_with_hashes, &elem(&1, 0)))
+
+ join =
+ quote do
+ %Join{
+ hash: unquote(join_hash),
+ joins: unquote(parent_hashes)
+ }
+ end
+
+ wrk_expression =
+ quote generated: true do
+ unquote(wrk_expression)
+ |> Workflow.add_step(unquote(fan_out))
+ |> then(fn wrk_expression ->
+ Enum.reduce(unquote(parent_steps_with_hashes), wrk_expression, fn {_, join_step}, acc ->
+ Workflow.add_step(acc, unquote(fan_out), join_step)
+ end)
+ end)
+ |> then(fn wrk_expression ->
+ Enum.reduce(unquote(parent_steps_with_hashes), wrk_expression, fn {_, join_step}, acc ->
+ Workflow.add_step(acc, join_step, unquote(join))
+ end)
+ end)
+ end
+
+ Enum.reduce(dependent_steps, wrk_expression, fn dstep, wrk_acc ->
+ dependent_pipeline_workflow =
+ CompilationUtils.workflow_graph_of_pipeline_tree_expression(
+ dstep,
+ name
+ )
+
+ quote generated: true do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(dependent_pipeline_workflow), to: unquote(join_hash))
+ end
+ end)
+ end
+
+ defp pipeline_workflow_of_map_expression(
+ wrk_expression,
+ {step_expression, [_ | _] = dependent_steps} = pipeline_expression,
+ name
+ ) do
+ fan_out_hash = Components.fact_hash({:fan_out, pipeline_expression})
+
+ fan_out =
+ quote do
+ %FanOut{
+ hash: unquote(fan_out_hash),
+ name: unquote(name)
+ }
+ end
+
+ step = CompilationUtils.pipeline_step(step_expression)
+
+ wrk_expression =
+ quote generated: true do
+ unquote(wrk_expression)
+ |> Workflow.add_step(unquote(fan_out))
+ |> Workflow.add(unquote(step), to: unquote(fan_out_hash))
+ end
+
+ dependent_pipeline_workflow =
+ CompilationUtils.workflow_graph_of_pipeline_tree_expression(dependent_steps, name)
+
+ quote generated: true do
+ unquote(wrk_expression)
+ |> Workflow.add(unquote(dependent_pipeline_workflow), to: unquote(step))
+ end
+ end
+
+ defp pipeline_workflow_of_map_expression(
+ wrk_expression,
+ [_ | _] = pipeline_expression,
+ name
+ ) do
+ fan_out_hash = Components.fact_hash({:fan_out, pipeline_expression})
+
+ fan_out =
+ quote do
+ %FanOut{
+ hash: unquote(fan_out_hash),
+ name: unquote(name)
+ }
+ end
+
+ wrk_expression =
+ quote generated: true do
+ unquote(wrk_expression)
+ |> Workflow.add_step(unquote(fan_out))
+ end
+
+ Enum.reduce(pipeline_expression, wrk_expression, fn dstep, wrk_acc ->
+ dependent_pipeline_workflow =
+ CompilationUtils.workflow_graph_of_pipeline_tree_expression(dstep, name)
+
+ quote generated: true do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(dependent_pipeline_workflow), to: unquote(fan_out_hash))
+ end
+ end)
+ end
+
+ # =============================================================================
+ # StateMachine Compilation Helpers
+ # =============================================================================
+
+ defp build_state_machine_accumulator({:fn, _, _} = init, reducer, sm_name, meta_refs) do
+ acc_hash = Components.fact_hash({init, reducer, sm_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(init),
+ reducer: unquote(reducer),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(sm_name)}_accumulator",
+ meta_refs: unquote(meta_refs)
+ }
+ end
+ end
+
+ defp build_state_machine_accumulator({:&, _, _} = init, reducer, sm_name, meta_refs) do
+ acc_hash = Components.fact_hash({init, reducer, sm_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(init),
+ reducer: unquote(reducer),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(sm_name)}_accumulator",
+ meta_refs: unquote(meta_refs)
+ }
+ end
+ end
+
+ defp build_state_machine_accumulator({:{}, _, _} = init, reducer, sm_name, meta_refs) do
+ init_fun =
+ quote do
+ {m, f, a} = unquote(init)
+ Function.capture(m, f, a)
+ end
+
+ acc_hash = Components.fact_hash({init, reducer, sm_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(init_fun),
+ reducer: unquote(reducer),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(sm_name)}_accumulator",
+ meta_refs: unquote(meta_refs)
+ }
+ end
+ end
+
+ defp build_state_machine_accumulator(literal_init, reducer, sm_name, meta_refs) do
+ literal_init_ast =
+ quote do
+ fn -> unquote(literal_init) end
+ end
+
+ acc_hash = Components.fact_hash({literal_init_ast, reducer, sm_name})
+
+ quote generated: true do
+ %Accumulator{
+ init: unquote(literal_init_ast),
+ reducer: unquote(reducer),
+ hash: unquote(acc_hash),
+ name: :"#{unquote(sm_name)}_accumulator",
+ meta_refs: unquote(meta_refs)
+ }
+ end
+ end
+
+ defp build_reactor_rules(nil, _sm_name, _env), do: quote(do: [])
+
+ defp build_reactor_rules(reactors, sm_name, env) when is_list(reactors) do
+ reactor_asts =
+ reactors
+ |> Enum.with_index()
+ |> Enum.map(fn
+ # Named reactor: {name, fn ... end}
+ {{name, reactor_fn}, _idx} when is_atom(name) ->
+ build_single_reactor_rule(reactor_fn, sm_name, name, env)
+
+ # Unnamed reactor: fn ... end
+ {reactor_fn, idx} ->
+ build_single_reactor_rule(reactor_fn, sm_name, idx, env)
+ end)
+
+ quote do
+ [unquote_splicing(reactor_asts)]
+ end
+ end
+
+ defp build_single_reactor_rule(
+ {:fn, _, [{:->, _, [[lhs], rhs]}]},
+ sm_name,
+ rule_name_or_idx,
+ _env
+ ) do
+ lhs = mark_vars_generated(lhs)
+ rhs = mark_vars_generated(rhs)
+
+ # Use a placeholder atom for state_of target; at connect time, the meta_ref
+ # edges will resolve by the accumulator's actual name
+ acc_ref = :__sm_accumulator__
+
+ # Build condition: check state_of(accumulator) matches the reactor's pattern
+ condition_fn =
+ quote generated: true do
+ fn _input ->
+ case state_of(unquote(acc_ref)) do
+ unquote(lhs) -> true
+ _ -> false
+ end
+ end
+ end
+
+ # Detect meta expressions in the condition (state_of)
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ # Wrap condition to be arity-2 (input, meta_ctx)
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, sm_name})
+
+ # Build reaction: run the reactor function with state
+ reaction_fn =
+ quote generated: true do
+ fn _input ->
+ case state_of(unquote(acc_ref)) do
+ unquote(lhs) -> unquote(rhs)
+ _ -> nil
+ end
+ end
+ end
+
+ # Detect meta expressions in the reaction (state_of)
+ reaction_meta_refs = detect_meta_expressions(reaction_fn)
+ rewritten_reaction = rewrite_meta_refs_in_ast(reaction_fn, reaction_meta_refs)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ # Wrap reaction to be arity-2 (input, meta_ctx)
+ final_reaction =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_reaction).(input)
+ end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, sm_name})
+
+ # Build the rule name: atom if given, or derive from sm_name at runtime
+ rule_name_ast = reactor_rule_name_ast(sm_name, rule_name_or_idx)
+
+ # Build condition and step, then assemble rule
+ quote generated: true do
+ sm_rule_name = unquote(rule_name_ast)
+
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(final_reaction),
+ hash: unquote(reaction_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+
+ rule_workflow =
+ Workflow.new(sm_rule_name)
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: sm_rule_name,
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ # Handle multi-clause reactor fns
+ defp build_single_reactor_rule({:fn, _, clauses}, sm_name, rule_name_or_idx, _env)
+ when length(clauses) > 1 do
+ acc_ref = :__sm_accumulator__
+
+ # Build a condition that checks if any clause matches
+ match_clauses =
+ Enum.map(clauses, fn {:->, _, [[lhs], _rhs]} ->
+ lhs = mark_vars_generated(lhs)
+
+ quote generated: true do
+ unquote(lhs) -> true
+ end
+ end)
+
+ fallback_clause =
+ quote generated: true do
+ _ -> false
+ end
+
+ all_clauses = List.flatten(match_clauses) ++ [fallback_clause]
+
+ condition_fn =
+ quote generated: true do
+ fn _input ->
+ case state_of(unquote(acc_ref)) do
+ (unquote_splicing(all_clauses))
+ end
+ end
+ end
+
+ condition_meta_refs = detect_meta_expressions(condition_fn)
+ rewritten_condition = rewrite_meta_refs_in_ast(condition_fn, condition_meta_refs)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ final_condition =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_condition).(input)
+ end
+ end
+
+ condition_hash = Components.fact_hash({condition_fn, sm_name})
+
+ # Build reaction clauses
+ reaction_clauses =
+ Enum.map(clauses, fn {:->, _, [[lhs], rhs]} ->
+ lhs = mark_vars_generated(lhs)
+ rhs = mark_vars_generated(rhs)
+
+ quote generated: true do
+ unquote(lhs) -> unquote(rhs)
+ end
+ end)
+
+ reaction_fallback =
+ quote generated: true do
+ _ -> {:error, :no_match_of_lhs_in_reactor_fn}
+ end
+
+ all_reaction_clauses = List.flatten(reaction_clauses) ++ [reaction_fallback]
+
+ reaction_fn =
+ quote generated: true do
+ fn _input ->
+ case state_of(unquote(acc_ref)) do
+ (unquote_splicing(all_reaction_clauses))
+ end
+ end
+ end
+
+ reaction_meta_refs = detect_meta_expressions(reaction_fn)
+ rewritten_reaction = rewrite_meta_refs_in_ast(reaction_fn, reaction_meta_refs)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ final_reaction =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten_reaction).(input)
+ end
+ end
+
+ reaction_hash = Components.fact_hash({reaction_fn, sm_name})
+
+ rule_name_ast = reactor_rule_name_ast(sm_name, rule_name_or_idx)
+
+ quote generated: true do
+ sm_rule_name = unquote(rule_name_ast)
+
+ condition =
+ Condition.new(
+ work: unquote(final_condition),
+ hash: unquote(condition_hash),
+ arity: 2,
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+
+ reaction =
+ Step.new(
+ work: unquote(final_reaction),
+ hash: unquote(reaction_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+
+ rule_workflow =
+ Workflow.new(sm_rule_name)
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+
+ %Rule{
+ name: sm_rule_name,
+ arity: 1,
+ workflow: rule_workflow,
+ hash: Components.fact_hash({unquote(condition_hash), unquote(reaction_hash)}),
+ condition_hash: condition.hash,
+ reaction_hash: reaction.hash
+ }
+ end
+ end
+
+ # Generates the rule name AST. If rule_name_or_idx is an atom, use it directly.
+ # If it's an integer index, derive the name from sm_name at runtime.
+ defp reactor_rule_name_ast(_sm_name, name) when is_atom(name) do
+ quote do: unquote(name)
+ end
+
+ defp reactor_rule_name_ast(sm_name, idx) when is_integer(idx) do
+ quote do: :"#{unquote(sm_name)}_reactor_#{unquote(idx)}"
+ end
+
+ defp build_state_machine_workflow(accumulator_ast, reactor_rules_ast, sm_name) do
+ quote generated: true do
+ acc = unquote(accumulator_ast)
+ reactor_rules = unquote(reactor_rules_ast)
+
+ base_wrk =
+ Workflow.new(unquote(sm_name))
+ |> Workflow.add_step(acc)
+ |> Workflow.register_component(acc)
+
+ Enum.reduce(reactor_rules, base_wrk, fn rule, wrk ->
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk =
+ wrk
+ |> Workflow.add_step(acc, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+
+ # Create meta_ref edges pointing to the accumulator for state_of() resolution
+ wrk =
+ Enum.reduce(condition.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, condition.hash, acc.hash, meta_ref)
+ end)
+
+ Enum.reduce(reaction.meta_refs || [], wrk, fn meta_ref, w ->
+ Workflow.draw_meta_ref_edge(w, reaction.hash, acc.hash, meta_ref)
+ end)
+ end)
+ end
+ end
+
+ # =============================================================================
+ # Given/When/Then DSL Compilation
+ # =============================================================================
+
+ # Creates a variable AST marked as generated to suppress unused variable warnings
+ defp generated_var(name) do
+ {name, [generated: true], nil}
+ end
+
+ # Marks all variables in an AST as generated to suppress unused variable warnings
+ defp mark_vars_generated(ast) do
+ Macro.prewalk(ast, fn
+ {name, meta, context} when is_atom(name) and is_atom(context) ->
+ {name, Keyword.put(meta, :generated, true), context}
+
+ other ->
+ other
+ end)
+ end
+
+ defp compile_given_when_then_rule(block, opts, env) do
+ # Parse the block to extract given, when, then clauses
+ {given_clause, where_clause, then_clause} = parse_given_when_then_block(block)
+
+ # Extract bindings and pattern from given clause
+ {pattern_ast, top_binding, binding_vars} = compile_given_clause(given_clause)
+
+ # Check for condition references in the where clause
+ has_condition_refs = contains_condition_refs?(where_clause)
+
+ if has_condition_refs do
+ compile_given_when_then_rule_with_condition_refs(
+ where_clause,
+ then_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ block,
+ opts,
+ env
+ )
+ else
+ compile_given_when_then_rule_standard(
+ where_clause,
+ then_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ block,
+ opts,
+ env
+ )
+ end
+ end
+
+ defp compile_given_when_then_rule_standard(
+ where_clause,
+ then_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ block,
+ opts,
+ env
+ ) do
+ # Detect meta expressions in the where clause
+ condition_meta_refs = detect_meta_expressions(where_clause)
+ has_condition_meta = condition_meta_refs != []
+
+ # Compile where clause into a condition function
+ # If meta expressions present, compile with 2-arity (input, meta_ctx)
+ {condition_fn, condition_arity, condition_meta_refs} =
+ if has_condition_meta do
+ fn_ast =
+ compile_meta_when_clause(
+ where_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env,
+ condition_meta_refs
+ )
+
+ {fn_ast, 2, condition_meta_refs}
+ else
+ fn_ast = compile_when_clause(where_clause, pattern_ast, top_binding, binding_vars, env)
+ {fn_ast, 1, []}
+ end
+
+ # Detect meta expressions in the then clause
+ reaction_meta_refs = detect_meta_expressions(then_clause)
+ has_reaction_meta = reaction_meta_refs != []
+
+ # Compile then clause - if meta expressions present, compile with 2-arity (input, meta_ctx)
+ {reaction_fn, reaction_meta_refs} =
+ if has_reaction_meta do
+ fn_ast =
+ compile_meta_then_clause(
+ then_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env,
+ reaction_meta_refs
+ )
+
+ {fn_ast, reaction_meta_refs}
+ else
+ fn_ast = compile_then_clause(then_clause, pattern_ast, top_binding, binding_vars, env)
+ {fn_ast, []}
+ end
+
+ # Get options
+ {rewritten_opts, opts_bindings} = traverse_options(opts, env)
+ name = rewritten_opts[:name]
+
+ # Determine arity for the rule (always 1 for DSL rules - single input matched against pattern)
+ # But condition may be 2-arity if it has meta refs
+ arity = 1
+
+ # Build the rule workflow with meta refs if present
+ {workflow, condition_hash, reaction_hash} =
+ workflow_of_rule_with_meta(
+ {condition_fn, reaction_fn},
+ arity,
+ condition_arity,
+ condition_meta_refs,
+ reaction_meta_refs
+ )
+
+ source =
+ quote do
+ Runic.rule(unquote(opts), do: unquote(block))
+ end
+
+ closure_source =
+ quote do
+ Runic.rule(unquote(rewritten_opts), do: unquote(block))
+ end
+
+ # Collect bindings from where clause (for pin operators in conditions)
+ {_rewritten_where, where_bindings} = traverse_expression(where_clause, env)
+
+ # Collect bindings from then clause
+ {_rewritten_then, then_bindings} = traverse_expression(then_clause, env)
+ variable_bindings = Enum.uniq(where_bindings ++ then_bindings ++ opts_bindings)
+
+ closure = build_closure(closure_source, variable_bindings, env)
+
+ if Enum.empty?(variable_bindings) do
+ rule_hash = Components.fact_hash(source)
+ rule_name = name || default_component_name("rule", rule_hash)
+
+ quote do
+ %Rule{
+ name: unquote(rule_name),
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ hash: unquote(rule_hash),
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ else
+ base_name = name
+
+ quote do
+ closure = unquote(closure)
+ rule_hash = closure.hash
+
+ rule_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("rule", "_")) <> "_#{rule_hash}"
+
+ %Rule{
+ name: rule_name,
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ hash: rule_hash,
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs])
+ }
+ end
+ end
+ end
+
+ defp compile_given_when_then_rule_with_condition_refs(
+ where_clause,
+ then_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ block,
+ opts,
+ env
+ ) do
+ # Build boolean IR from the where clause AST
+ bool_tree = flatten_boolean_tree(where_clause)
+
+ # Compile reaction
+ reaction_meta_refs = detect_meta_expressions(then_clause)
+ has_reaction_meta = reaction_meta_refs != []
+
+ {reaction_fn, reaction_meta_refs} =
+ if has_reaction_meta do
+ fn_ast =
+ compile_meta_then_clause(
+ then_clause,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env,
+ reaction_meta_refs
+ )
+
+ {fn_ast, reaction_meta_refs}
+ else
+ fn_ast = compile_then_clause(then_clause, pattern_ast, top_binding, binding_vars, env)
+ {fn_ast, []}
+ end
+
+ # Get options
+ {rewritten_opts, opts_bindings} = traverse_options(opts, env)
+ name = rewritten_opts[:name]
+ arity = 1
+
+ # Build workflow from boolean IR
+ {workflow, condition_hash, reaction_hash, rule_condition_refs_ast} =
+ build_condition_ref_workflow_from_tree(
+ bool_tree,
+ reaction_fn,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env,
+ reaction_meta_refs
+ )
+
+ source =
+ quote do
+ Runic.rule(unquote(opts), do: unquote(block))
+ end
+
+ closure_source =
+ quote do
+ Runic.rule(unquote(rewritten_opts), do: unquote(block))
+ end
+
+ # Collect bindings from inline where expressions (for pin operators)
+ where_bindings =
+ Enum.flat_map(collect_inline_exprs(bool_tree), fn expr ->
+ {_rewritten, bindings} = traverse_expression(expr, env)
+ bindings
+ end)
+
+ # Collect bindings from then clause
+ {_rewritten_then, then_bindings} = traverse_expression(then_clause, env)
+ variable_bindings = Enum.uniq(where_bindings ++ then_bindings ++ opts_bindings)
+
+ closure = build_closure(closure_source, variable_bindings, env)
+
+ if Enum.empty?(variable_bindings) do
+ rule_hash = Components.fact_hash(source)
+ rule_name = name || default_component_name("rule", rule_hash)
+
+ quote do
+ %Rule{
+ name: unquote(rule_name),
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ hash: unquote(rule_hash),
+ closure: unquote(closure),
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ condition_refs: unquote(rule_condition_refs_ast)
+ }
+ end
+ else
+ base_name = name
+
+ quote do
+ closure = unquote(closure)
+ rule_hash = closure.hash
+
+ rule_name =
+ if unquote(base_name),
+ do: unquote(base_name),
+ else: unquote(default_component_name("rule", "_")) <> "_#{rule_hash}"
+
+ %Rule{
+ name: rule_name,
+ arity: unquote(arity),
+ workflow: unquote(workflow),
+ condition_hash: unquote(condition_hash),
+ reaction_hash: unquote(reaction_hash),
+ hash: rule_hash,
+ closure: closure,
+ inputs: unquote(rewritten_opts[:inputs]),
+ outputs: unquote(rewritten_opts[:outputs]),
+ condition_refs: unquote(rule_condition_refs_ast)
+ }
+ end
+ end
+ end
+
+ defp parse_given_when_then_block({:__block__, _, statements}) do
+ parse_statements(statements)
+ end
+
+ defp parse_given_when_then_block(single_statement) do
+ parse_statements([single_statement])
+ end
+
+ defp parse_statements(statements) do
+ given_clause =
+ Enum.find_value(statements, fn
+ {:given, _, [bindings]} -> bindings
+ _ -> nil
+ end)
+
+ # Support both `where` (preferred) and `when` (if it parses correctly)
+ where_clause =
+ Enum.find_value(statements, fn
+ {:where, _, [expr]} -> expr
+ {:when, _, [expr]} -> expr
+ _ -> nil
+ end)
+
+ then_clause =
+ Enum.find_value(statements, fn
+ {:then, _, [expr]} -> expr
+ _ -> nil
+ end)
+
+ # Validate required clauses
+ unless then_clause do
+ raise ArgumentError,
+ "rule DSL requires a `then` clause with an action function"
+ end
+
+ # Default given to match anything if not specified - use special marker
+ given_clause = given_clause || :match_any
+
+ # Default where to true if not specified
+ where_clause = where_clause || true
+
+ {given_clause, where_clause, then_clause}
+ end
+
+ defp compile_given_clause(:match_any) do
+ # Match anything - bind to `input` for the then clause
+ {generated_var(:input), :input, [{:input, generated_var(:input)}]}
+ end
+
+ # Phase 2: Direct map pattern - given(%{item: i, quantity: q})
+ defp compile_given_clause({:%{}, _, pairs} = pattern) when is_list(pairs) do
+ # Mark variables as generated to suppress unused variable warnings
+ pattern = mark_vars_generated(pattern)
+
+ # For direct map patterns, we need to:
+ # 1. Extract all variables from the map pattern (the variable names like `i`, `q`)
+ # 2. Also add the map keys themselves (like `item`, `quantity`) bound to their values
+ #
+ # This allows `then(fn %{item: i} -> ... end)` to work correctly
+ binding_vars = extract_pattern_variables(pattern)
+
+ # Add key bindings: for %{item: i}, add both `i` (the variable) and `item` bound to same value
+ key_bindings =
+ Enum.flat_map(pairs, fn
+ {key, {var_name, _, ctx}} when is_atom(key) and is_atom(var_name) and is_atom(ctx) ->
+ # Key maps to a variable - bind key to the same variable
+ [{key, generated_var(var_name)}]
+
+ {key, _pattern} when is_atom(key) ->
+ # Key maps to a complex pattern - try to bind key to the whole match if possible
+ # For now, we can't easily do this, so skip
+ []
+
+ _ ->
+ []
+ end)
+
+ # Merge: key_bindings first, then binding_vars (vars take precedence if same name)
+ all_vars = Enum.uniq_by(key_bindings ++ binding_vars, fn {name, _} -> name end)
+
+ # No top_binding for direct map patterns (input IS the map)
+ {pattern, nil, all_vars}
+ end
+
+ # Phase 2: Direct tuple pattern (3+ elements) - given({:event, type, payload})
+ defp compile_given_clause({:{}, _, elements} = pattern) when is_list(elements) do
+ pattern = mark_vars_generated(pattern)
+ binding_vars = extract_pattern_variables(pattern)
+ {pattern, nil, binding_vars}
+ end
+
+ # Phase 2: Two-element tuple pattern - given({:ok, value})
+ defp compile_given_clause({_first, _second} = pattern) do
+ pattern = mark_vars_generated(pattern)
+ binding_vars = extract_pattern_variables(pattern)
+ {pattern, nil, binding_vars}
+ end
+
+ defp compile_given_clause(bindings) when is_list(bindings) do
+ # bindings is a keyword list like [order: %{status: status, total: total}]
+ # We need to:
+ # 1. Build a pattern that matches the input
+ # 2. Extract all variable names for the bindings map
+
+ case bindings do
+ [{binding_name, pattern}] ->
+ # Single binding - match input against pattern
+ # Check if pattern is just a plain variable (like `given value: value`)
+ # In that case, the value IS the binding, don't also add binding_name
+ # Mark pattern variables as generated to suppress unused variable warnings
+ pattern = mark_vars_generated(pattern)
+
+ case pattern do
+ {var_name, _, ctx} when is_atom(var_name) and is_atom(ctx) ->
+ # Pattern is just a variable - check if it's same as binding_name
+ if var_name == binding_name do
+ # Just use _ as pattern and bind to binding_name
+ {generated_var(binding_name), binding_name,
+ [{binding_name, generated_var(binding_name)}]}
+ else
+ # Different names - use the pattern variable
+ binding_vars = extract_pattern_variables(pattern)
+ all_vars = [{binding_name, generated_var(binding_name)} | binding_vars]
+ {pattern, binding_name, all_vars}
+ end
+
+ {:_, _, _} ->
+ # Pattern is underscore - just bind to binding_name
+ {{:_, [generated: true], nil}, binding_name,
+ [{binding_name, generated_var(binding_name)}]}
+
+ _ ->
+ # Complex pattern - extract all variables from it
+ binding_vars = extract_pattern_variables(pattern)
+ # Add the top-level binding
+ all_vars = [{binding_name, generated_var(binding_name)} | binding_vars]
+ {pattern, binding_name, all_vars}
+ end
+
+ multiple_bindings ->
+ # Multiple bindings - treat as map pattern where input must be a map
+ # containing all the specified keys
+ # We need to bind each key's matched value to a variable
+ # e.g., given order: %{status: :pending}, user: %{tier: tier}
+ # becomes: %{order: %{status: :pending} = order, user: %{tier: tier} = user}
+
+ all_vars =
+ Enum.flat_map(multiple_bindings, fn {name, pattern} ->
+ pattern = mark_vars_generated(pattern)
+ pattern_vars = extract_pattern_variables(pattern)
+ [{name, generated_var(name)} | pattern_vars]
+ end)
+
+ # Build a map pattern that binds each key's value to a variable
+ # Each entry becomes: key: pattern = var
+ map_entries =
+ Enum.map(multiple_bindings, fn {name, pattern} ->
+ pattern = mark_vars_generated(pattern)
+ # Bind the pattern to a variable with the same name as the key
+ {name, {:=, [], [pattern, generated_var(name)]}}
+ end)
+
+ map_pattern = {:%{}, [], map_entries}
+ {map_pattern, nil, all_vars}
+ end
+ end
+
+ defp extract_pattern_variables(pattern) do
+ # Walk the pattern AST and collect all variable bindings
+ # Variables are marked as generated to suppress unused variable warnings
+ {_ast, vars} =
+ Macro.prewalk(pattern, [], fn
+ # Skip underscores and underscore-prefixed vars
+ {:_, _, _} = node, acc ->
+ {node, acc}
+
+ # Collect variables
+ {name, _meta, context} = node, acc when is_atom(name) and is_atom(context) ->
+ name_str = Atom.to_string(name)
+
+ # Skip special forms, underscore-prefixed, and module names
+ if String.starts_with?(name_str, "_") or
+ name in [:__MODULE__, :__ENV__, :__DIR__, :__CALLER__] or
+ String.match?(name_str, ~r/^[A-Z]/) do
+ {node, acc}
+ else
+ # Mark extracted variables as generated
+ {node, [{name, generated_var(name)} | acc]}
+ end
+
+ node, acc ->
+ {node, acc}
+ end)
+
+ Enum.uniq_by(vars, fn {name, _} -> name end)
+ end
+
+ defp compile_when_clause(when_expr, pattern, top_binding, _binding_vars, env) do
+ # Build a condition function that:
+ # 1. Matches the input against the pattern
+ # 2. If matched, evaluates the when expression with bindings
+ # 3. Returns boolean
+ #
+ # Phase 1: where clauses are now compiled as full function bodies,
+ # supporting pin operators (^var) and any Elixir expression (not just guards).
+
+ # Check if the when_expr is literal true (no condition)
+ is_always_true = when_expr == true
+
+ # Traverse the where expression for pinned variables (^var)
+ {rewritten_when_expr, when_bindings} = traverse_expression(when_expr, env)
+
+ # The when expression, with variables bound
+ when_body =
+ if is_always_true do
+ true
+ else
+ rewritten_when_expr
+ end
+
+ # Build the case pattern - if we have a top_binding, use = to bind the whole value
+ # But avoid self-match like `value = value` when pattern IS the top_binding var
+ # Mark the when_body variables as generated for proper warning suppression
+ when_body = mark_vars_generated(when_body)
+
+ case_pattern =
+ cond do
+ is_nil(top_binding) ->
+ pattern
+
+ # Check if pattern is the same variable as top_binding
+ match?({^top_binding, _, ctx} when is_atom(ctx), pattern) ->
+ # Pattern is already the binding variable, no need to wrap with =
+ pattern
+
+ true ->
+ # Bind the whole matched value to the binding name (use generated var)
+ {:=, [], [generated_var(top_binding), pattern]}
+ end
+
+ # Build the condition function
+ # Use generated: true to suppress unused variable warnings for pattern bindings
+ # If we have pinned bindings, we need to capture them in the closure
+ _ = when_bindings
+
+ quote generated: true do
+ fn input ->
+ case input do
+ unquote(case_pattern) ->
+ unquote(when_body)
+
+ _ ->
+ false
+ end
+ end
+ end
+ end
+
+ defp compile_then_clause(then_expr, pattern, top_binding, binding_vars, env) do
+ # The then clause should be a function that receives the input,
+ # pattern matches it to extract bindings, then calls the user's function
+ # with the bindings map
+
+ # Build the case pattern - same as condition
+ # Avoid self-match like `value = value`
+ case_pattern =
+ cond do
+ is_nil(top_binding) ->
+ pattern
+
+ match?({^top_binding, _, ctx} when is_atom(ctx), pattern) ->
+ pattern
+
+ true ->
+ {:=, [], [generated_var(top_binding), pattern]}
+ end
+
+ # Build bindings map from the extracted variables
+ # Use the stored AST for each binding - this allows key aliases (like :item -> :i variable)
+ bindings_map_entries =
+ Enum.map(binding_vars, fn {name, ast} ->
+ {name, ast}
+ end)
+
+ bindings_map = {:%{}, [], bindings_map_entries}
+
+ # Use generated: true to suppress unused variable warnings for pattern bindings
+ case then_expr do
+ {:fn, _, _} ->
+ # User provided a function - traverse for ^ bindings then wrap
+ {rewritten_fn, _bindings} = traverse_expression(then_expr, env)
+
+ quote generated: true do
+ fn input ->
+ case input do
+ unquote(case_pattern) ->
+ bindings = unquote(bindings_map)
+ unquote(rewritten_fn).(bindings)
+ end
+ end
+ end
+
+ {:&, _, _} ->
+ # Capture syntax - wrap to pass bindings
+ quote generated: true do
+ fn input ->
+ case input do
+ unquote(case_pattern) ->
+ bindings = unquote(bindings_map)
+ unquote(then_expr).(bindings)
+ end
+ end
+ end
+
+ _ ->
+ # Raw expression - wrap in a function
+ quote generated: true do
+ fn input ->
+ case input do
+ unquote(case_pattern) ->
+ _bindings = unquote(bindings_map)
+ unquote(then_expr)
+ end
+ end
+ end
+ end
+ end
+
+ defp workflow_of_rule({condition, reaction}, arity) do
+ reaction_ast_hash = Components.fact_hash(reaction)
+
+ reaction = quote(do: Step.new(work: unquote(reaction), hash: unquote(reaction_ast_hash)))
+
+ condition_ast_hash = Components.fact_hash(condition)
+
+ condition =
+ quote(
+ do:
+ Condition.new(
+ work: unquote(condition),
+ hash: unquote(condition_ast_hash),
+ arity: unquote(arity)
+ )
+ )
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ defp workflow_of_rule({:fn, _, [{:->, _, [[], _rhs]}]} = expression, 0 = _arity) do
+ reaction_ast_hash = Components.fact_hash(expression)
+
+ reaction = quote(do: Step.new(work: unquote(expression), hash: unquote(reaction_ast_hash)))
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(reaction))
+ end
+
+ # For zero-arity rules, there is no condition, so condition_hash should be nil
+ {workflow, nil, reaction_ast_hash}
+ end
+
+ defp workflow_of_rule(
+ {:fn, _head_meta, [{:->, _clause_meta, [[{:when, _, _clauses} = lhs], _rhs]}]} =
+ expression,
+ arity
+ ) do
+ reaction_ast_hash = Components.fact_hash(expression)
+
+ reaction =
+ quote(
+ do:
+ Step.new(
+ work: unquote(expression),
+ hash: unquote(reaction_ast_hash)
+ )
+ )
+
+ binds = binds_of_guarded_anonymous(lhs, arity)
+
+ condition_fun =
+ quote do
+ fn
+ unquote(lhs) ->
+ true
+
+ unquote_splicing(Enum.map(binds, fn {_bind, meta, cont} -> {:_, meta, cont} end)) ->
+ false
+ end
+ end
+
+ condition_ast_hash = Components.fact_hash(condition_fun)
+
+ condition =
+ quote do
+ Condition.new(
+ work: unquote(condition_fun),
+ hash: unquote(condition_ast_hash),
+ arity: unquote(arity)
+ )
+ end
+
+ quoted_workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {quoted_workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ # matches: fn true -> _ end | fn nil -> _ end
+ defp workflow_of_rule({:fn, _, [{:->, _, [[lhs], rhs]}]} = _expression, 1 = _arity)
+ when lhs in [true, nil] do
+ condition_fun = fn _lhs -> true end
+ condition_ast_hash = Components.fact_hash(condition_fun)
+
+ condition = quote(do: Condition.new(unquote(condition_fun)))
+
+ reaction_fun = quote(do: fn _ -> unquote(rhs) end)
+ reaction_ast_hash = Components.fact_hash(reaction_fun)
+
+ reaction = quote(do: step(unquote(reaction_fun)))
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ defp workflow_of_rule(
+ {:fn, head_meta, [{:->, clause_meta, [[lhs], _rhs]}]} = expression,
+ 1 = arity
+ ) do
+ condition_fun =
+ {:fn, head_meta,
+ [
+ {:->, clause_meta, [[lhs], true]},
+ {:->, clause_meta, [[{:_otherwise, [if_undefined: :apply], Elixir}], false]}
+ ]}
+
+ condition_ast_hash = Components.fact_hash(condition_fun)
+
+ condition =
+ quote do
+ Condition.new(
+ work: unquote(condition_fun),
+ hash: unquote(condition_ast_hash),
+ arity: unquote(arity)
+ )
+ end
+
+ reaction_ast_hash = Components.fact_hash(expression)
+ reaction = quote(do: Step.new(work: unquote(expression), hash: unquote(reaction_ast_hash)))
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ defp workflow_of_rule_with_meta(
+ {condition, reaction},
+ rule_arity,
+ condition_arity,
+ condition_meta_refs,
+ reaction_meta_refs
+ ) do
+ reaction_ast_hash = Components.fact_hash(reaction)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ reaction_step =
+ quote do
+ Step.new(
+ work: unquote(reaction),
+ hash: unquote(reaction_ast_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+ end
+
+ condition_ast_hash = Components.fact_hash(condition)
+ escaped_condition_meta_refs = escape_meta_refs(condition_meta_refs)
+
+ condition_node =
+ quote do
+ Condition.new(
+ work: unquote(condition),
+ hash: unquote(condition_ast_hash),
+ arity: unquote(condition_arity),
+ meta_refs: unquote(escaped_condition_meta_refs)
+ )
+ end
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition_node))
+ |> Workflow.add_step(unquote(condition_node), unquote(reaction_step))
+ end
+
+ _ = rule_arity
+
+ {workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ # Extracts only the body AST from an fn expression, excluding patterns and guards.
+ # Used to detect meta expressions only in the reaction body of fn-form rules.
+ defp extract_fn_body({:fn, _, [{:->, _, [_args, body]}]}), do: body
+
+ # Extracts the guard AST from an fn expression, if present.
+ # Returns nil for non-guarded fn expressions.
+ defp extract_fn_guard({:fn, _, [{:->, _, [[{:when, _, clauses}], _body]}]}) do
+ List.last(clauses)
+ end
+
+ defp extract_fn_guard({:fn, _, [{:->, _, _}]}), do: nil
+
+ # Validates that no meta expressions (context/1, state_of/1, etc.) appear
+ # in the guard of an fn-form rule. Guards only allow a restricted set of
+ # built-in functions — meta expressions like context/1 compile to Map.get
+ # which is not a valid guard call. Raises a CompileError with guidance to
+ # use the given/where/then DSL form instead.
+ defp validate_no_meta_in_fn_guard!(expression, env) do
+ guard_ast = extract_fn_guard(expression)
+
+ if guard_ast do
+ guard_meta_refs = detect_meta_expressions(guard_ast)
+
+ if guard_meta_refs != [] do
+ kinds = guard_meta_refs |> Enum.map(& &1.kind) |> Enum.uniq()
+
+ kind_names = Enum.map_join(kinds, ", ", &"#{&1}/1")
+
+ raise CompileError,
+ description:
+ "#{kind_names} cannot be used in guard position of fn-form rules. " <>
+ "Guards only allow a restricted set of built-in functions. " <>
+ "Use the rule(given: ..., where: ..., then: ...) DSL form, or the " <>
+ "rule(condition: fn ... end, reaction: fn ... end) form with " <>
+ "#{kind_names} in the condition body instead.",
+ file: env.file,
+ line: env.line
+ end
+ end
+ end
+
+ # Builds a rule workflow from an fn expression where the reaction body
+ # contains meta expressions (context/1 etc.). Derives the condition from
+ # the original expression's pattern/guard (same as workflow_of_rule) but
+ # uses the meta-rewritten reaction fn with meta_refs on the reaction Step.
+ #
+ # `original_expression` - the rewritten fn AST (for condition derivation)
+ # `meta_reaction` - the arity-2 wrapped fn from maybe_compile_meta_work
+ # `reaction_meta_refs` - detected meta refs to attach to the reaction Step
+
+ # Zero-arity: no condition, just a reaction step with meta_refs
+ defp workflow_of_rule_fn_with_meta(
+ {:fn, _, [{:->, _, [[], _rhs]}]} = _original_expression,
+ 0 = _arity,
+ meta_reaction,
+ reaction_meta_refs
+ ) do
+ reaction_ast_hash = Components.fact_hash(meta_reaction)
+ escaped_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ reaction =
+ quote do
+ Step.new(
+ work: unquote(meta_reaction),
+ hash: unquote(reaction_ast_hash),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(reaction))
+ end
+
+ {workflow, nil, reaction_ast_hash}
+ end
+
+ # Guarded fn: condition derived from guard, reaction gets meta_refs
+ defp workflow_of_rule_fn_with_meta(
+ {:fn, _head_meta, [{:->, _clause_meta, [[{:when, _, _clauses} = lhs], _rhs]}]} =
+ _original_expression,
+ arity,
+ meta_reaction,
+ reaction_meta_refs
+ ) do
+ reaction_ast_hash = Components.fact_hash(meta_reaction)
+ escaped_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ reaction =
+ quote do
+ Step.new(
+ work: unquote(meta_reaction),
+ hash: unquote(reaction_ast_hash),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+
+ binds = binds_of_guarded_anonymous(lhs, arity)
+
+ condition_fun =
+ quote do
+ fn
+ unquote(lhs) ->
+ true
+
+ unquote_splicing(Enum.map(binds, fn {_bind, meta, cont} -> {:_, meta, cont} end)) ->
+ false
+ end
+ end
+
+ condition_ast_hash = Components.fact_hash(condition_fun)
+
+ condition =
+ quote do
+ Condition.new(
+ work: unquote(condition_fun),
+ hash: unquote(condition_ast_hash),
+ arity: unquote(arity)
+ )
+ end
+
+ quoted_workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {quoted_workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ # Literal match (true/nil): always-true condition, reaction gets meta_refs
+ defp workflow_of_rule_fn_with_meta(
+ {:fn, _, [{:->, _, [[lhs], _rhs]}]} = _original_expression,
+ 1 = _arity,
+ meta_reaction,
+ reaction_meta_refs
+ )
+ when lhs in [true, nil] do
+ condition_fun = fn _lhs -> true end
+ condition_ast_hash = Components.fact_hash(condition_fun)
+
+ condition = quote(do: Condition.new(unquote(condition_fun)))
+
+ reaction_ast_hash = Components.fact_hash(meta_reaction)
+ escaped_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ reaction =
+ quote do
+ Step.new(
+ work: unquote(meta_reaction),
+ hash: unquote(reaction_ast_hash),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ # Simple pattern match: condition from pattern, reaction gets meta_refs
+ defp workflow_of_rule_fn_with_meta(
+ {:fn, head_meta, [{:->, clause_meta, [[lhs], _rhs]}]} = _original_expression,
+ 1 = arity,
+ meta_reaction,
+ reaction_meta_refs
+ ) do
+ condition_fun =
+ {:fn, head_meta,
+ [
+ {:->, clause_meta, [[lhs], true]},
+ {:->, clause_meta, [[{:_otherwise, [if_undefined: :apply], Elixir}], false]}
+ ]}
+
+ condition_ast_hash = Components.fact_hash(condition_fun)
+
+ condition =
+ quote do
+ Condition.new(
+ work: unquote(condition_fun),
+ hash: unquote(condition_ast_hash),
+ arity: unquote(arity)
+ )
+ end
+
+ reaction_ast_hash = Components.fact_hash(meta_reaction)
+ escaped_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ reaction =
+ quote do
+ Step.new(
+ work: unquote(meta_reaction),
+ hash: unquote(reaction_ast_hash),
+ meta_refs: unquote(escaped_meta_refs)
+ )
+ end
+
+ workflow =
+ quote do
+ import Runic
+
+ Workflow.new()
+ |> Workflow.add_step(unquote(condition))
+ |> Workflow.add_step(unquote(condition), unquote(reaction))
+ end
+
+ {workflow, condition_ast_hash, reaction_ast_hash}
+ end
+
+ defp binds_of_guarded_anonymous(
+ {:fn, _meta, [{:->, _, [[lhs], _rhs]}]} = _quoted_fun_expression,
+ arity
+ ) do
+ binds_of_guarded_anonymous(lhs, arity)
+ end
+
+ defp binds_of_guarded_anonymous({:when, _meta, guarded_expression}, arity) do
+ Enum.take(guarded_expression, arity)
+ end
+
+ # =============================================================================
+ # Meta Expression Detection and Compilation
+ # =============================================================================
+
+ @meta_expression_kinds [
+ :state_of,
+ :step_ran?,
+ :fact_count,
+ :latest_value_of,
+ :latest_fact_of,
+ :all_values_of,
+ :all_facts_of,
+ :context
+ ]
+
+ @doc false
+ def detect_meta_expressions(ast) do
+ {_rewritten, refs} = Macro.prewalk(ast, [], &collect_meta_ref/2)
+ Enum.reverse(refs)
+ end
+
+ # Handle field access: check if it's accessing a meta expression (potentially chained)
+ defp collect_meta_ref({:., _, [_inner, field]} = node, acc) when is_atom(field) do
+ case extract_meta_expression_with_fields(node) do
+ {:ok, kind, target, field_path} ->
+ existing = find_ref_by_target(acc, target, kind)
+
+ if existing do
+ # Update the field_path to the longer path (we process outer-to-inner)
+ updated = update_field_path_if_longer(acc, target, kind, field_path)
+ {node, updated}
+ else
+ context_key = build_context_key(target, kind)
+
+ ref = %{
+ kind: kind,
+ target: target,
+ field_path: field_path,
+ context_key: context_key
+ }
+
+ {node, [ref | acc]}
+ end
+
+ :not_meta ->
+ {node, acc}
+ end
+ end
+
+ # Handle 2-arity context/2 with opts (e.g., context(:key, default: "value"))
+ defp collect_meta_ref({:context, _, [target, opts]} = node, acc)
+ when is_atom(target) and is_list(opts) do
+ existing = find_ref_by_target(acc, target, :context)
+
+ unless existing do
+ context_key = build_context_key(target, :context)
+ default = Keyword.get(opts, :default)
+
+ ref = %{
+ kind: :context,
+ target: target,
+ field_path: [],
+ context_key: context_key,
+ default: default
+ }
+
+ {node, [ref | acc]}
+ else
+ {node, acc}
+ end
+ end
+
+ defp collect_meta_ref({kind, _, [target]} = node, acc)
+ when kind in @meta_expression_kinds do
+ existing = find_ref_by_target(acc, target, kind)
+
+ unless existing do
+ context_key = build_context_key(target, kind)
+
+ ref = %{
+ kind: kind,
+ target: target,
+ field_path: [],
+ context_key: context_key
+ }
+
+ {node, [ref | acc]}
+ else
+ {node, acc}
+ end
+ end
+
+ defp collect_meta_ref(node, acc), do: {node, acc}
+
+ # Extract meta expression and field path from chained field access
+ # e.g., state_of(:config).database.connection.pool_size
+ # Returns {:ok, kind, target, field_path} or :not_meta
+ defp extract_meta_expression_with_fields({:., _, [inner, field]}) when is_atom(field) do
+ case extract_meta_expression_with_fields(inner) do
+ {:ok, kind, target, inner_fields} ->
+ {:ok, kind, target, inner_fields ++ [field]}
+
+ :not_meta ->
+ :not_meta
+ end
+ end
+
+ # Match the call form of dot access (e.g., `foo.bar` becomes `{{:., _, [foo, :bar]}, _, []}`)
+ defp extract_meta_expression_with_fields({{:., _, [inner, field]}, _, []})
+ when is_atom(field) do
+ case extract_meta_expression_with_fields(inner) do
+ {:ok, kind, target, inner_fields} ->
+ {:ok, kind, target, inner_fields ++ [field]}
+
+ :not_meta ->
+ :not_meta
+ end
+ end
+
+ # Base case: bare meta expression like state_of(:config)
+ defp extract_meta_expression_with_fields({:state_of, _, [target]}) do
+ {:ok, :state_of, target, []}
+ end
+
+ # Base case: context(:key)
+ defp extract_meta_expression_with_fields({:context, _, [target]}) do
+ {:ok, :context, target, []}
+ end
+
+ # Base case: context(:key, default: value)
+ defp extract_meta_expression_with_fields({:context, _, [target, opts]})
+ when is_atom(target) and is_list(opts) do
+ {:ok, :context, target, []}
+ end
+
+ # For other meta expression kinds (they don't typically have field access, but handle for completeness)
+ defp extract_meta_expression_with_fields({kind, _, [target]})
+ when kind in @meta_expression_kinds do
+ {:ok, kind, target, []}
+ end
+
+ defp extract_meta_expression_with_fields(_), do: :not_meta
+
+ defp find_ref_by_target(refs, target, kind) do
+ Enum.find(refs, fn ref -> ref.target == target and ref.kind == kind end)
+ end
+
+ # Update field_path only if the new path is longer (outer nodes have longer paths)
+ defp update_field_path_if_longer(refs, target, kind, new_field_path) do
+ Enum.map(refs, fn ref ->
+ if ref.target == target and ref.kind == kind and
+ length(new_field_path) > length(ref.field_path) do
+ %{ref | field_path: new_field_path}
+ else
+ ref
+ end
+ end)
+ end
+
+ defp build_context_key(target, :context) when is_atom(target), do: target
+
+ defp build_context_key(target, kind) when is_atom(target) do
+ suffix =
+ case kind do
+ :state_of -> "_state"
+ :step_ran? -> "_ran"
+ :fact_count -> "_count"
+ :latest_value_of -> "_latest_value"
+ :latest_fact_of -> "_latest_fact"
+ :all_values_of -> "_all_values"
+ :all_facts_of -> "_all_facts"
+ end
+
+ String.to_atom("#{target}#{suffix}")
+ end
+
+ defp build_context_key(_target, kind) do
+ String.to_atom("meta_#{kind}")
+ end
+
+ @doc false
+ def has_meta_expressions?(ast) do
+ detect_meta_expressions(ast) != []
+ end
+
+ @doc false
+ def rewrite_meta_refs_in_ast(ast, meta_refs) do
+ # Use var! with Runic context to match compile_meta_when_clause
+ meta_ctx_var = quote(do: var!(meta_ctx, Runic))
+
+ Macro.prewalk(ast, fn node ->
+ case node do
+ # state_of(:x).field - full dot access expression with call
+ {{:., dot_meta, [{:state_of, _, [target]}, field]}, call_meta, []} ->
+ ref = find_ref_by_target(meta_refs, target, :state_of)
+
+ if ref do
+ # Build: Map.get(meta_ctx, :context_key, %{}).field
+ # Use Map.get with default empty map to avoid KeyError
+ map_get = quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key), %{}))
+ {{:., dot_meta, [map_get, field]}, call_meta, []}
+ else
+ node
+ end
+
+ # context(:key).field - dot access on context expression
+ {{:., dot_meta, [{:context, _, [target]}, field]}, call_meta, []} ->
+ ref = find_ref_by_target(meta_refs, target, :context)
+
+ if ref do
+ map_get = quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key), %{}))
+ {{:., dot_meta, [map_get, field]}, call_meta, []}
+ else
+ node
+ end
+
+ # context(:key, default: val).field - dot access on context/2 expression
+ {{:., dot_meta, [{:context, _, [target, _opts]}, field]}, call_meta, []}
+ when is_atom(target) ->
+ ref = find_ref_by_target(meta_refs, target, :context)
+
+ if ref do
+ map_get = quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key), %{}))
+ {{:., dot_meta, [map_get, field]}, call_meta, []}
+ else
+ node
+ end
+
+ # state_of(:x) without field access
+ {:state_of, _, [target]} ->
+ ref = find_ref_by_target(meta_refs, target, :state_of)
+
+ if ref do
+ # Build: Map.get(meta_ctx, :context_key)
+ quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key)))
+ else
+ node
+ end
+
+ # context(:key, default: val) with default — 2-arity form
+ {:context, _, [target, _opts]} when is_atom(target) ->
+ ref = find_ref_by_target(meta_refs, target, :context)
+
+ if ref do
+ value_expr = quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key)))
+ apply_default_expr(value_expr, ref)
+ else
+ node
+ end
+
+ # context(:key) without field access
+ {:context, _, [target]} when is_atom(target) ->
+ ref = find_ref_by_target(meta_refs, target, :context)
+
+ if ref do
+ value_expr = quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key)))
+ apply_default_expr(value_expr, ref)
+ else
+ node
+ end
+
+ # Other meta expressions: step_ran?, fact_count, etc.
+ {kind, _, [target]} when kind in @meta_expression_kinds ->
+ ref = find_ref_by_target(meta_refs, target, kind)
+
+ if ref do
+ quote(do: Map.get(unquote(meta_ctx_var), unquote(ref.context_key)))
+ else
+ node
+ end
+
+ other ->
+ other
+ end
+ end)
+ end
+
+ # Escapes meta_refs for embedding in generated code.
+ # Handles function AST defaults by injecting them directly instead of double-escaping.
+ defp escape_meta_refs(meta_refs) do
+ refs_with_escaped_defaults =
+ Enum.map(meta_refs, fn ref ->
+ case Map.get(ref, :default) do
+ {:fn, _, _} = fn_ast ->
+ escaped_ref = ref |> Map.delete(:default) |> Macro.escape()
+
+ quote generated: true do
+ Map.put(unquote(escaped_ref), :default, unquote(fn_ast))
+ end
+
+ _ ->
+ Macro.escape(ref)
+ end
+ end)
+
+ refs_with_escaped_defaults
+ end
+
+ defp apply_default_expr(value_expr, ref) do
+ default = Map.get(ref, :default)
+
+ case default do
+ nil ->
+ value_expr
+
+ # Function AST: fn -> ... end — inject and call at runtime
+ {:fn, _, _} = fn_ast ->
+ quote generated: true do
+ case unquote(value_expr) do
+ nil -> unquote(fn_ast).()
+ val -> val
+ end
+ end
+
+ # Already-compiled function (e.g., from runtime meta_ref construction)
+ default when is_function(default) ->
+ quote generated: true do
+ case unquote(value_expr) do
+ nil -> unquote(Macro.escape(default)).()
+ val -> val
+ end
+ end
+
+ # Literal value
+ default ->
+ quote generated: true do
+ case unquote(value_expr) do
+ nil -> unquote(Macro.escape(default))
+ val -> val
+ end
+ end
+ end
+ end
+
+ # Detects context/1 meta expressions in a step or condition's work function AST.
+ # If found, rewrites the work function to be arity-2 (input, meta_ctx) with
+ # meta expression references resolved from the meta context map.
+ # Returns {rewritten_work_ast, meta_refs_list}.
+ defp maybe_compile_meta_work(work_ast, rewritten_work, _env) do
+ meta_refs = detect_meta_expressions(work_ast)
+
+ if meta_refs != [] do
+ rewritten = rewrite_meta_refs_in_ast(rewritten_work, meta_refs)
+
+ wrapped =
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten).(input)
+ end
+ end
+
+ {wrapped, meta_refs}
+ else
+ {rewritten_work, []}
+ end
+ end
+
+ defp maybe_compile_meta_reducer(reducer_ast, rewritten_reducer, _env) do
+ meta_refs = detect_meta_expressions(reducer_ast)
+
+ if meta_refs != [] do
+ rewritten = rewrite_meta_refs_in_ast(rewritten_reducer, meta_refs)
+
+ wrapped =
+ quote generated: true do
+ fn value, acc, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+ unquote(rewritten).(value, acc)
+ end
+ end
+
+ {wrapped, meta_refs}
+ else
+ {rewritten_reducer, []}
+ end
+ end
+
+ @doc false
+ def compile_meta_then_clause(
+ then_expr,
+ pattern,
+ top_binding,
+ binding_vars,
+ env,
+ meta_refs
+ ) do
+ # Build bindings map from the extracted variables
+ # Use the stored AST for each binding - this allows key aliases (like :item -> :i variable)
+ bindings_map_entries =
+ Enum.map(binding_vars, fn {name, ast} ->
+ {name, ast}
+ end)
+
+ bindings_map = {:%{}, [], bindings_map_entries}
+
+ case_pattern =
+ cond do
+ is_nil(top_binding) ->
+ pattern
+
+ match?({^top_binding, _, ctx} when is_atom(ctx), pattern) ->
+ pattern
+
+ true ->
+ {:=, [], [generated_var(top_binding), pattern]}
+ end
+
+ case then_expr do
+ {:fn, _, _} ->
+ # User provided a function - traverse for ^ bindings, rewrite meta expressions
+ {rewritten_fn, _bindings} = traverse_expression(then_expr, env)
+ rewritten_fn = rewrite_meta_refs_in_ast(rewritten_fn, meta_refs)
+
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+
+ case input do
+ unquote(case_pattern) ->
+ bindings = unquote(bindings_map)
+ unquote(rewritten_fn).(bindings)
+ end
+ end
+ end
+
+ {:&, _, _} ->
+ # Capture syntax - wrap to pass bindings
+ # Note: Capture syntax can't use meta expressions directly in the captured function
+ # The meta expressions would need to be in the wrapping function
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+
+ case input do
+ unquote(case_pattern) ->
+ bindings = unquote(bindings_map)
+ unquote(then_expr).(bindings)
+ end
+ end
+ end
+
+ _ ->
+ # Raw expression - wrap in a function, rewrite meta expressions
+ rewritten_expr = rewrite_meta_refs_in_ast(then_expr, meta_refs)
+ rewritten_expr = mark_vars_generated(rewritten_expr)
+
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+
+ case input do
+ unquote(case_pattern) ->
+ _bindings = unquote(bindings_map)
+ unquote(rewritten_expr)
+ end
+ end
+ end
+ end
+ end
+
+ @doc false
+ def compile_meta_when_clause(when_expr, pattern, top_binding, _binding_vars, _env, meta_refs) do
+ rewritten_body = rewrite_meta_refs_in_ast(when_expr, meta_refs)
+ rewritten_body = mark_vars_generated(rewritten_body)
+
+ case_pattern =
+ cond do
+ is_nil(top_binding) ->
+ pattern
+
+ match?({^top_binding, _, ctx} when is_atom(ctx), pattern) ->
+ pattern
+
+ true ->
+ {:=, [], [generated_var(top_binding), pattern]}
+ end
+
+ quote generated: true do
+ fn input, var!(meta_ctx, Runic) ->
+ _ = var!(meta_ctx, Runic)
+
+ case input do
+ unquote(case_pattern) ->
+ unquote(rewritten_body)
+
+ _ ->
+ false
+ end
+ end
+ end
+ end
+
+ # =============================================================================
+ # Condition Reference Detection & Boolean Decomposition (Phase 4)
+ # =============================================================================
+
+ defp contains_condition_refs?(ast) do
+ {_, found} =
+ Macro.prewalk(ast, false, fn
+ {:condition, _, [name]} = node, _acc when is_atom(name) -> {node, true}
+ node, acc -> {node, acc}
+ end)
+
+ found
+ end
+
+ # Flatten a boolean expression AST into an IR tree of :and/:or/:inline/:ref nodes.
+ # This supports arbitrary nesting like (a and b) or (c and d).
+ defp flatten_boolean_tree({:or, _, [lhs, rhs]}) do
+ {:or, flatten_or_branches(lhs) ++ flatten_or_branches(rhs)}
+ end
+
+ defp flatten_boolean_tree({:||, _, [lhs, rhs]}) do
+ {:or, flatten_or_branches(lhs) ++ flatten_or_branches(rhs)}
+ end
+
+ defp flatten_boolean_tree({:and, _, [_lhs, _rhs]} = ast) do
+ {:and, flatten_and_parts(ast)}
+ end
+
+ defp flatten_boolean_tree({:&&, _, [_lhs, _rhs]} = ast) do
+ {:and, flatten_and_parts(ast)}
+ end
+
+ defp flatten_boolean_tree({:condition, _, [name]} = _ast) when is_atom(name) do
+ {:ref, name}
+ end
+
+ defp flatten_boolean_tree(expr) do
+ {:inline, expr}
+ end
+
+ defp flatten_or_branches({:or, _, [lhs, rhs]}),
+ do: flatten_or_branches(lhs) ++ flatten_or_branches(rhs)
+
+ defp flatten_or_branches({:||, _, [lhs, rhs]}),
+ do: flatten_or_branches(lhs) ++ flatten_or_branches(rhs)
+
+ defp flatten_or_branches(expr), do: [flatten_boolean_tree(expr)]
+
+ defp flatten_and_parts({:and, _, [lhs, rhs]}),
+ do: flatten_and_parts(lhs) ++ flatten_and_parts(rhs)
+
+ defp flatten_and_parts({:&&, _, [lhs, rhs]}),
+ do: flatten_and_parts(lhs) ++ flatten_and_parts(rhs)
+
+ defp flatten_and_parts({:condition, _, [name]}) when is_atom(name) do
+ [{:ref, name}]
+ end
+
+ defp flatten_and_parts(expr), do: [{:inline, expr}]
+
+ defp compile_inline_condition_expr(expr, pattern_ast, top_binding, binding_vars, env) do
+ compile_when_clause(expr, pattern_ast, top_binding, binding_vars, env)
+ end
+
+ # Build workflow from boolean IR tree.
+ # NOTE: Consider introducing a %Disjunction{} struct in the future if boolean
+ # logic becomes more elaborate (e.g., negation, nested mixed expressions).
+ # For now, `or` is modeled as multiple independent flow paths to the reaction —
+ # the runtime deduplicates via mark_runnable_as_ran.
+ defp build_condition_ref_workflow_from_tree(
+ bool_tree,
+ reaction_fn,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env,
+ reaction_meta_refs
+ ) do
+ reaction_ast_hash = Components.fact_hash(reaction_fn)
+ escaped_reaction_meta_refs = escape_meta_refs(reaction_meta_refs)
+
+ reaction_step =
+ quote do
+ Step.new(
+ work: unquote(reaction_fn),
+ hash: unquote(reaction_ast_hash),
+ meta_refs: unquote(escaped_reaction_meta_refs)
+ )
+ end
+
+ case bool_tree do
+ {:and, parts} ->
+ build_and_branch_workflow(
+ parts,
+ reaction_step,
+ reaction_ast_hash,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env
+ )
+
+ {:or, branches} ->
+ build_or_branches_workflow(
+ branches,
+ reaction_step,
+ reaction_ast_hash,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env
+ )
+
+ {:ref, name} ->
+ build_single_ref_workflow(name, reaction_step, reaction_ast_hash)
+
+ {:inline, expr} ->
+ build_single_inline_workflow(
+ expr,
+ reaction_step,
+ reaction_ast_hash,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env
+ )
+ end
+ end
+
+ # Build workflow for a single and-group: inline conditions + refs → Conjunction → reaction
+ defp build_and_branch_workflow(
+ parts,
+ reaction_step,
+ reaction_ast_hash,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env
+ ) do
+ {condition_refs, inline_exprs} = partition_and_parts(parts)
+
+ inline_conditions =
+ Enum.map(inline_exprs, fn expr ->
+ condition_fn =
+ compile_inline_condition_expr(expr, pattern_ast, top_binding, binding_vars, env)
+
+ condition_ast_hash = Components.fact_hash(condition_fn)
+
+ condition_node =
+ quote do
+ Condition.new(
+ work: unquote(condition_fn),
+ hash: unquote(condition_ast_hash),
+ arity: 1
+ )
+ end
+
+ {condition_node, condition_ast_hash}
+ end)
+
+ inline_hashes = Enum.map(inline_conditions, &elem(&1, 1))
+ escaped_refs = Macro.escape(condition_refs)
+
+ conjunction =
+ quote do
+ Conjunction.new(unquote(inline_hashes), unquote(escaped_refs))
+ end
+
+ workflow =
+ quote do
+ import Runic
+
+ wrk = Workflow.new()
+
+ wrk =
+ Enum.reduce(
+ unquote(Enum.map(inline_conditions, &elem(&1, 0))),
+ wrk,
+ fn cond_node, w -> Workflow.add_step(w, cond_node) end
+ )
+
+ conj = unquote(conjunction)
+ inline_conds = Workflow.conditions(wrk)
+
+ wrk =
+ Enum.reduce(inline_conds, wrk, fn cond_node, w ->
+ Workflow.add_step(w, cond_node, conj)
+ end)
+
+ Workflow.add_step(wrk, conj, unquote(reaction_step))
+ end
+
+ conjunction_hash =
+ quote do
+ Conjunction.new(unquote(inline_hashes), unquote(escaped_refs)).hash
+ end
+
+ rule_condition_refs =
+ quote do
+ conj_hash = unquote(conjunction_hash)
+ Enum.map(unquote(escaped_refs), fn ref_name -> {ref_name, conj_hash} end)
+ end
+
+ {workflow, conjunction_hash, reaction_ast_hash, rule_condition_refs}
+ end
+
+ # Build workflow for or-branches: each branch independently flows to the reaction step.
+ defp build_or_branches_workflow(
+ branches,
+ reaction_step,
+ reaction_ast_hash,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env
+ ) do
+ compiled_branches =
+ Enum.map(branches, fn branch ->
+ compile_or_branch(branch, pattern_ast, top_binding, binding_vars, env)
+ end)
+
+ # Compute a synthetic condition hash from all branches
+ branch_hash_basis =
+ Enum.map(compiled_branches, fn
+ {:ref_branch, name} -> {:ref, name}
+ {:inline_branch, _node, hash} -> {:hash, hash}
+ {:and_branch, _inline_conds, _refs, conj_hash_ast} -> {:conj, conj_hash_ast}
+ end)
+
+ # Separate compile-time-resolvable and runtime hash components
+ {static_parts, dynamic_parts} =
+ Enum.split_with(branch_hash_basis, fn
+ {:ref, _} -> true
+ {:hash, _} -> true
+ {:conj, _} -> false
+ end)
+
+ static_basis = Enum.sort(static_parts)
+
+ workflow =
+ quote do
+ import Runic
+
+ reaction = unquote(reaction_step)
+ wrk = Workflow.new()
+ wrk = %Workflow{wrk | graph: Graph.add_vertex(wrk.graph, reaction, reaction.hash)}
+
+ unquote(build_or_branch_wiring(compiled_branches))
+ end
+
+ condition_hash =
+ if Enum.empty?(dynamic_parts) do
+ Components.fact_hash({:or, static_basis})
+ else
+ quote do
+ dynamic_hashes =
+ Enum.map(unquote(Macro.escape(dynamic_parts)), fn
+ {:conj, hash} -> {:conj_hash, hash}
+ end)
+
+ Components.fact_hash({:or, unquote(Macro.escape(static_basis)) ++ dynamic_hashes})
+ end
+ end
+
+ # Collect condition_refs: direct ref branches wire to reaction,
+ # and-branch refs wire to their conjunction
+ rule_condition_refs =
+ Enum.reduce(compiled_branches, [], fn
+ {:ref_branch, name}, acc ->
+ [{name, reaction_ast_hash} | acc]
+
+ {:and_branch, _inline_conds, refs, conj_hash_ast}, acc ->
+ and_refs = Enum.map(refs, fn ref_name -> {ref_name, conj_hash_ast} end)
+ and_refs ++ acc
+
+ {:inline_branch, _node, _hash}, acc ->
+ acc
+ end)
+ |> Enum.reverse()
+
+ # Separate static and dynamic refs
+ {static_refs, dynamic_refs} =
+ Enum.split_with(rule_condition_refs, fn {_name, target} -> is_integer(target) end)
+
+ rule_condition_refs_ast =
+ if Enum.empty?(dynamic_refs) do
+ Macro.escape(static_refs)
+ else
+ quote do
+ unquote(Macro.escape(static_refs)) ++
+ unquote(
+ Enum.map(dynamic_refs, fn {name, hash_ast} ->
+ quote do
+ {unquote(name), unquote(hash_ast)}
+ end
+ end)
+ )
+ end
+ end
+
+ {workflow, condition_hash, reaction_ast_hash, rule_condition_refs_ast}
+ end
+
+ # Compile a single or-branch into a tagged tuple for workflow wiring
+ defp compile_or_branch({:ref, name}, _pattern_ast, _top_binding, _binding_vars, _env) do
+ {:ref_branch, name}
+ end
+
+ defp compile_or_branch({:inline, expr}, pattern_ast, top_binding, binding_vars, env) do
+ condition_fn =
+ compile_inline_condition_expr(expr, pattern_ast, top_binding, binding_vars, env)
+
+ condition_ast_hash = Components.fact_hash(condition_fn)
+
+ condition_node =
+ quote do
+ Condition.new(
+ work: unquote(condition_fn),
+ hash: unquote(condition_ast_hash),
+ arity: 1
+ )
+ end
+
+ {:inline_branch, condition_node, condition_ast_hash}
+ end
+
+ defp compile_or_branch({:and, parts}, pattern_ast, top_binding, binding_vars, env) do
+ {condition_refs, inline_exprs} = partition_and_parts(parts)
+
+ inline_conditions =
+ Enum.map(inline_exprs, fn expr ->
+ condition_fn =
+ compile_inline_condition_expr(expr, pattern_ast, top_binding, binding_vars, env)
+
+ condition_ast_hash = Components.fact_hash(condition_fn)
+
+ condition_node =
+ quote do
+ Condition.new(
+ work: unquote(condition_fn),
+ hash: unquote(condition_ast_hash),
+ arity: 1
+ )
+ end
+
+ {condition_node, condition_ast_hash}
+ end)
+
+ inline_hashes = Enum.map(inline_conditions, &elem(&1, 1))
+ escaped_refs = Macro.escape(condition_refs)
+
+ conj_hash_ast =
+ quote do
+ Conjunction.new(unquote(inline_hashes), unquote(escaped_refs)).hash
+ end
+
+ {:and_branch, inline_conditions, condition_refs, conj_hash_ast}
+ end
+
+ # Generate AST that wires each compiled branch to the reaction step in the workflow
+ defp build_or_branch_wiring(compiled_branches) do
+ Enum.reduce(compiled_branches, nil, fn branch, acc ->
+ branch_ast =
+ case branch do
+ {:ref_branch, _name} ->
+ # Condition ref branches have no inline nodes to add — they're wired at connect-time.
+ # The reaction step is already in the workflow; the ref's condition will be wired
+ # to the reaction by Component.connect/3 when the rule is added to a workflow.
+ nil
+
+ {:inline_branch, condition_node, _hash} ->
+ quote do
+ cond_node = unquote(condition_node)
+ wrk = Workflow.add_step(wrk, cond_node)
+ wrk = Workflow.add_step(wrk, cond_node, reaction)
+ end
+
+ {:and_branch, inline_conditions, condition_refs, _conj_hash_ast} ->
+ inline_nodes_ast = Enum.map(inline_conditions, &elem(&1, 0))
+ inline_hashes = Enum.map(inline_conditions, &elem(&1, 1))
+ escaped_refs = Macro.escape(condition_refs)
+
+ quote do
+ conj = Conjunction.new(unquote(inline_hashes), unquote(escaped_refs))
+
+ wrk =
+ Enum.reduce(
+ unquote(inline_nodes_ast),
+ wrk,
+ fn cond_node, w -> Workflow.add_step(w, cond_node) end
+ )
+
+ branch_inline_conds =
+ Enum.filter(Graph.vertices(wrk.graph), fn
+ %Condition{hash: h} -> h in unquote(inline_hashes)
+ _ -> false
+ end)
+
+ wrk =
+ Enum.reduce(branch_inline_conds, wrk, fn cond_node, w ->
+ Workflow.add_step(w, cond_node, conj)
+ end)
+
+ wrk = Workflow.add_step(wrk, conj, reaction)
+ end
+ end
+
+ case {acc, branch_ast} do
+ {nil, nil} ->
+ nil
+
+ {nil, ast} ->
+ ast
+
+ {acc, nil} ->
+ acc
+
+ {acc, ast} ->
+ quote do
+ unquote(acc)
+ unquote(ast)
+ end
+ end
+ end)
+ |> case do
+ nil ->
+ quote do
+ wrk
+ end
+
+ ast ->
+ quote do
+ unquote(ast)
+ wrk
+ end
+ end
+ end
+
+ # Build workflow for a single condition ref flowing directly to reaction
+ defp build_single_ref_workflow(name, reaction_step, reaction_ast_hash) do
+ workflow =
+ quote do
+ import Runic
+ wrk = Workflow.new()
+ reaction = unquote(reaction_step)
+ %Workflow{wrk | graph: Graph.add_vertex(wrk.graph, reaction, reaction.hash)}
+ end
+
+ condition_hash = Components.fact_hash({:or, [{:ref, name}]})
+ rule_condition_refs = Macro.escape([{name, reaction_ast_hash}])
+ {workflow, condition_hash, reaction_ast_hash, rule_condition_refs}
+ end
+
+ # Build workflow for a single inline condition flowing directly to reaction
+ defp build_single_inline_workflow(
+ expr,
+ reaction_step,
+ reaction_ast_hash,
+ pattern_ast,
+ top_binding,
+ binding_vars,
+ env
+ ) do
+ condition_fn =
+ compile_inline_condition_expr(expr, pattern_ast, top_binding, binding_vars, env)
+
+ condition_ast_hash = Components.fact_hash(condition_fn)
+
+ condition_node =
+ quote do
+ Condition.new(
+ work: unquote(condition_fn),
+ hash: unquote(condition_ast_hash),
+ arity: 1
+ )
+ end
+
+ workflow =
+ quote do
+ import Runic
+ wrk = Workflow.new()
+ cond_node = unquote(condition_node)
+ wrk = Workflow.add_step(wrk, cond_node)
+ Workflow.add_step(wrk, cond_node, unquote(reaction_step))
+ end
+
+ condition_hash = condition_ast_hash
+ {workflow, condition_hash, reaction_ast_hash, Macro.escape([])}
+ end
+
+ # Extract refs and inline expressions from and-group parts
+ defp partition_and_parts(parts) do
+ Enum.reduce(parts, {[], []}, fn
+ {:ref, name}, {refs, inlines} -> {[name | refs], inlines}
+ {:inline, expr}, {refs, inlines} -> {refs, [expr | inlines]}
+ end)
+ |> then(fn {refs, inlines} -> {Enum.reverse(refs), Enum.reverse(inlines)} end)
+ end
+
+ # Recursively collect all inline expressions from a boolean IR tree
+ defp collect_inline_exprs({:inline, expr}), do: [expr]
+ defp collect_inline_exprs({:ref, _}), do: []
+ defp collect_inline_exprs({:and, parts}), do: Enum.flat_map(parts, &collect_inline_exprs/1)
+ defp collect_inline_exprs({:or, branches}), do: Enum.flat_map(branches, &collect_inline_exprs/1)
+end
diff --git a/vendor/runic/lib/runic/exceptions.ex b/vendor/runic/lib/runic/exceptions.ex
new file mode 100644
index 0000000..7eef875
--- /dev/null
+++ b/vendor/runic/lib/runic/exceptions.ex
@@ -0,0 +1,134 @@
+defmodule Runic.UnresolvedReferenceError do
+ @moduledoc """
+ Raised when a component references another component that does not exist in the workflow.
+
+ This typically occurs when adding a rule with meta expressions like `state_of(:counter)`
+ before the `:counter` component has been added to the workflow.
+
+ ## Example
+
+ workflow = Workflow.new()
+
+ # This will raise because :counter doesn't exist yet
+ |> Workflow.add(rule_using_state_of_counter)
+
+ # The accumulator should be added first
+ |> Workflow.add(counter_accumulator)
+
+ ## Solutions
+
+ 1. Add target components before adding rules that reference them
+ 2. Ensure component names match exactly (atoms are case-sensitive)
+ 3. For subcomponent references like `{:parent, :child}`, ensure both parent and child exist
+
+ """
+
+ defexception [:message, :component_name, :reference_kind, :target, :hint]
+
+ @impl true
+ def exception(opts) do
+ component_name = Keyword.fetch!(opts, :component_name)
+ reference_kind = Keyword.fetch!(opts, :reference_kind)
+ target = Keyword.fetch!(opts, :target)
+ hint = Keyword.get(opts, :hint)
+
+ target_str = format_target(target)
+
+ message =
+ "Cannot add component #{inspect(component_name)} - " <>
+ "meta reference #{reference_kind}(#{target_str}) targets component #{target_str} " <>
+ "which does not exist in the workflow."
+
+ message =
+ if hint do
+ message <> "\n\nHint: #{hint}"
+ else
+ message <>
+ "\n\nHint: Add the #{target_str} component before adding this component."
+ end
+
+ %__MODULE__{
+ message: message,
+ component_name: component_name,
+ reference_kind: reference_kind,
+ target: target,
+ hint: hint
+ }
+ end
+
+ defp format_target({parent, child}), do: "{#{inspect(parent)}, #{inspect(child)}}"
+ defp format_target(target), do: inspect(target)
+end
+
+defmodule Runic.IncompatiblePortError do
+ @moduledoc """
+ Raised when connecting two components with incompatible port contracts.
+
+ This occurs when `Workflow.add/3` detects that a producer's output ports
+ are not type-compatible with a consumer's input ports.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ int_step = Runic.step(fn x -> x + 1 end,
+ name: :int_producer,
+ outputs: [out: [type: :integer]]
+ )
+
+ string_consumer = Runic.step(fn x -> String.upcase(x) end,
+ name: :string_consumer,
+ inputs: [in: [type: :string]]
+ )
+
+ # This raises IncompatiblePortError
+ Workflow.new()
+ |> Workflow.add(int_step)
+ |> Workflow.add(string_consumer, to: :int_producer)
+
+ ## Solutions
+
+ 1. Ensure output types match expected input types
+ 2. Use `type: :any` for flexible ports
+ 3. Pass `validate: :off` to bypass validation during prototyping
+
+ """
+
+ defexception [:message, :producer, :consumer, :reasons]
+
+ @impl true
+ def exception(opts) do
+ producer = Keyword.fetch!(opts, :producer)
+ consumer = Keyword.fetch!(opts, :consumer)
+ reasons = Keyword.fetch!(opts, :reasons)
+
+ producer_name = component_name(producer)
+ consumer_name = component_name(consumer)
+
+ reason_lines =
+ Enum.map_join(reasons, "\n", fn
+ {:type_mismatch, p_type, c_type} ->
+ " - Type mismatch: producer outputs #{inspect(p_type)}, consumer expects #{inspect(c_type)}"
+
+ {:unmatched_port, port_name, expected_type} ->
+ " - Required input port #{inspect(port_name)} (type: #{inspect(expected_type)}) has no compatible producer output"
+ end)
+
+ message =
+ "Cannot connect #{inspect(consumer_name)} to #{inspect(producer_name)} — port contracts are incompatible:\n" <>
+ reason_lines <>
+ "\n\nHint: Use `validate: :off` to bypass port validation during prototyping."
+
+ %__MODULE__{
+ message: message,
+ producer: producer,
+ consumer: consumer,
+ reasons: reasons
+ }
+ end
+
+ defp component_name(%{name: name}) when not is_nil(name), do: name
+ defp component_name(%{hash: hash}), do: hash
+ defp component_name(other), do: inspect(other)
+end
diff --git a/vendor/runic/lib/runic/kino.ex b/vendor/runic/lib/runic/kino.ex
new file mode 100644
index 0000000..6a83814
--- /dev/null
+++ b/vendor/runic/lib/runic/kino.ex
@@ -0,0 +1,285 @@
+if Code.ensure_loaded?(Kino.JS) do
+ defmodule Runic.Kino.Mermaid do
+ @moduledoc """
+ Kino widget for rendering Runic Workflows as Mermaid diagrams in LiveBook.
+
+ Requires the optional `kino` dependency.
+
+ ## Usage
+
+ # Render workflow as flowchart
+ Runic.Kino.Mermaid.new(workflow)
+
+ # With options
+ Runic.Kino.Mermaid.new(workflow, direction: :LR, include_memory: true)
+
+ # Render causal sequence diagram
+ Runic.Kino.Mermaid.sequence(workflow)
+
+ # Render raw Mermaid code
+ Runic.Kino.Mermaid.render("flowchart TB\\n A --> B")
+ """
+
+ use Kino.JS
+
+ alias Runic.Workflow
+
+ @doc """
+ Creates a new Mermaid diagram from a Runic Workflow.
+ """
+ def new(%Workflow{} = workflow, opts \\ []) do
+ mermaid_code = Workflow.to_mermaid(workflow, opts)
+ render(mermaid_code)
+ end
+
+ @doc """
+ Creates a sequence diagram showing causal reactions in the workflow.
+ """
+ def sequence(%Workflow{} = workflow, opts \\ []) do
+ mermaid_code = Workflow.to_mermaid_sequence(workflow, opts)
+ render(mermaid_code)
+ end
+
+ @doc """
+ Renders raw Mermaid code.
+ """
+ def render(mermaid_code) when is_binary(mermaid_code) do
+ Kino.JS.new(__MODULE__, mermaid_code)
+ end
+
+ asset "main.js" do
+ """
+ export async function init(ctx, graph) {
+ ctx.root.style.background = '#1a1a2e';
+ ctx.root.style.padding = '16px';
+ ctx.root.style.borderRadius = '8px';
+ ctx.root.style.minHeight = '200px';
+
+ const container = document.createElement('div');
+ container.id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
+ ctx.root.appendChild(container);
+
+ try {
+ const mermaid = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");
+
+ mermaid.default.initialize({
+ startOnLoad: false,
+ theme: 'dark',
+ themeVariables: {
+ background: '#1a1a2e',
+ primaryColor: '#2d3748',
+ primaryTextColor: '#fff',
+ primaryBorderColor: '#4fd1c5',
+ lineColor: '#4fd1c5',
+ secondaryColor: '#553c9a',
+ tertiaryColor: '#1e3a5f'
+ },
+ flowchart: {
+ htmlLabels: true,
+ curve: 'basis'
+ },
+ sequence: {
+ actorMargin: 50,
+ showSequenceNumbers: true
+ }
+ });
+
+ const { svg, bindFunctions } = await mermaid.default.render(container.id + '-svg', graph);
+ container.innerHTML = svg;
+ if (bindFunctions) {
+ bindFunctions(container);
+ }
+ } catch (error) {
+ container.innerHTML = 'Error rendering diagram: ' + error.message + ' ';
+ console.error('Mermaid render error:', error);
+ }
+ }
+ """
+ end
+ end
+
+ defmodule Runic.Kino.Cytoscape do
+ @moduledoc """
+ Kino widget for rendering Runic Workflows as Cytoscape.js graphs in LiveBook.
+
+ Provides interactive pan/zoom and node selection.
+
+ ## Usage
+
+ # Render workflow
+ Runic.Kino.Cytoscape.new(workflow)
+
+ # With layout options
+ Runic.Kino.Cytoscape.new(workflow, layout: :dagre)
+
+ # Render raw Cytoscape elements
+ Runic.Kino.Cytoscape.render(elements)
+ """
+
+ use Kino.JS
+
+ alias Runic.Workflow
+
+ @default_opts [
+ layout: :breadthfirst,
+ include_memory: false
+ ]
+
+ @doc """
+ Creates a new Cytoscape graph from a Runic Workflow.
+ """
+ def new(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.merge(@default_opts, opts)
+ elements = Workflow.to_cytoscape(workflow, opts)
+ render(elements, opts)
+ end
+
+ @doc """
+ Renders raw Cytoscape.js elements.
+ """
+ def render(elements, opts \\ []) when is_list(elements) do
+ opts = Keyword.merge(@default_opts, opts)
+
+ config = %{
+ elements: elements,
+ layout_options: layout_config(opts[:layout])
+ }
+
+ Kino.JS.new(__MODULE__, config)
+ end
+
+ defp layout_config(:dagre) do
+ %{
+ name: "dagre",
+ rankDir: "TB",
+ nodeSep: 50,
+ rankSep: 80,
+ fit: true,
+ padding: 30
+ }
+ end
+
+ defp layout_config(:breadthfirst) do
+ %{
+ name: "breadthfirst",
+ fit: true,
+ directed: true,
+ padding: 30,
+ spacingFactor: 1.2,
+ avoidOverlap: true,
+ circle: false
+ }
+ end
+
+ defp layout_config(:cose) do
+ %{
+ name: "cose",
+ fit: true,
+ padding: 30,
+ nodeRepulsion: 8000,
+ idealEdgeLength: 100,
+ nodeOverlap: 20
+ }
+ end
+
+ defp layout_config(:grid) do
+ %{
+ name: "grid",
+ fit: true,
+ padding: 30,
+ avoidOverlap: true,
+ condense: true
+ }
+ end
+
+ defp layout_config(_), do: layout_config(:breadthfirst)
+
+ asset "main.js" do
+ """
+ import "https://cdn.jsdelivr.net/npm/cytoscape@3.26.0/dist/cytoscape.min.js";
+
+ export function init(ctx, config) {
+ ctx.root.style.width = '100%';
+ ctx.root.style.height = '500px';
+ ctx.root.style.background = '#1a1a2e';
+ ctx.root.style.borderRadius = '8px';
+
+ const cy = cytoscape({
+ container: ctx.root,
+ elements: config.elements,
+ style: [
+ {
+ selector: 'node',
+ style: {
+ 'background-color': 'data(background_color)',
+ 'label': 'data(name)',
+ 'color': '#fff',
+ 'text-valign': 'center',
+ 'text-halign': 'center',
+ 'font-size': '11px',
+ 'text-wrap': 'wrap',
+ 'text-max-width': '80px',
+ 'width': 100,
+ 'height': 50,
+ 'shape': 'data(shape)',
+ 'border-width': 2,
+ 'border-color': '#4fd1c5'
+ }
+ },
+ {
+ selector: 'node[?is_component]',
+ style: {
+ 'background-opacity': 0.3,
+ 'border-style': 'dashed',
+ 'padding': '20px'
+ }
+ },
+ {
+ selector: 'edge',
+ style: {
+ 'width': 2,
+ 'line-color': '#4fd1c5',
+ 'target-arrow-color': '#4fd1c5',
+ 'target-arrow-shape': 'triangle',
+ 'curve-style': 'bezier',
+ 'label': 'data(label)',
+ 'font-size': '9px',
+ 'color': '#a0aec0'
+ }
+ },
+ {
+ selector: 'edge[edge_type="causal"]',
+ style: {
+ 'line-style': 'dashed',
+ 'line-color': '#a0aec0'
+ }
+ },
+ {
+ selector: ':selected',
+ style: {
+ 'border-color': '#f6ad55',
+ 'border-width': 3
+ }
+ }
+ ],
+ layout: config.layout_options,
+ zoomingEnabled: true,
+ userZoomingEnabled: true,
+ panningEnabled: true,
+ userPanningEnabled: true,
+ boxSelectionEnabled: false
+ });
+
+ // Fit on load
+ cy.fit(30);
+
+ // Add click handler for node info
+ cy.on('tap', 'node', function(evt) {
+ const node = evt.target;
+ console.log('Node clicked:', node.data());
+ });
+ }
+ """
+ end
+ end
+end
diff --git a/vendor/runic/lib/runic/runner.ex b/vendor/runic/lib/runic/runner.ex
new file mode 100644
index 0000000..a889d0b
--- /dev/null
+++ b/vendor/runic/lib/runic/runner.ex
@@ -0,0 +1,330 @@
+defmodule Runic.Runner do
+ @moduledoc """
+ Built-in workflow execution infrastructure.
+
+ Provides supervision, persistence, registry, and lifecycle management
+ for running workflows as managed processes.
+
+ ## Starting a Runner
+
+ {:ok, _pid} = Runic.Runner.start_link(name: MyApp.Runner)
+
+ ## Running Workflows
+
+ {:ok, pid} = Runic.Runner.start_workflow(MyApp.Runner, :my_workflow, workflow)
+ :ok = Runic.Runner.run(MyApp.Runner, :my_workflow, input)
+ {:ok, results} = Runic.Runner.get_results(MyApp.Runner, :my_workflow)
+
+ # Structured results using output port contracts
+ {:ok, %{total: value}} = Runic.Runner.get_results(MyApp.Runner, :my_workflow, [])
+
+ # Select specific components
+ {:ok, %{price: p}} = Runic.Runner.get_results(MyApp.Runner, :id, components: [:price])
+ """
+
+ use Supervisor
+
+ # --- Public API ---
+
+ def start_link(opts) do
+ name = Keyword.fetch!(opts, :name)
+ Supervisor.start_link(__MODULE__, opts, name: name)
+ end
+
+ @impl Supervisor
+ def init(opts) do
+ name = Keyword.fetch!(opts, :name)
+
+ store_module = Keyword.get(opts, :store, Runic.Runner.Store.ETS)
+ store_opts = Keyword.get(opts, :store_opts, []) |> Keyword.put(:runner_name, name)
+
+ task_supervisor_opts = Keyword.get(opts, :task_supervisor, [])
+
+ children = [
+ {store_module, store_opts},
+ {Registry, keys: :unique, name: Module.concat(name, Registry)},
+ build_task_supervisor_child(name, task_supervisor_opts),
+ {DynamicSupervisor, name: Module.concat(name, WorkerSupervisor), strategy: :one_for_one}
+ ]
+
+ :persistent_term.put({__MODULE__, name, :store_module}, store_module)
+ :persistent_term.put({__MODULE__, name, :store_opts}, store_opts)
+
+ Supervisor.init(children, strategy: :rest_for_one)
+ end
+
+ defp build_task_supervisor_child(name, opts) when is_list(opts) do
+ {Task.Supervisor, Keyword.put(opts, :name, Module.concat(name, TaskSupervisor))}
+ end
+
+ defp build_task_supervisor_child(name, {:partition, n}) do
+ {PartitionSupervisor,
+ child_spec: Task.Supervisor, name: Module.concat(name, TaskSupervisor), partitions: n}
+ end
+
+ # --- Workflow Lifecycle ---
+
+ @doc """
+ Starts a new workflow under this runner.
+
+ Returns `{:ok, pid}` or `{:error, {:already_started, pid}}`.
+ """
+ def start_workflow(runner, workflow_id, workflow, opts \\ []) do
+ worker_spec =
+ {Runic.Runner.Worker,
+ Keyword.merge(opts,
+ runner: runner,
+ workflow_id: workflow_id,
+ workflow: workflow
+ )}
+
+ DynamicSupervisor.start_child(
+ Module.concat(runner, WorkerSupervisor),
+ worker_spec
+ )
+ end
+
+ @doc """
+ Feeds input to a running workflow.
+
+ ## Options
+
+ - `:run_context` - A map of external values keyed by component name, made available
+ to components that use `context/1` expressions. Supports a `:_global` key for
+ values available to all components.
+ """
+ def run(runner, workflow_id, input, opts \\ []) do
+ case lookup(runner, workflow_id) do
+ nil -> {:error, :not_found}
+ pid -> GenServer.cast(pid, {:run, input, opts})
+ end
+ end
+
+ @doc """
+ Returns the raw productions from a running workflow.
+
+ For structured results using port contracts, use `get_results/3`.
+ """
+ def get_results(runner, workflow_id) do
+ case lookup(runner, workflow_id) do
+ nil -> {:error, :not_found}
+ pid -> GenServer.call(pid, :get_results)
+ end
+ end
+
+ @doc """
+ Returns structured results from a running workflow.
+
+ ## Options
+
+ - `:components` — list of component names to extract. When `nil` (default),
+ uses the workflow's output port contract.
+ - `:facts` — when `true`, returns `%Fact{}` structs. Default `false`.
+ - `:all` — when `true`, returns all produced values as lists. Default `false`.
+
+ ## Examples
+
+ # Use output port contract
+ {:ok, %{total: 42.50}} = Runner.get_results(runner, :order_pipeline, [])
+
+ # Explicit component selection
+ {:ok, %{price: 42.50}} = Runner.get_results(runner, :order_pipeline, components: [:price])
+
+ # All values as facts
+ {:ok, %{total: [%Fact{}, ...]}} = Runner.get_results(runner, :id, facts: true, all: true)
+ """
+ def get_results(runner, workflow_id, opts) when is_list(opts) do
+ case lookup(runner, workflow_id) do
+ nil -> {:error, :not_found}
+ pid -> GenServer.call(pid, {:get_results, opts})
+ end
+ end
+
+ @doc """
+ Returns the full workflow struct from a running workflow.
+ """
+ def get_workflow(runner, workflow_id) do
+ case lookup(runner, workflow_id) do
+ nil -> {:error, :not_found}
+ pid -> GenServer.call(pid, :get_workflow)
+ end
+ end
+
+ @doc """
+ Stops a running workflow.
+
+ Options:
+ - `persist: true` (default) — saves final state to the store before stopping
+ - `persist: false` — stops without saving
+ """
+ def stop(runner, workflow_id, opts \\ []) do
+ case lookup(runner, workflow_id) do
+ nil -> {:error, :not_found}
+ pid -> GenServer.call(pid, {:stop, opts})
+ end
+ end
+
+ @doc """
+ Triggers an explicit checkpoint for a running workflow.
+
+ Persists the current workflow state to the store regardless of
+ the configured checkpoint strategy. Useful with `checkpoint_strategy: :manual`.
+ """
+ def checkpoint(runner, workflow_id) do
+ case lookup(runner, workflow_id) do
+ nil -> {:error, :not_found}
+ pid -> GenServer.call(pid, :checkpoint)
+ end
+ end
+
+ @doc """
+ Lists all active workflow IDs managed by this runner.
+ """
+ def list_workflows(runner) do
+ registry = Module.concat(runner, Registry)
+
+ Registry.select(registry, [
+ {{{Runic.Runner.Worker, :"$1"}, :_, :_}, [], [:"$1"]}
+ ])
+ end
+
+ @doc """
+ Looks up the PID of a running workflow by ID.
+
+ Returns `pid` or `nil`.
+ """
+ def lookup(runner, workflow_id) do
+ registry = Module.concat(runner, Registry)
+
+ case Registry.lookup(registry, {Runic.Runner.Worker, workflow_id}) do
+ [{pid, _value}] -> pid
+ [] -> nil
+ end
+ end
+
+ @doc """
+ Resumes a workflow from persisted state.
+
+ Loads the workflow log from the store, rebuilds the workflow via
+ `Workflow.from_log/1`, and starts a new Worker.
+
+ ## Options
+
+ - `:rehydration` — Controls how fact values are loaded during recovery.
+ - `:full` (default) — All fact values are loaded into memory.
+ - `:hybrid` — Uses lean replay to create `FactRef` vertices, classifies
+ hot/cold facts, then resolves only hot values from the fact store.
+ Requires a store that implements `save_fact/3` and `load_fact/2`.
+ - `:lazy` — All facts stay as `FactRef` structs, resolved on demand
+ during dispatch. Maximum memory savings, but requires resolution
+ before any fact value can be used.
+ """
+ def resume(runner, workflow_id, opts \\ []) do
+ {store_mod, store_state} = get_store(runner)
+
+ if Runic.Runner.Store.supports_stream?(store_mod) do
+ case store_mod.stream(workflow_id, store_state) do
+ {:ok, event_stream} ->
+ rehydration = Keyword.get(opts, :rehydration, :full)
+ store = {store_mod, store_state}
+ events = Enum.to_list(event_stream)
+
+ {workflow, resolver} = resume_from_events(events, rehydration, store)
+
+ worker_opts =
+ opts
+ |> Keyword.put(:resumed, true)
+ |> Keyword.put(:resolver, resolver)
+
+ start_workflow(runner, workflow_id, workflow, worker_opts)
+
+ {:error, :not_found} ->
+ # Fall back to legacy load
+ resume_from_log(runner, workflow_id, store_mod, store_state, opts)
+
+ {:error, _} = error ->
+ error
+ end
+ else
+ resume_from_log(runner, workflow_id, store_mod, store_state, opts)
+ end
+ end
+
+ defp resume_from_events(events, :full, store) do
+ # Check if any FactProduced events have been stripped of values
+ has_stripped =
+ Enum.any?(events, fn
+ %Runic.Workflow.Events.FactProduced{value: nil} -> true
+ _ -> false
+ end)
+
+ if has_stripped do
+ # Lean replay + resolve all facts to restore full in-memory state
+ workflow = Runic.Workflow.from_events(events, nil, fact_mode: :ref)
+
+ all_ref_hashes =
+ for {hash, %Runic.Workflow.FactRef{}} <- workflow.graph.vertices,
+ into: MapSet.new(),
+ do: hash
+
+ resolver = Runic.Workflow.FactResolver.new(store)
+
+ {workflow, _resolver} =
+ Runic.Workflow.Rehydration.resolve_hot(workflow, all_ref_hashes, resolver)
+
+ {workflow, nil}
+ else
+ {Runic.Workflow.from_events(events), nil}
+ end
+ end
+
+ defp resume_from_events(events, :hybrid, store) do
+ workflow = Runic.Workflow.from_events(events, nil, fact_mode: :ref)
+ %{hot: hot} = Runic.Workflow.Rehydration.classify(workflow)
+ resolver = Runic.Workflow.FactResolver.new(store)
+ {workflow, resolver} = Runic.Workflow.Rehydration.resolve_hot(workflow, hot, resolver)
+ {workflow, resolver}
+ end
+
+ defp resume_from_events(events, :lazy, store) do
+ workflow = Runic.Workflow.from_events(events, nil, fact_mode: :ref)
+ {workflow, Runic.Workflow.FactResolver.new(store)}
+ end
+
+ defp resume_from_log(runner, workflow_id, store_mod, store_state, opts) do
+ case store_mod.load(workflow_id, store_state) do
+ {:ok, log} ->
+ workflow = Runic.Workflow.from_events(log)
+ start_workflow(runner, workflow_id, workflow, opts)
+
+ {:error, _} = error ->
+ error
+ end
+ end
+
+ @doc """
+ Returns the via tuple for addressing a worker through the registry.
+ """
+ def via(runner, workflow_id) do
+ {:via, Registry, {Module.concat(runner, Registry), {Runic.Runner.Worker, workflow_id}}}
+ end
+
+ @doc """
+ Returns the `{store_module, store_state}` tuple for this runner.
+
+ The store state is initialized lazily on first access and cached in persistent_term.
+ """
+ def get_store(runner) do
+ case :persistent_term.get({__MODULE__, runner, :store}, nil) do
+ nil ->
+ store_module = :persistent_term.get({__MODULE__, runner, :store_module})
+ store_opts = :persistent_term.get({__MODULE__, runner, :store_opts})
+ {:ok, store_state} = store_module.init_store(store_opts)
+ :persistent_term.put({__MODULE__, runner, :store}, {store_module, store_state})
+ {store_module, store_state}
+
+ result ->
+ result
+ end
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/executor.ex b/vendor/runic/lib/runic/runner/executor.ex
new file mode 100644
index 0000000..acb6f1b
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/executor.ex
@@ -0,0 +1,65 @@
+defmodule Runic.Runner.Executor do
+ @moduledoc """
+ Behaviour for controlling how runnables are dispatched to compute.
+
+ Executors abstract the process/task/worker mechanism used to execute
+ runnables. The Runner's Worker calls `dispatch/3` for each runnable
+ (or Promise) and receives a handle for tracking completion.
+
+ Completion is signaled asynchronously to the calling process via
+ standard Erlang messages: `{ref, result}` and `{:DOWN, ref, ...}`.
+
+ ## Message Contract
+
+ The executor MUST arrange for the calling process to receive:
+
+ - `{handle, result}` on successful completion
+ - `{:DOWN, handle, :process, pid, reason}` on crash
+
+ This contract matches `Task.Supervisor.async_nolink` semantics,
+ making the default `Runic.Runner.Executor.Task` a zero-cost abstraction.
+
+ ## Built-in Executors
+
+ - `Runic.Runner.Executor.Task` — default, uses `Task.Supervisor.async_nolink`
+ - `:inline` — special value indicating synchronous execution in the Worker process
+ """
+
+ @type handle :: reference()
+ @type dispatch_opts :: keyword()
+ @type executor_state :: term()
+
+ @doc """
+ Initialize the executor with configuration.
+
+ Called once when the Worker starts. Returns opaque state passed
+ to subsequent `dispatch/3` and `cleanup/1` calls.
+ """
+ @callback init(opts :: keyword()) :: {:ok, executor_state()} | {:error, term()}
+
+ @doc """
+ Dispatch a unit of work for execution.
+
+ The `work_fn` is a zero-arity function that, when called, executes
+ the runnable through the PolicyDriver and returns the result.
+
+ Returns `{handle, new_state}` where `handle` is a reference the
+ Worker uses to correlate completion messages.
+
+ The executor MUST arrange for the calling process to receive:
+
+ - `{handle, result}` on successful completion
+ - `{:DOWN, handle, :process, pid, reason}` on crash
+ """
+ @callback dispatch(work_fn :: (-> term()), dispatch_opts(), executor_state()) ::
+ {handle(), executor_state()}
+
+ @doc """
+ Clean up executor resources.
+
+ Called when the Worker is stopping. Optional.
+ """
+ @callback cleanup(executor_state()) :: :ok
+
+ @optional_callbacks [cleanup: 1]
+end
diff --git a/vendor/runic/lib/runic/runner/executor/gen_stage.ex b/vendor/runic/lib/runic/runner/executor/gen_stage.ex
new file mode 100644
index 0000000..396db2d
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/executor/gen_stage.ex
@@ -0,0 +1,91 @@
+defmodule Runic.Runner.Executor.GenStage do
+ @moduledoc """
+ GenStage-based executor providing demand-driven dispatch with back-pressure.
+
+ Uses a GenStage Producer + Consumer pool architecture. The Producer buffers
+ work items and Consumers pull them on demand, providing natural back-pressure
+ that prevents the Worker from overwhelming available compute.
+
+ Each consumer processes one work item at a time (`max_demand: 1`). The number
+ of consumers controls the concurrency level. Work items that arrive when all
+ consumers are busy are buffered in the Producer until demand is available.
+
+ ## Options
+
+ * `:max_demand` — number of concurrent consumers (default: `System.schedulers_online()`)
+ * `:buffer_size` — producer event buffer size (default: `:infinity`)
+
+ ## Architecture
+
+ ```
+ Worker (caller)
+ │ dispatch/3 → cast to Producer
+ ▼
+ GenStage.Producer (buffers {handle, work_fn, caller} events)
+ │
+ ├──────────────────┤
+ ▼ ▼
+ Consumer 1 Consumer N
+ (execute work_fn) (execute work_fn)
+ │ │
+ └──── send {handle, result} / {:DOWN, handle, ...} to caller ────┘
+ ```
+
+ ## Message Contract
+
+ Preserves the standard Executor message contract:
+
+ * `{handle, result}` — sent to caller on successful completion
+ * `{:DOWN, handle, :process, pid, reason}` — sent to caller on failure
+
+ Failures are caught inside the consumer via `try/catch`, so individual work
+ item failures do not crash the consumer process.
+ """
+
+ @behaviour Runic.Runner.Executor
+
+ @impl true
+ def init(opts) do
+ max_demand = Keyword.get(opts, :max_demand, System.schedulers_online())
+ buffer_size = Keyword.get(opts, :buffer_size, :infinity)
+
+ {:ok, producer} =
+ Runic.Runner.Executor.GenStage.Producer.start_link(buffer_size: buffer_size)
+
+ consumers =
+ for _ <- 1..max_demand do
+ {:ok, pid} =
+ Runic.Runner.Executor.GenStage.Consumer.start_link(producer: producer)
+
+ pid
+ end
+
+ {:ok, %{producer: producer, consumers: consumers}}
+ end
+
+ @impl true
+ def dispatch(work_fn, _opts, %{producer: producer} = state) do
+ handle = make_ref()
+ caller = self()
+
+ Runic.Runner.Executor.GenStage.Producer.enqueue(
+ producer,
+ {handle, work_fn, caller}
+ )
+
+ {handle, state}
+ end
+
+ @impl true
+ def cleanup(%{consumers: consumers, producer: producer}) do
+ for pid <- consumers, Process.alive?(pid) do
+ GenServer.stop(pid, :normal, 5_000)
+ end
+
+ if Process.alive?(producer) do
+ GenServer.stop(producer, :normal, 5_000)
+ end
+
+ :ok
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/executor/gen_stage/consumer.ex b/vendor/runic/lib/runic/runner/executor/gen_stage/consumer.ex
new file mode 100644
index 0000000..c31f222
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/executor/gen_stage/consumer.ex
@@ -0,0 +1,29 @@
+defmodule Runic.Runner.Executor.GenStage.Consumer do
+ @moduledoc false
+ use GenStage
+
+ def start_link(opts) do
+ GenStage.start_link(__MODULE__, opts)
+ end
+
+ @impl true
+ def init(opts) do
+ producer = Keyword.fetch!(opts, :producer)
+ {:consumer, %{}, subscribe_to: [{producer, max_demand: 1, min_demand: 0}]}
+ end
+
+ @impl true
+ def handle_events(events, _from, state) do
+ for {handle, work_fn, caller} <- events do
+ try do
+ result = work_fn.()
+ send(caller, {handle, result})
+ catch
+ kind, reason ->
+ send(caller, {:DOWN, handle, :process, self(), {kind, reason}})
+ end
+ end
+
+ {:noreply, [], state}
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/executor/gen_stage/producer.ex b/vendor/runic/lib/runic/runner/executor/gen_stage/producer.ex
new file mode 100644
index 0000000..2abe704
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/executor/gen_stage/producer.ex
@@ -0,0 +1,50 @@
+defmodule Runic.Runner.Executor.GenStage.Producer do
+ @moduledoc false
+ use GenStage
+
+ def start_link(opts) do
+ GenStage.start_link(__MODULE__, opts)
+ end
+
+ def enqueue(producer, event) do
+ GenStage.cast(producer, {:enqueue, event})
+ end
+
+ @impl true
+ def init(opts) do
+ buffer_size = Keyword.get(opts, :buffer_size, :infinity)
+ {:producer, %{queue: :queue.new(), pending_demand: 0}, buffer_size: buffer_size}
+ end
+
+ @impl true
+ def handle_cast({:enqueue, event}, state) do
+ queue = :queue.in(event, state.queue)
+ {events, new_state} = dispatch_events(%{state | queue: queue})
+ {:noreply, events, new_state}
+ end
+
+ @impl true
+ def handle_demand(demand, state) do
+ {events, new_state} =
+ dispatch_events(%{state | pending_demand: state.pending_demand + demand})
+
+ {:noreply, events, new_state}
+ end
+
+ defp dispatch_events(state) do
+ {events, queue, remaining_demand} = take_events(state.queue, state.pending_demand, [])
+ {Enum.reverse(events), %{state | queue: queue, pending_demand: remaining_demand}}
+ end
+
+ defp take_events(queue, 0, acc), do: {acc, queue, 0}
+
+ defp take_events(queue, demand, acc) do
+ case :queue.out(queue) do
+ {{:value, event}, rest} ->
+ take_events(rest, demand - 1, [event | acc])
+
+ {:empty, queue} ->
+ {acc, queue, demand}
+ end
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/executor/task.ex b/vendor/runic/lib/runic/runner/executor/task.ex
new file mode 100644
index 0000000..3e16872
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/executor/task.ex
@@ -0,0 +1,25 @@
+defmodule Runic.Runner.Executor.Task do
+ @moduledoc """
+ Default executor using `Task.Supervisor.async_nolink`.
+
+ This is the zero-overhead default. The Worker's dispatch loop
+ delegates to this executor when no custom executor is configured.
+ """
+
+ @behaviour Runic.Runner.Executor
+
+ @impl true
+ def init(opts) do
+ task_supervisor = Keyword.fetch!(opts, :task_supervisor)
+ {:ok, %{task_supervisor: task_supervisor}}
+ end
+
+ @impl true
+ def dispatch(work_fn, _opts, %{task_supervisor: sup} = state) do
+ task = Task.Supervisor.async_nolink(sup, work_fn)
+ {task.ref, state}
+ end
+
+ @impl true
+ def cleanup(_state), do: :ok
+end
diff --git a/vendor/runic/lib/runic/runner/promise.ex b/vendor/runic/lib/runic/runner/promise.ex
new file mode 100644
index 0000000..0db2393
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/promise.ex
@@ -0,0 +1,91 @@
+defmodule Runic.Runner.Promise do
+ @moduledoc """
+ A batched execution unit that groups runnables for optimized dispatch.
+
+ Promises enable the scheduler to dispatch multiple runnables as a single
+ task, reducing process spawn overhead.
+
+ ## Strategies
+
+ * `:sequential` — runnables execute one after another within a single Task.
+ Each step sees the updated workflow state from the previous step. Used for
+ linear chains (e.g., `a → b → c`).
+
+ * `:parallel` — runnables execute concurrently via `Flow` (or `Task.async_stream`
+ as fallback). All runnables are independent — no intermediate state sharing.
+ Used for fan-out batches of independent runnables.
+
+ ## Failure Semantics
+
+ **Sequential:** partial commit — if runnable N fails, runnables 1..N-1
+ are committed, the failed runnable is returned for error handling, and
+ runnables N+1..end are skipped.
+
+ **Parallel:** all runnables execute independently. Individual failures are
+ caught and returned as failed runnables in the result list. Succeeded
+ runnables are committed normally.
+
+ ## Observability
+
+ Promise telemetry events are emitted in addition to per-runnable events:
+
+ * `[:runic, :runner, :promise, :start]` — promise dispatched
+ * `[:runic, :runner, :promise, :stop]` — promise completed or partially failed
+ """
+
+ @type t :: %__MODULE__{
+ id: reference(),
+ runnables: [Runic.Workflow.Runnable.t()],
+ node_hashes: MapSet.t(),
+ strategy: :sequential | :parallel,
+ status: :pending | :resolving | :resolved | :failed,
+ results: [Runic.Workflow.Runnable.t()],
+ checkpoint_boundary: boolean(),
+ flow_opts: keyword()
+ }
+
+ defstruct [
+ :id,
+ :runnables,
+ :node_hashes,
+ strategy: :sequential,
+ status: :pending,
+ results: [],
+ checkpoint_boundary: false,
+ flow_opts: []
+ ]
+
+ @doc """
+ Creates a new Promise from a list of runnables.
+
+ The `node_hashes` set is used by the scheduler to skip dispatch
+ for runnables whose nodes are already covered by an in-flight Promise.
+
+ ## Options
+
+ * `:node_hashes` — pre-computed MapSet of node hashes (default: derived from runnables)
+ * `:strategy` — `:sequential` or `:parallel` (default: `:sequential`)
+ * `:checkpoint_boundary` — whether this promise is a checkpoint boundary (default: `false`)
+ * `:flow_opts` — options for parallel resolution via Flow (default: `[]`):
+ * `:stages` — number of Flow stages (default: `min(count, schedulers_online)`)
+ * `:max_demand` — Flow max_demand per stage (default: `1`)
+ """
+ @spec new([Runic.Workflow.Runnable.t()], keyword()) :: t()
+ def new(runnables, opts \\ []) do
+ node_hashes =
+ Keyword.get_lazy(opts, :node_hashes, fn ->
+ runnables
+ |> Enum.map(& &1.node.hash)
+ |> MapSet.new()
+ end)
+
+ %__MODULE__{
+ id: make_ref(),
+ runnables: runnables,
+ node_hashes: node_hashes,
+ strategy: Keyword.get(opts, :strategy, :sequential),
+ checkpoint_boundary: Keyword.get(opts, :checkpoint_boundary, false),
+ flow_opts: Keyword.get(opts, :flow_opts, [])
+ }
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/promise_builder.ex b/vendor/runic/lib/runic/runner/promise_builder.ex
new file mode 100644
index 0000000..b7c1a98
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/promise_builder.ex
@@ -0,0 +1,147 @@
+defmodule Runic.Runner.PromiseBuilder do
+ @moduledoc """
+ Analyzes workflow graphs to construct Promises from linear chain patterns.
+
+ Given a set of prepared runnables, identifies structural chains by following
+ `:flow` edges forward from each runnable. A chain is a sequence of nodes where
+ each has exactly one successor and each successor has exactly one predecessor
+ via `:flow` edges.
+
+ The Promise captures the full chain's node hashes but only contains the
+ currently-prepared head runnable. The resolve loop inside the Worker executes
+ each step, applies it to a local workflow copy, prepares the next runnable
+ in the chain, and continues.
+
+ ## Chain Exclusions
+
+ The following nodes are excluded from chains:
+
+ * Join/FanIn nodes — they synchronize external inputs
+ * Nodes with `:meta_ref` edges — they read mutable workflow state
+
+ ## Usage
+
+ {promises, standalone} = PromiseBuilder.build_promises(workflow, runnables)
+ """
+
+ alias Runic.Runner.Promise
+ alias Runic.Workflow
+
+ @doc """
+ Given a set of prepared runnables, identifies structural linear chains
+ that can be batched into Promises.
+
+ Returns `{[Promise.t()], [Runnable.t()]}` — promises + standalone runnables
+ that could not be chained.
+
+ ## Options
+
+ * `:min_chain_length` — minimum chain length to form a Promise (default: 2)
+ """
+ @spec build_promises(Workflow.t(), [Runic.Workflow.Runnable.t()], keyword()) ::
+ {[Promise.t()], [Runic.Workflow.Runnable.t()]}
+ def build_promises(%Workflow{} = workflow, runnables, opts \\ []) do
+ min_chain_length = Keyword.get(opts, :min_chain_length, 2)
+ graph = workflow.graph
+
+ # For each runnable, walk forward via structural :flow edges to find the chain
+ {promises, chained_hashes} =
+ Enum.reduce(runnables, {[], MapSet.new()}, fn runnable, {promises, used} ->
+ node_hash = runnable.node.hash
+
+ # Skip if this node is already part of another chain
+ if MapSet.member?(used, node_hash) do
+ {promises, used}
+ else
+ chain_hashes = walk_structural_chain(graph, node_hash)
+
+ if length(chain_hashes) >= min_chain_length do
+ promise = Promise.new([runnable], node_hashes: MapSet.new(chain_hashes))
+ {[promise | promises], MapSet.union(used, MapSet.new(chain_hashes))}
+ else
+ {promises, used}
+ end
+ end
+ end)
+
+ standalone = Enum.reject(runnables, &MapSet.member?(chained_hashes, &1.node.hash))
+
+ {Enum.reverse(promises), standalone}
+ end
+
+ # Walk forward from a node via :flow edges to build a structural chain.
+ # A chain continues as long as:
+ # - Current node has exactly 1 :flow successor
+ # - That successor has exactly 1 :flow predecessor
+ # - The successor is not excluded (Join, FanIn, meta_ref)
+ defp walk_structural_chain(graph, start_hash) do
+ if excluded_node?(graph, start_hash) do
+ [start_hash]
+ else
+ do_walk_structural(graph, start_hash, [start_hash])
+ end
+ end
+
+ defp do_walk_structural(graph, current_hash, chain) do
+ successors = flow_successors(graph, current_hash)
+
+ case successors do
+ [single_succ] ->
+ # Check successor has exactly 1 flow predecessor
+ preds = flow_predecessors(graph, single_succ)
+
+ if length(preds) == 1 and not excluded_node?(graph, single_succ) do
+ do_walk_structural(graph, single_succ, chain ++ [single_succ])
+ else
+ chain
+ end
+
+ _ ->
+ chain
+ end
+ end
+
+ defp flow_successors(graph, node_hash) do
+ graph
+ |> Graph.out_edges(node_hash, by: :flow)
+ |> Enum.map(fn edge ->
+ case edge.v2 do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ _ -> edge.v2
+ end
+ end)
+ |> Enum.uniq()
+ end
+
+ defp flow_predecessors(graph, node_hash) do
+ graph
+ |> Graph.in_edges(node_hash, by: :flow)
+ |> Enum.map(fn edge ->
+ case edge.v1 do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ _ -> edge.v1
+ end
+ end)
+ |> Enum.uniq()
+ end
+
+ defp excluded_node?(graph, node_hash) do
+ node = Map.get(graph.vertices, node_hash)
+
+ cond do
+ is_nil(node) -> true
+ is_struct(node, Runic.Workflow.Join) -> true
+ is_struct(node, Runic.Workflow.FanIn) -> true
+ has_meta_refs?(graph, node_hash) -> true
+ true -> false
+ end
+ end
+
+ defp has_meta_refs?(graph, node_hash) do
+ graph
+ |> Graph.out_edges(node_hash, by: :meta_ref)
+ |> Enum.any?()
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/scheduler.ex b/vendor/runic/lib/runic/runner/scheduler.ex
new file mode 100644
index 0000000..55628ca
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/scheduler.ex
@@ -0,0 +1,77 @@
+defmodule Runic.Runner.Scheduler do
+ @moduledoc """
+ Behaviour for controlling what gets dispatched together and when.
+
+ Schedulers sit between the Worker's prepare phase and the actual dispatch,
+ deciding how runnables are grouped and ordered for execution. The Worker
+ calls `plan_dispatch/3` with the current workflow and prepared runnables,
+ and the scheduler returns a list of dispatch units — either individual
+ runnables or batched Promises.
+
+ ## Built-in Schedulers
+
+ * `Runic.Runner.Scheduler.Default` — wraps each runnable individually (zero overhead)
+ * `Runic.Runner.Scheduler.ChainBatching` — detects linear chains and batches them into Promises
+
+ ## Dispatch Units
+
+ A dispatch unit is one of:
+
+ * `{:runnable, %Runnable{}}` — dispatch as an individual task
+ * `{:promise, %Promise{}}` — dispatch as a batched chain
+
+ ## Contract
+
+ Scheduler implementations must satisfy:
+
+ * Every input runnable appears in exactly one dispatch unit
+ * No runnable appears in more than one dispatch unit
+ * Promise `node_hashes` do not overlap between dispatch units
+ * `plan_dispatch/3` handles empty runnable lists gracefully
+
+ Use `Runic.Runner.Scheduler.ContractTest` to verify implementations.
+ """
+
+ alias Runic.Workflow.Runnable
+ alias Runic.Runner.Promise
+
+ @type dispatch_unit :: {:runnable, Runnable.t()} | {:promise, Promise.t()}
+ @type scheduler_state :: term()
+
+ @doc """
+ Initialize the scheduler with configuration.
+
+ Called once when the Worker starts. Returns opaque state passed
+ to subsequent `plan_dispatch/3` and `on_complete/3` calls.
+ """
+ @callback init(opts :: keyword()) :: {:ok, scheduler_state()} | {:error, term()}
+
+ @doc """
+ Plan how to dispatch a set of prepared runnables.
+
+ Receives the current workflow and a list of runnables ready for dispatch
+ (already filtered for active tasks and concurrency limits). Returns a
+ list of dispatch units and updated scheduler state.
+
+ The Worker iterates over the returned units, routing `{:runnable, r}`
+ to individual dispatch and `{:promise, p}` to batched dispatch.
+ """
+ @callback plan_dispatch(
+ workflow :: Runic.Workflow.t(),
+ runnables :: [Runnable.t()],
+ scheduler_state()
+ ) :: {[dispatch_unit()], scheduler_state()}
+
+ @doc """
+ Called when a dispatch unit completes.
+
+ Receives the completed dispatch unit, execution duration in milliseconds,
+ and the current scheduler state. Returns updated scheduler state.
+
+ Optional — used by adaptive schedulers for profiling.
+ """
+ @callback on_complete(dispatch_unit(), duration_ms :: non_neg_integer(), scheduler_state()) ::
+ scheduler_state()
+
+ @optional_callbacks [on_complete: 3]
+end
diff --git a/vendor/runic/lib/runic/runner/scheduler/adaptive.ex b/vendor/runic/lib/runic/runner/scheduler/adaptive.ex
new file mode 100644
index 0000000..6181a6a
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/scheduler/adaptive.ex
@@ -0,0 +1,459 @@
+defmodule Runic.Runner.Scheduler.Adaptive do
+ @moduledoc """
+ Self-tuning scheduler that adjusts dispatch strategy based on runtime profiling.
+
+ Tracks per-node execution statistics via `on_complete/3` callbacks and classifies
+ nodes into capabilities for optimal dispatch grouping. During a warmup period
+ (insufficient samples), falls back to structural analysis only — equivalent to
+ `ChainBatching` / `FlowBatch` behavior.
+
+ ## Capabilities
+
+ The scheduler maintains a registry of known dispatch capabilities, evaluated
+ in priority order (lowest priority number first):
+
+ * `:fast` — average duration below `fast_threshold_ms`. Dispatched individually
+ to avoid batching overhead. Nodes classified as fast are ideal candidates for
+ `:inline` executor via `SchedulerPolicy`.
+ * `:unreliable` — error rate above `error_rate_threshold`. Dispatched individually
+ to contain failure blast radius and prevent chain contamination.
+ * `:batchable` — default classification. Eligible for chain batching (sequential
+ Promises) and parallel batching (parallel Promises via Flow).
+
+ Nodes with insufficient samples (below `warmup_samples`) are classified as
+ `:batchable` and participate in structural chain/parallel detection without
+ profiling-based overrides.
+
+ ## Profiling
+
+ Duration tracking uses an exponential moving average (EMA) to weight recent
+ observations more heavily than historical ones. The `ema_alpha` parameter
+ controls the smoothing factor — higher values make the average more responsive
+ to recent changes.
+
+ Error rates are computed as `error_count / sample_count` — a simple running ratio.
+
+ For Promise dispatch units, the total duration is distributed equally across
+ all node hashes in the promise. This is a rough estimate that converges as
+ nodes are also observed via individual dispatch.
+
+ ## Options
+
+ * `:fast_threshold_ms` — duration threshold for `:fast` classification (default: `1.0`)
+ * `:error_rate_threshold` — error rate threshold for `:unreliable` (default: `0.1`)
+ * `:warmup_samples` — minimum samples before profiling influences decisions (default: `3`)
+ * `:ema_alpha` — EMA smoothing factor, 0..1; higher = more reactive (default: `0.3`)
+ * `:min_chain_length` — minimum chain length for sequential Promises (default: `2`)
+ * `:min_batch_size` — minimum batch size for parallel Promises (default: `4`)
+ * `:flow_stages` — max Flow stages for parallel Promises (default: `System.schedulers_online()`)
+ * `:flow_max_demand` — Flow max_demand per stage (default: `1`)
+ * `:capabilities` — list of `%Capability{}` structs to override default classifications
+ * `:classifier` — custom `(NodeProfile.t(), Runnable.t() -> atom())` function that
+ bypasses capability matching entirely
+
+ ## Example
+
+ # Adaptive scheduler with aggressive inline threshold
+ Runic.Runner.start_workflow(runner, :my_workflow, workflow,
+ scheduler: Runic.Runner.Scheduler.Adaptive,
+ scheduler_opts: [
+ fast_threshold_ms: 5.0,
+ warmup_samples: 5,
+ min_chain_length: 3
+ ]
+ )
+ """
+
+ @behaviour Runic.Runner.Scheduler
+
+ alias Runic.Runner.{Promise, PromiseBuilder}
+
+ # --- Node Profile ---
+
+ defmodule NodeProfile do
+ @moduledoc """
+ Per-node execution statistics tracked by the Adaptive scheduler.
+
+ Uses exponential moving average (EMA) for duration tracking and
+ a running count for error rate calculation.
+ """
+
+ @type t :: %__MODULE__{
+ sample_count: non_neg_integer(),
+ avg_duration_ms: float(),
+ error_count: non_neg_integer(),
+ last_seen: integer()
+ }
+
+ defstruct sample_count: 0,
+ avg_duration_ms: 0.0,
+ error_count: 0,
+ last_seen: 0
+ end
+
+ # --- Capability ---
+
+ defmodule Capability do
+ @moduledoc """
+ A registered dispatch capability that the Adaptive scheduler can assign to nodes.
+
+ Each capability has a name, description, priority (lower = evaluated first),
+ and a classifier function that receives a `%NodeProfile{}` and returns
+ `true` if the node matches.
+
+ ## Example
+
+ %Capability{
+ name: :cpu_bound,
+ description: "CPU-intensive nodes that benefit from dedicated processes",
+ priority: 5,
+ classifier: fn profile -> profile.avg_duration_ms > 100.0 end
+ }
+ """
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ description: String.t(),
+ priority: non_neg_integer(),
+ classifier: (NodeProfile.t() -> boolean())
+ }
+
+ defstruct [:name, :description, :priority, :classifier]
+ end
+
+ # --- Scheduler Callbacks ---
+
+ @impl true
+ def init(opts) do
+ config = %{
+ fast_threshold_ms: Keyword.get(opts, :fast_threshold_ms, 1.0),
+ error_rate_threshold: Keyword.get(opts, :error_rate_threshold, 0.1),
+ warmup_samples: Keyword.get(opts, :warmup_samples, 3),
+ ema_alpha: Keyword.get(opts, :ema_alpha, 0.3),
+ min_chain_length: Keyword.get(opts, :min_chain_length, 2),
+ min_batch_size: Keyword.get(opts, :min_batch_size, 4),
+ flow_stages: Keyword.get(opts, :flow_stages, System.schedulers_online()),
+ flow_max_demand: Keyword.get(opts, :flow_max_demand, 1),
+ classifier: Keyword.get(opts, :classifier)
+ }
+
+ capabilities = Keyword.get(opts, :capabilities, default_capabilities(config))
+
+ {:ok, %{profiles: %{}, config: config, capabilities: capabilities}}
+ end
+
+ @impl true
+ def plan_dispatch(_workflow, [], state), do: {[], state}
+
+ def plan_dispatch(workflow, runnables, state) do
+ classified = Enum.map(runnables, fn r -> {r, classify_node(r, state)} end)
+
+ # Fast and unreliable nodes dispatch individually; batchable go through
+ # structural analysis (chain detection + parallel batch detection).
+ {individual, batchable} =
+ Enum.split_with(classified, fn {_r, cap} -> cap in [:fast, :unreliable] end)
+
+ individual_units = Enum.map(individual, fn {r, _} -> {:runnable, r} end)
+ batchable_runnables = Enum.map(batchable, fn {r, _} -> r end)
+
+ # Chain detection on batchable runnables
+ {chain_promises, standalone} =
+ PromiseBuilder.build_promises(workflow, batchable_runnables,
+ min_chain_length: state.config.min_chain_length
+ )
+
+ # Parallel batch detection on standalone batchable runnables
+ {parallel_promises, remaining} =
+ build_parallel_batches(workflow, standalone, state)
+
+ units =
+ individual_units ++
+ Enum.map(chain_promises, &{:promise, &1}) ++
+ Enum.map(parallel_promises, &{:promise, &1}) ++
+ Enum.map(remaining, &{:runnable, &1})
+
+ {units, state}
+ end
+
+ @impl true
+ def on_complete(dispatch_unit, duration_ms, state) do
+ case dispatch_unit do
+ {:runnable, runnable} ->
+ failed? = runnable.status == :failed
+ update_profile(state, runnable.node.hash, duration_ms, failed?)
+
+ {:promise, promise} ->
+ # Distribute total promise duration equally across all covered nodes.
+ # This is a rough estimate that converges as the EMA incorporates
+ # more accurate per-node observations from individual dispatch.
+ hashes = MapSet.to_list(promise.node_hashes)
+ count = max(length(hashes), 1)
+ per_node_ms = duration_ms / count
+
+ Enum.reduce(hashes, state, fn hash, acc ->
+ update_profile(acc, hash, per_node_ms, false)
+ end)
+ end
+ end
+
+ # --- Public API ---
+
+ @doc """
+ Returns the current node profile for a given node hash, or nil if untracked.
+ """
+ @spec get_profile(map(), term()) :: NodeProfile.t() | nil
+ def get_profile(state, node_hash) do
+ Map.get(state.profiles, node_hash)
+ end
+
+ @doc """
+ Returns the classification for a runnable given the current scheduler state.
+
+ During warmup (insufficient samples), returns `:batchable`.
+ After warmup, evaluates registered capabilities in priority order.
+ """
+ @spec classify_node(Runic.Workflow.Runnable.t(), map()) :: atom()
+ def classify_node(runnable, state) do
+ node_hash = runnable.node.hash
+ profile = Map.get(state.profiles, node_hash, %NodeProfile{})
+
+ if state.config.classifier do
+ state.config.classifier.(profile, runnable)
+ else
+ if profile.sample_count < state.config.warmup_samples do
+ :batchable
+ else
+ match_capability(profile, state.capabilities)
+ end
+ end
+ end
+
+ @doc """
+ Returns a summary of current profiling state for observability.
+
+ Useful for debugging and monitoring adaptive scheduler behavior.
+ """
+ @spec profile_summary(map()) :: %{
+ total_tracked: non_neg_integer(),
+ classifications: %{atom() => non_neg_integer()}
+ }
+ def profile_summary(state) do
+ tracked = Map.keys(state.profiles)
+
+ classifications =
+ Enum.frequencies_by(tracked, fn hash ->
+ profile = Map.fetch!(state.profiles, hash)
+
+ if profile.sample_count < state.config.warmup_samples do
+ :warmup
+ else
+ match_capability(profile, state.capabilities)
+ end
+ end)
+
+ %{
+ total_tracked: length(tracked),
+ classifications: classifications
+ }
+ end
+
+ # --- Default Capabilities ---
+
+ defp default_capabilities(config) do
+ [
+ %Capability{
+ name: :fast,
+ description: "Sub-threshold duration, dispatch individually",
+ priority: 1,
+ classifier: fn profile ->
+ profile.avg_duration_ms < config.fast_threshold_ms
+ end
+ },
+ %Capability{
+ name: :unreliable,
+ description: "High error rate, dispatch individually to contain failures",
+ priority: 2,
+ classifier: fn profile ->
+ error_rate =
+ if profile.sample_count > 0,
+ do: profile.error_count / profile.sample_count,
+ else: 0.0
+
+ error_rate > config.error_rate_threshold
+ end
+ },
+ %Capability{
+ name: :batchable,
+ description: "Default — eligible for chain/parallel batching",
+ priority: 100,
+ classifier: fn _profile -> true end
+ }
+ ]
+ end
+
+ # --- Classification ---
+
+ defp match_capability(profile, capabilities) do
+ capabilities
+ |> Enum.sort_by(& &1.priority)
+ |> Enum.find_value(:batchable, fn cap ->
+ if cap.classifier.(profile), do: cap.name
+ end)
+ end
+
+ # --- Profile Updates ---
+
+ defp update_profile(state, node_hash, duration_ms, failed?) do
+ alpha = state.config.ema_alpha
+ now = System.monotonic_time(:millisecond)
+
+ profile = Map.get(state.profiles, node_hash, %NodeProfile{})
+
+ new_avg =
+ if profile.sample_count == 0 do
+ duration_ms * 1.0
+ else
+ alpha * duration_ms + (1 - alpha) * profile.avg_duration_ms
+ end
+
+ updated = %NodeProfile{
+ sample_count: profile.sample_count + 1,
+ avg_duration_ms: new_avg,
+ error_count: if(failed?, do: profile.error_count + 1, else: profile.error_count),
+ last_seen: now
+ }
+
+ %{state | profiles: Map.put(state.profiles, node_hash, updated)}
+ end
+
+ # --- Parallel Batch Detection ---
+ # Adapted from FlowBatch — groups independent standalone runnables into
+ # parallel Promises for concurrent execution via Flow.
+
+ defp build_parallel_batches(_workflow, [], _state), do: {[], []}
+ defp build_parallel_batches(_workflow, [single], _state), do: {[], [single]}
+
+ defp build_parallel_batches(workflow, standalone, state) do
+ graph = workflow.graph
+
+ {eligible, ineligible} =
+ Enum.split_with(standalone, &eligible_for_parallel?(graph, &1))
+
+ eligible_hashes = MapSet.new(eligible, & &1.node.hash)
+ connected_pairs = find_connected_pairs(graph, eligible, eligible_hashes)
+ groups = find_independent_groups(eligible, connected_pairs)
+
+ {singletons, dependent_groups} =
+ Enum.split_with(groups, fn group -> length(group) == 1 end)
+
+ independent = List.flatten(singletons)
+
+ parallel_promises =
+ if length(independent) >= state.config.min_batch_size do
+ [
+ Promise.new(independent,
+ strategy: :parallel,
+ flow_opts: [
+ stages: min(length(independent), state.config.flow_stages),
+ max_demand: state.config.flow_max_demand
+ ]
+ )
+ ]
+ else
+ []
+ end
+
+ remaining =
+ if parallel_promises == [] do
+ independent
+ else
+ []
+ end ++
+ List.flatten(dependent_groups) ++ ineligible
+
+ {parallel_promises, remaining}
+ end
+
+ defp eligible_for_parallel?(graph, runnable) do
+ node_hash = runnable.node.hash
+ node = Map.get(graph.vertices, node_hash)
+
+ cond do
+ is_nil(node) -> false
+ is_struct(node, Runic.Workflow.Join) -> false
+ has_meta_refs?(graph, node_hash) -> false
+ true -> true
+ end
+ end
+
+ defp has_meta_refs?(graph, node_hash) do
+ graph
+ |> Graph.out_edges(node_hash, by: :meta_ref)
+ |> Enum.any?()
+ end
+
+ defp find_connected_pairs(graph, eligible, eligible_hashes) do
+ Enum.reduce(eligible, MapSet.new(), fn runnable, pairs ->
+ hash = runnable.node.hash
+
+ successors =
+ graph
+ |> Graph.out_edges(hash, by: :flow)
+ |> Enum.map(&extract_hash_v2/1)
+ |> Enum.filter(&MapSet.member?(eligible_hashes, &1))
+
+ predecessors =
+ graph
+ |> Graph.in_edges(hash, by: :flow)
+ |> Enum.map(&extract_hash_v1/1)
+ |> Enum.filter(&MapSet.member?(eligible_hashes, &1))
+
+ connected = successors ++ predecessors
+
+ Enum.reduce(connected, pairs, fn other_hash, acc ->
+ pair = if hash < other_hash, do: {hash, other_hash}, else: {other_hash, hash}
+ MapSet.put(acc, pair)
+ end)
+ end)
+ end
+
+ defp extract_hash_v2(edge) do
+ case edge.v2 do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ other -> other
+ end
+ end
+
+ defp extract_hash_v1(edge) do
+ case edge.v1 do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ other -> other
+ end
+ end
+
+ defp find_independent_groups(eligible, connected_pairs) do
+ initial_parents = Map.new(eligible, &{&1.node.hash, &1.node.hash})
+
+ parents =
+ Enum.reduce(connected_pairs, initial_parents, fn {h1, h2}, parents ->
+ union(parents, h1, h2)
+ end)
+
+ eligible
+ |> Enum.group_by(fn r -> find_root(parents, r.node.hash) end)
+ |> Map.values()
+ end
+
+ defp find_root(parents, node) do
+ parent = Map.fetch!(parents, node)
+ if parent == node, do: node, else: find_root(parents, parent)
+ end
+
+ defp union(parents, a, b) do
+ root_a = find_root(parents, a)
+ root_b = find_root(parents, b)
+ if root_a == root_b, do: parents, else: Map.put(parents, root_a, root_b)
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/scheduler/chain_batching.ex b/vendor/runic/lib/runic/runner/scheduler/chain_batching.ex
new file mode 100644
index 0000000..1afb705
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/scheduler/chain_batching.ex
@@ -0,0 +1,34 @@
+defmodule Runic.Runner.Scheduler.ChainBatching do
+ @moduledoc """
+ Scheduler strategy that batches linear chains into Promises.
+
+ Delegates to `Runic.Runner.PromiseBuilder` for chain detection. Linear
+ chains of runnables are grouped into Promise dispatch units; runnables
+ that don't form chains are dispatched individually.
+
+ ## Options
+
+ * `:min_chain_length` — minimum chain length to form a Promise (default: 2)
+ """
+
+ @behaviour Runic.Runner.Scheduler
+
+ alias Runic.Runner.PromiseBuilder
+
+ @impl true
+ def init(opts) do
+ {:ok, %{min_chain_length: Keyword.get(opts, :min_chain_length, 2)}}
+ end
+
+ @impl true
+ def plan_dispatch(workflow, runnables, state) do
+ {promises, standalone} =
+ PromiseBuilder.build_promises(workflow, runnables, min_chain_length: state.min_chain_length)
+
+ units =
+ Enum.map(promises, &{:promise, &1}) ++
+ Enum.map(standalone, &{:runnable, &1})
+
+ {units, state}
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/scheduler/contract_test.ex b/vendor/runic/lib/runic/runner/scheduler/contract_test.ex
new file mode 100644
index 0000000..cbbf101
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/scheduler/contract_test.ex
@@ -0,0 +1,293 @@
+defmodule Runic.Runner.Scheduler.ContractTest do
+ @moduledoc """
+ Conformance test suite for `Runic.Runner.Scheduler` behaviour implementations.
+
+ Verifies that a scheduler correctly implements the dispatch contract:
+
+ * `init/1` returns `{:ok, state}`
+ * `plan_dispatch/3` returns well-formed dispatch units
+ * All input runnables are accounted for (no dropped runnables)
+ * No duplicate runnables across dispatch units
+ * No overlapping `node_hashes` between dispatch units
+ * Promise `node_hashes` are supersets of their runnable hashes
+ * Empty input produces empty output
+ * State is properly threaded across calls
+
+ ## Usage
+
+ defmodule MySchedulerTest do
+ use Runic.Runner.Scheduler.ContractTest,
+ scheduler: MyApp.CustomScheduler,
+ opts: [my_option: true]
+ end
+ """
+
+ defmacro __using__(config) do
+ scheduler = Keyword.fetch!(config, :scheduler)
+ scheduler_opts = Keyword.get(config, :opts, [])
+
+ quote location: :keep do
+ use ExUnit.Case, async: true
+
+ require Runic
+ alias Runic.Workflow
+
+ @scheduler unquote(scheduler)
+ @scheduler_opts unquote(scheduler_opts)
+
+ # --- Test Fixtures ---
+
+ defp build_linear_chain do
+ step_a = Runic.step(fn x -> x + 1 end, name: :ct_chain_a)
+ step_b = Runic.step(fn x -> x * 2 end, name: :ct_chain_b)
+ step_c = Runic.step(fn x -> x - 1 end, name: :ct_chain_c)
+ workflow = Runic.workflow(steps: [{step_a, [{step_b, [step_c]}]}])
+ workflow = Workflow.plan_eagerly(workflow, 1)
+ Workflow.prepare_for_dispatch(workflow)
+ end
+
+ defp build_parallel_steps do
+ step_a = Runic.step(fn x -> x + 1 end, name: :ct_par_a)
+ step_b = Runic.step(fn x -> x * 2 end, name: :ct_par_b)
+ workflow = Runic.workflow(steps: [step_a, step_b])
+ workflow = Workflow.plan_eagerly(workflow, 1)
+ Workflow.prepare_for_dispatch(workflow)
+ end
+
+ defp build_fan_out do
+ step_a = Runic.step(fn x -> x + 1 end, name: :ct_fan_a)
+ step_b = Runic.step(fn x -> x * 2 end, name: :ct_fan_b)
+ step_c = Runic.step(fn x -> x * 3 end, name: :ct_fan_c)
+ workflow = Runic.workflow(steps: [{step_a, [step_b, step_c]}])
+ workflow = Workflow.plan_eagerly(workflow, 1)
+ Workflow.prepare_for_dispatch(workflow)
+ end
+
+ defp build_single_step do
+ step = Runic.step(fn x -> x + 1 end, name: :ct_single)
+ workflow = Runic.workflow(steps: [step])
+ workflow = Workflow.plan_eagerly(workflow, 1)
+ Workflow.prepare_for_dispatch(workflow)
+ end
+
+ defp extract_runnable_ids(units) do
+ Enum.flat_map(units, fn
+ {:runnable, r} -> [r.id]
+ {:promise, p} -> Enum.map(p.runnables, & &1.id)
+ end)
+ end
+
+ defp extract_covered_hashes(units) do
+ Enum.flat_map(units, fn
+ {:runnable, r} -> [r.node.hash]
+ {:promise, p} -> MapSet.to_list(p.node_hashes)
+ end)
+ end
+
+ defp assert_valid_dispatch_units(units) do
+ for unit <- units do
+ assert match?({:runnable, %Runic.Workflow.Runnable{}}, unit) or
+ match?({:promise, %Runic.Runner.Promise{}}, unit),
+ "Expected {:runnable, %Runnable{}} or {:promise, %Promise{}}, got: #{inspect(unit)}"
+ end
+ end
+
+ # --- Contract: Initialization ---
+
+ describe "#{inspect(@scheduler)} contract: initialization" do
+ test "init/1 returns {:ok, state}" do
+ assert {:ok, _state} = @scheduler.init(@scheduler_opts)
+ end
+ end
+
+ # --- Contract: Empty Input ---
+
+ describe "#{inspect(@scheduler)} contract: empty input" do
+ test "plan_dispatch with empty runnables returns empty units" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, _runnables} = build_linear_chain()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, [], state)
+ assert units == []
+ end
+ end
+
+ # --- Contract: Well-Formed Output ---
+
+ describe "#{inspect(@scheduler)} contract: well-formed output" do
+ test "all dispatch units are valid with parallel input" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+ assert_valid_dispatch_units(units)
+ end
+
+ test "all dispatch units are valid with single runnable input" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_single_step()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+ refute Enum.empty?(units)
+ assert_valid_dispatch_units(units)
+ end
+
+ test "all dispatch units are valid with fan-out input" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_fan_out()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+ assert_valid_dispatch_units(units)
+ end
+
+ test "all dispatch units are valid with linear chain input" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_linear_chain()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+ refute Enum.empty?(units)
+ assert_valid_dispatch_units(units)
+ end
+ end
+
+ # --- Contract: Runnable Accounting ---
+
+ describe "#{inspect(@scheduler)} contract: runnable accounting" do
+ test "every input runnable appears in output dispatch units" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ output_ids = MapSet.new(extract_runnable_ids(units))
+ input_ids = MapSet.new(runnables, & &1.id)
+
+ assert MapSet.subset?(input_ids, output_ids),
+ "Input runnables missing from output.\n" <>
+ " Missing: #{inspect(MapSet.difference(input_ids, output_ids))}"
+ end
+
+ test "no runnable appears in more than one dispatch unit" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ all_ids = extract_runnable_ids(units)
+
+ assert length(all_ids) == length(Enum.uniq(all_ids)),
+ "Duplicate runnable IDs in dispatch units: #{inspect(all_ids -- Enum.uniq(all_ids))}"
+ end
+
+ test "dispatch units only reference input runnables" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ output_ids = MapSet.new(extract_runnable_ids(units))
+ input_ids = MapSet.new(runnables, & &1.id)
+
+ assert MapSet.subset?(output_ids, input_ids),
+ "Scheduler introduced runnables not in input: #{inspect(MapSet.difference(output_ids, input_ids))}"
+ end
+
+ test "single runnable is accounted for" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_single_step()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ output_ids = MapSet.new(extract_runnable_ids(units))
+ input_ids = MapSet.new(runnables, & &1.id)
+ assert MapSet.equal?(input_ids, output_ids)
+ end
+ end
+
+ # --- Contract: Hash Isolation ---
+
+ describe "#{inspect(@scheduler)} contract: hash isolation" do
+ test "no overlapping node_hashes between dispatch units" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ all_hashes = extract_covered_hashes(units)
+
+ assert length(all_hashes) == length(Enum.uniq(all_hashes)),
+ "Overlapping node hashes between dispatch units: #{inspect(all_hashes -- Enum.uniq(all_hashes))}"
+ end
+
+ test "promise node_hashes are non-empty" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_linear_chain()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ for {:promise, promise} <- units do
+ assert MapSet.size(promise.node_hashes) > 0,
+ "Promise #{inspect(promise.id)} has empty node_hashes"
+ end
+ end
+
+ test "promise runnables hashes are subset of promise node_hashes" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_linear_chain()
+
+ {units, _new_state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ for {:promise, promise} <- units do
+ for r <- promise.runnables do
+ assert MapSet.member?(promise.node_hashes, r.node.hash),
+ "Promise runnable hash #{inspect(r.node.hash)} not in node_hashes"
+ end
+ end
+ end
+ end
+
+ # --- Contract: State Threading ---
+
+ describe "#{inspect(@scheduler)} contract: state threading" do
+ test "state is properly threaded across multiple plan_dispatch calls" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {_units1, state} = @scheduler.plan_dispatch(workflow, runnables, state)
+ {units2, _state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ assert_valid_dispatch_units(units2)
+ end
+
+ test "repeated calls with same input produce consistent results" do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+
+ {units1, state1} = @scheduler.plan_dispatch(workflow, runnables, state)
+ {units2, _state2} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ ids1 = extract_runnable_ids(units1) |> Enum.sort()
+ ids2 = extract_runnable_ids(units2) |> Enum.sort()
+ assert ids1 == ids2
+ end
+ end
+
+ # --- Contract: on_complete (if implemented) ---
+
+ describe "#{inspect(@scheduler)} contract: on_complete" do
+ test "on_complete/3 returns state when implemented" do
+ if function_exported?(@scheduler, :on_complete, 3) do
+ {:ok, state} = @scheduler.init(@scheduler_opts)
+ {workflow, runnables} = build_parallel_steps()
+ {units, state} = @scheduler.plan_dispatch(workflow, runnables, state)
+
+ for unit <- units do
+ result = apply(@scheduler, :on_complete, [unit, 100, state])
+ assert result != nil, "on_complete/3 returned nil"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/scheduler/default.ex b/vendor/runic/lib/runic/runner/scheduler/default.ex
new file mode 100644
index 0000000..e07ddc1
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/scheduler/default.ex
@@ -0,0 +1,20 @@
+defmodule Runic.Runner.Scheduler.Default do
+ @moduledoc """
+ Default scheduler strategy that dispatches each runnable individually.
+
+ This is the zero-overhead default. Each prepared runnable becomes a
+ `{:runnable, r}` dispatch unit — identical to the pre-scheduler Worker
+ behavior.
+ """
+
+ @behaviour Runic.Runner.Scheduler
+
+ @impl true
+ def init(_opts), do: {:ok, %{}}
+
+ @impl true
+ def plan_dispatch(_workflow, runnables, state) do
+ units = Enum.map(runnables, &{:runnable, &1})
+ {units, state}
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/scheduler/flow_batch.ex b/vendor/runic/lib/runic/runner/scheduler/flow_batch.ex
new file mode 100644
index 0000000..0d997cc
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/scheduler/flow_batch.ex
@@ -0,0 +1,209 @@
+defmodule Runic.Runner.Scheduler.FlowBatch do
+ @moduledoc """
+ Scheduler strategy that detects both sequential chains and parallel batch
+ opportunities.
+
+ Extends the chain-detection logic from `Runic.Runner.PromiseBuilder` with
+ independent-runnable grouping: after sequential chains are extracted, any
+ remaining standalone runnables that share no graph edges are grouped into a
+ single `:parallel` Promise for concurrent execution via Flow (or
+ `Task.async_stream` as fallback).
+
+ ## Options
+
+ * `:min_chain_length` — minimum chain length to form a sequential Promise (default: 2)
+ * `:min_batch_size` — minimum group size to form a parallel Promise (default: 4)
+ * `:flow_stages` — max Flow stages for parallel Promises (default: `System.schedulers_online()`)
+ * `:flow_max_demand` — Flow max_demand per stage (default: 1)
+ """
+
+ @behaviour Runic.Runner.Scheduler
+
+ alias Runic.Runner.{Promise, PromiseBuilder}
+
+ @impl true
+ def init(opts) do
+ {:ok,
+ %{
+ min_chain_length: Keyword.get(opts, :min_chain_length, 2),
+ min_batch_size: Keyword.get(opts, :min_batch_size, 4),
+ flow_stages: Keyword.get(opts, :flow_stages, System.schedulers_online()),
+ flow_max_demand: Keyword.get(opts, :flow_max_demand, 1)
+ }}
+ end
+
+ @impl true
+ def plan_dispatch(workflow, runnables, state) do
+ # Step 1: Extract sequential chains (reuse PromiseBuilder)
+ {seq_promises, standalone} =
+ PromiseBuilder.build_promises(workflow, runnables, min_chain_length: state.min_chain_length)
+
+ # Step 2: From standalone runnables, find independent groups for parallel batching
+ {parallel_promises, remaining} =
+ build_parallel_batches(workflow, standalone, state)
+
+ units =
+ Enum.map(seq_promises, &{:promise, &1}) ++
+ Enum.map(parallel_promises, &{:promise, &1}) ++
+ Enum.map(remaining, &{:runnable, &1})
+
+ {units, state}
+ end
+
+ # --- Parallel Batch Detection ---
+
+ defp build_parallel_batches(_workflow, [], _state), do: {[], []}
+
+ defp build_parallel_batches(_workflow, [single], _state), do: {[], [single]}
+
+ defp build_parallel_batches(workflow, standalone, state) do
+ graph = workflow.graph
+
+ # Filter out runnables that are excluded from parallel batching
+ {eligible, ineligible} =
+ Enum.split_with(standalone, &eligible_for_parallel?(graph, &1))
+
+ # Build an adjacency set: which eligible runnables share graph edges?
+ eligible_hashes = MapSet.new(eligible, & &1.node.hash)
+
+ # For each eligible runnable, check if it has any :flow edge connections
+ # to other eligible runnables. If not, it's independent.
+ connected_pairs = find_connected_pairs(graph, eligible, eligible_hashes)
+
+ # Group into connected components using union-find.
+ # Runnables in the same component share :flow edges and cannot be parallelized.
+ # Runnables in different components (especially singletons) are independent.
+ groups = find_independent_groups(eligible, connected_pairs)
+
+ # Separate truly independent runnables (singleton components — no edges to
+ # other eligibles) from dependent groups (components with internal edges).
+ {singletons, dependent_groups} =
+ Enum.split_with(groups, fn group -> length(group) == 1 end)
+
+ # All singletons are independent → merge into one parallel batch candidate
+ independent = List.flatten(singletons)
+
+ parallel_promises =
+ if length(independent) >= state.min_batch_size do
+ [
+ Promise.new(independent,
+ strategy: :parallel,
+ flow_opts: [
+ stages: min(length(independent), state.flow_stages),
+ max_demand: state.flow_max_demand
+ ]
+ )
+ ]
+ else
+ []
+ end
+
+ # Remaining: below-threshold independents + dependent group members + ineligibles
+ remaining =
+ if parallel_promises == [] do
+ independent
+ else
+ []
+ end ++
+ List.flatten(dependent_groups) ++ ineligible
+
+ {parallel_promises, remaining}
+ end
+
+ defp eligible_for_parallel?(graph, runnable) do
+ node_hash = runnable.node.hash
+ node = Map.get(graph.vertices, node_hash)
+
+ cond do
+ is_nil(node) -> false
+ is_struct(node, Runic.Workflow.Join) -> false
+ is_struct(node, Runic.Workflow.FanIn) -> false
+ has_meta_refs?(graph, node_hash) -> false
+ true -> true
+ end
+ end
+
+ defp has_meta_refs?(graph, node_hash) do
+ graph
+ |> Graph.out_edges(node_hash, by: :meta_ref)
+ |> Enum.any?()
+ end
+
+ # Find pairs of eligible runnables that are connected via :flow edges
+ # (directly or transitively through the graph).
+ # Two runnables are "connected" if there's any :flow edge path between them.
+ defp find_connected_pairs(graph, eligible, eligible_hashes) do
+ Enum.reduce(eligible, MapSet.new(), fn runnable, pairs ->
+ hash = runnable.node.hash
+
+ # Check :flow successors that are also in the eligible set
+ successors =
+ graph
+ |> Graph.out_edges(hash, by: :flow)
+ |> Enum.map(&extract_hash/1)
+ |> Enum.filter(&MapSet.member?(eligible_hashes, &1))
+
+ # Check :flow predecessors that are also in the eligible set
+ predecessors =
+ graph
+ |> Graph.in_edges(hash, by: :flow)
+ |> Enum.map(&extract_hash_v1/1)
+ |> Enum.filter(&MapSet.member?(eligible_hashes, &1))
+
+ connected = successors ++ predecessors
+
+ Enum.reduce(connected, pairs, fn other_hash, acc ->
+ pair = if hash < other_hash, do: {hash, other_hash}, else: {other_hash, hash}
+ MapSet.put(acc, pair)
+ end)
+ end)
+ end
+
+ defp extract_hash(edge) do
+ case edge.v2 do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ other -> other
+ end
+ end
+
+ defp extract_hash_v1(edge) do
+ case edge.v1 do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ other -> other
+ end
+ end
+
+ # Group runnables into connected components using union-find.
+ # Runnables with no connections to other eligible runnables form singleton groups
+ # initially, and connected pairs merge their groups.
+ defp find_independent_groups(eligible, connected_pairs) do
+ # Initialize: each runnable is its own group, keyed by node hash
+ initial_parents = Map.new(eligible, &{&1.node.hash, &1.node.hash})
+
+ # Union connected pairs
+ parents =
+ Enum.reduce(connected_pairs, initial_parents, fn {h1, h2}, parents ->
+ union(parents, h1, h2)
+ end)
+
+ # Group by root
+ eligible
+ |> Enum.group_by(fn r -> find_root(parents, r.node.hash) end)
+ |> Map.values()
+ end
+
+ # Union-find: find root
+ defp find_root(parents, node) do
+ parent = Map.fetch!(parents, node)
+ if parent == node, do: node, else: find_root(parents, parent)
+ end
+
+ # Union-find: union two sets
+ defp union(parents, a, b) do
+ root_a = find_root(parents, a)
+ root_b = find_root(parents, b)
+ if root_a == root_b, do: parents, else: Map.put(parents, root_a, root_b)
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/store.ex b/vendor/runic/lib/runic/runner/store.ex
new file mode 100644
index 0000000..28c12f6
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/store.ex
@@ -0,0 +1,88 @@
+defmodule Runic.Runner.Store do
+ @moduledoc """
+ Behaviour for workflow persistence adapters.
+
+ Adapters handle saving and loading workflow event logs for
+ durability across process restarts.
+
+ ## Stream Semantics (Event-Sourced)
+
+ The preferred interface uses `append/3` and `stream/2` for incremental
+ event persistence. Events are appended after each execution cycle and
+ streamed on recovery to rebuild workflow state via `Workflow.from_events/1`.
+
+ Stores that implement `append/3` and `stream/2` get automatic
+ event-sourced checkpointing and recovery from the Worker.
+
+ ## Legacy Semantics (Snapshot)
+
+ The `save/3` and `load/2` callbacks persist the full workflow log as a
+ snapshot. These remain the required baseline interface for backward
+ compatibility. Stores that only implement `save/load` continue to work
+ unchanged.
+
+ ## Optional Capabilities
+
+ - **Snapshots** (`save_snapshot/4`, `load_snapshot/3`): Point-in-time
+ workflow snapshots for faster recovery (replay from snapshot + events
+ after cursor instead of full replay).
+ - **Fact storage** (`save_fact/3`, `load_fact/2`): Content-addressed fact
+ value storage for hybrid rehydration without loading all values into memory.
+ """
+
+ @type workflow_id :: term()
+ @type event :: struct()
+ @type cursor :: non_neg_integer()
+ @type log :: [struct()]
+ @type state :: term()
+
+ # Core (required) — snapshot-based
+ @callback init_store(opts :: keyword()) :: {:ok, state()} | {:error, term()}
+ @callback save(workflow_id(), log(), state()) :: :ok | {:error, term()}
+ @callback load(workflow_id(), state()) :: {:ok, log()} | {:error, :not_found | term()}
+
+ # Stream semantics (optional — event-sourced)
+ @callback append(workflow_id(), events :: [event()], state()) ::
+ {:ok, cursor()} | {:error, term()}
+ @callback stream(workflow_id(), state()) ::
+ {:ok, Enumerable.t()} | {:error, :not_found | term()}
+
+ # Snapshot (optional — faster recovery with stream semantics)
+ @callback save_snapshot(workflow_id(), cursor(), snapshot :: binary(), state()) ::
+ :ok | {:error, term()}
+ @callback load_snapshot(workflow_id(), state()) ::
+ {:ok, {cursor(), binary()}} | {:error, :not_found | term()}
+
+ # Fact-level storage (optional — hybrid rehydration)
+ @callback save_fact(fact_hash :: term(), value :: term(), state()) ::
+ :ok | {:error, term()}
+ @callback load_fact(fact_hash :: term(), state()) ::
+ {:ok, term()} | {:error, :not_found | term()}
+
+ # Lifecycle (optional)
+ @callback checkpoint(workflow_id(), log(), state()) :: :ok | {:error, term()}
+ @callback delete(workflow_id(), state()) :: :ok | {:error, term()}
+ @callback list(state()) :: {:ok, [workflow_id()]} | {:error, term()}
+ @callback exists?(workflow_id(), state()) :: boolean()
+
+ @optional_callbacks [
+ append: 3,
+ stream: 2,
+ save_snapshot: 4,
+ load_snapshot: 2,
+ save_fact: 3,
+ load_fact: 2,
+ checkpoint: 3,
+ delete: 2,
+ list: 1,
+ exists?: 2
+ ]
+
+ @doc """
+ Returns true if the store module supports event-sourced stream semantics.
+ """
+ @spec supports_stream?(module()) :: boolean()
+ def supports_stream?(store_mod) do
+ function_exported?(store_mod, :append, 3) and function_exported?(store_mod, :stream, 2)
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/store/ets.ex b/vendor/runic/lib/runic/runner/store/ets.ex
new file mode 100644
index 0000000..bb0416e
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/store/ets.ex
@@ -0,0 +1,200 @@
+defmodule Runic.Runner.Store.ETS do
+ @moduledoc """
+ Default in-memory persistence adapter using ETS.
+
+ Survives worker restarts within the same VM but not VM restarts.
+ The GenServer owns the ETS tables, while Store callbacks operate
+ on the :public tables directly for zero-overhead reads and writes.
+
+ ## Stream Semantics
+
+ Supports event-sourced `append/3` and `stream/2` via a second
+ `:ordered_set` ETS table keyed by `{workflow_id, sequence}`.
+ A counter table tracks the next sequence number per workflow.
+ """
+
+ @behaviour Runic.Runner.Store
+ use GenServer
+
+ # --- Store Behaviour (snapshot) ---
+
+ @impl Runic.Runner.Store
+ def init_store(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+ table_name = Module.concat(runner_name, StoreTable)
+ events_table = Module.concat(runner_name, StoreEvents)
+ counters_table = Module.concat(runner_name, StoreCounters)
+ facts_table = Module.concat(runner_name, FactTable)
+
+ {:ok,
+ %{
+ table: table_name,
+ events_table: events_table,
+ counters_table: counters_table,
+ facts_table: facts_table,
+ runner_name: runner_name
+ }}
+ end
+
+ @impl Runic.Runner.Store
+ def save(workflow_id, log, %{table: table}) do
+ :ets.insert(table, {workflow_id, log, System.monotonic_time(:millisecond)})
+ :ok
+ end
+
+ @impl Runic.Runner.Store
+ def load(workflow_id, %{table: table}) do
+ case :ets.lookup(table, workflow_id) do
+ [{^workflow_id, log, _updated_at}] -> {:ok, log}
+ [] -> {:error, :not_found}
+ end
+ end
+
+ @impl Runic.Runner.Store
+ def checkpoint(workflow_id, log, state), do: save(workflow_id, log, state)
+
+ # --- Store Behaviour (stream semantics) ---
+
+ @impl Runic.Runner.Store
+ def append(workflow_id, events, %{events_table: events_table, counters_table: counters_table})
+ when is_list(events) do
+ count = length(events)
+
+ cursor =
+ :ets.update_counter(counters_table, workflow_id, {2, count}, {workflow_id, 0})
+
+ start_seq = cursor - count + 1
+
+ events
+ |> Enum.with_index(start_seq)
+ |> Enum.each(fn {event, seq} ->
+ :ets.insert(events_table, {{workflow_id, seq}, event})
+ end)
+
+ {:ok, cursor}
+ end
+
+ @impl Runic.Runner.Store
+ def stream(workflow_id, %{events_table: events_table, counters_table: counters_table}) do
+ case :ets.lookup(counters_table, workflow_id) do
+ [] ->
+ {:error, :not_found}
+
+ [{^workflow_id, _count}] ->
+ stream =
+ Stream.resource(
+ fn -> {workflow_id, 1} end,
+ fn {wf_id, seq} ->
+ case :ets.lookup(events_table, {wf_id, seq}) do
+ [{{^wf_id, ^seq}, event}] -> {[event], {wf_id, seq + 1}}
+ [] -> {:halt, {wf_id, seq}}
+ end
+ end,
+ fn _acc -> :ok end
+ )
+
+ {:ok, stream}
+ end
+ end
+
+ # --- Store Behaviour (fact storage) ---
+
+ @impl Runic.Runner.Store
+ def save_fact(fact_hash, value, %{facts_table: facts_table}) do
+ :ets.insert(facts_table, {fact_hash, value})
+ :ok
+ end
+
+ @impl Runic.Runner.Store
+ def load_fact(fact_hash, %{facts_table: facts_table}) do
+ case :ets.lookup(facts_table, fact_hash) do
+ [{^fact_hash, value}] -> {:ok, value}
+ [] -> {:error, :not_found}
+ end
+ end
+
+ # --- Lifecycle ---
+
+ @impl Runic.Runner.Store
+ def delete(workflow_id, %{table: table} = state) do
+ :ets.delete(table, workflow_id)
+ delete_events(workflow_id, state)
+ :ok
+ end
+
+ @impl Runic.Runner.Store
+ def list(%{table: table}) do
+ ids = :ets.select(table, [{{:"$1", :_, :_}, [], [:"$1"]}])
+ {:ok, ids}
+ end
+
+ @impl Runic.Runner.Store
+ def exists?(workflow_id, %{table: table, counters_table: counters_table}) do
+ :ets.member(table, workflow_id) or :ets.member(counters_table, workflow_id)
+ end
+
+ defp delete_events(workflow_id, %{events_table: events_table, counters_table: counters_table}) do
+ case :ets.lookup(counters_table, workflow_id) do
+ [{^workflow_id, count}] ->
+ for seq <- 1..count do
+ :ets.delete(events_table, {workflow_id, seq})
+ end
+
+ :ets.delete(counters_table, workflow_id)
+
+ [] ->
+ :ok
+ end
+ end
+
+ # --- GenServer (ETS table owner) ---
+
+ def start_link(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+ GenServer.start_link(__MODULE__, opts, name: Module.concat(runner_name, Store))
+ end
+
+ def child_spec(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+
+ %{
+ id: {__MODULE__, runner_name},
+ start: {__MODULE__, :start_link, [opts]},
+ type: :worker
+ }
+ end
+
+ @impl GenServer
+ def init(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+ table_name = Module.concat(runner_name, StoreTable)
+ events_table = Module.concat(runner_name, StoreEvents)
+ counters_table = Module.concat(runner_name, StoreCounters)
+ facts_table = Module.concat(runner_name, FactTable)
+
+ _table = :ets.new(table_name, [:named_table, :set, :public, read_concurrency: true])
+
+ _events =
+ :ets.new(events_table, [:named_table, :ordered_set, :public, read_concurrency: true])
+
+ _counters =
+ :ets.new(counters_table, [
+ :named_table,
+ :set,
+ :public,
+ read_concurrency: true,
+ write_concurrency: true
+ ])
+
+ _facts = :ets.new(facts_table, [:named_table, :set, :public, read_concurrency: true])
+
+ {:ok,
+ %{
+ table: table_name,
+ events_table: events_table,
+ counters_table: counters_table,
+ facts_table: facts_table,
+ runner_name: runner_name
+ }}
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/store/mnesia.ex b/vendor/runic/lib/runic/runner/store/mnesia.ex
new file mode 100644
index 0000000..cf22286
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/store/mnesia.ex
@@ -0,0 +1,396 @@
+defmodule Runic.Runner.Store.Mnesia do
+ @moduledoc """
+ Mnesia-backed persistence adapter for the Runner.
+
+ Uses Erlang/OTP's built-in Mnesia database for workflow log storage.
+ Provides persistence across VM restarts (via `disc_copies`) and
+ distributed storage across Erlang clusters.
+
+ ## Usage
+
+ {:ok, _} = Runic.Runner.start_link(
+ name: MyApp.Runner,
+ store: Runic.Runner.Store.Mnesia,
+ store_opts: [disc_copies: true]
+ )
+
+ ## Options
+
+ * `:runner_name` — (required) the Runner name, used to derive the Mnesia table name
+ * `:disc_copies` — when `true` (default), persists to disk on this node.
+ Set to `false` for RAM-only tables (faster, lost on VM restart).
+ * `:nodes` — list of nodes for distributed tables.
+ Defaults to `[node()]`.
+
+ ## Mnesia Schema
+
+ If Mnesia has not been initialized with a schema directory on this node,
+ one will be created automatically. For production use, configure the
+ Mnesia directory via the `:mnesia` application env:
+
+ config :mnesia, dir: ~c"/var/data/mnesia/\#{node()}"
+
+ ## Distributed Operation
+
+ For multi-node Mnesia clusters, ensure `:mnesia.change_config(:extra_db_nodes, nodes)`
+ is called before starting the Runner, or pass `:nodes` in store opts.
+
+ ## Stream Semantics
+
+ Supports event-sourced `append/3` and `stream/2` via a second Mnesia table
+ (`*EventStream`) with `{workflow_id, sequence}` compound keys for ordered
+ event retrieval.
+
+ ## Design Notes
+
+ * Write operations use `:mnesia.transaction/1` for ACID guarantees.
+ * Read operations use `:mnesia.dirty_read/2` — no transaction overhead,
+ eventually consistent during concurrent writes (sufficient for workflow
+ log reads, which are dominated by the owning Worker process).
+ * `checkpoint/3` delegates to `save/3` — Mnesia's transaction log already
+ provides write-ahead durability.
+ """
+
+ @behaviour Runic.Runner.Store
+ use GenServer
+
+ require Logger
+
+ # --- Store Behaviour ---
+
+ @impl Runic.Runner.Store
+ def init_store(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+ table_name = table_name(runner_name)
+ events_table = events_table_name(runner_name)
+ counters_table = counters_table_name(runner_name)
+ facts_table = facts_table_name(runner_name)
+
+ {:ok,
+ %{
+ table: table_name,
+ events_table: events_table,
+ counters_table: counters_table,
+ facts_table: facts_table,
+ runner_name: runner_name
+ }}
+ end
+
+ @impl Runic.Runner.Store
+ def save(workflow_id, log, %{table: table}) do
+ record = {table, workflow_id, log, System.monotonic_time(:millisecond)}
+
+ case :mnesia.transaction(fn -> :mnesia.write(record) end) do
+ {:atomic, :ok} -> :ok
+ {:aborted, reason} -> {:error, {:mnesia_write_failed, reason}}
+ end
+ end
+
+ @impl Runic.Runner.Store
+ def load(workflow_id, %{table: table}) do
+ case :mnesia.dirty_read(table, workflow_id) do
+ [{^table, ^workflow_id, log, _updated_at}] -> {:ok, log}
+ [] -> {:error, :not_found}
+ end
+ end
+
+ @impl Runic.Runner.Store
+ def checkpoint(workflow_id, log, state), do: save(workflow_id, log, state)
+
+ # --- Stream semantics ---
+
+ @impl Runic.Runner.Store
+ def append(workflow_id, events, %{events_table: events_table, counters_table: counters_table})
+ when is_list(events) do
+ case :mnesia.transaction(fn ->
+ # Atomically increment counter and write events
+ cursor =
+ case :mnesia.read(counters_table, workflow_id) do
+ [{^counters_table, ^workflow_id, current}] -> current
+ [] -> 0
+ end
+
+ count = length(events)
+ new_cursor = cursor + count
+ :mnesia.write({counters_table, workflow_id, new_cursor})
+
+ events
+ |> Enum.with_index(cursor + 1)
+ |> Enum.each(fn {event, seq} ->
+ :mnesia.write({events_table, {workflow_id, seq}, event})
+ end)
+
+ new_cursor
+ end) do
+ {:atomic, cursor} -> {:ok, cursor}
+ {:aborted, reason} -> {:error, {:mnesia_append_failed, reason}}
+ end
+ end
+
+ @impl Runic.Runner.Store
+ def stream(workflow_id, %{events_table: events_table, counters_table: counters_table}) do
+ case :mnesia.dirty_read(counters_table, workflow_id) do
+ [] ->
+ {:error, :not_found}
+
+ [{^counters_table, ^workflow_id, count}] ->
+ stream =
+ Stream.resource(
+ fn -> 1 end,
+ fn
+ seq when seq > count ->
+ {:halt, seq}
+
+ seq ->
+ case :mnesia.dirty_read(events_table, {workflow_id, seq}) do
+ [{^events_table, {^workflow_id, ^seq}, event}] -> {[event], seq + 1}
+ [] -> {:halt, seq}
+ end
+ end,
+ fn _acc -> :ok end
+ )
+
+ {:ok, stream}
+ end
+ end
+
+ # --- Fact storage ---
+
+ @impl Runic.Runner.Store
+ def save_fact(fact_hash, value, %{facts_table: facts_table}) do
+ record = {facts_table, fact_hash, value}
+
+ case :mnesia.transaction(fn -> :mnesia.write(record) end) do
+ {:atomic, :ok} -> :ok
+ {:aborted, reason} -> {:error, {:mnesia_write_failed, reason}}
+ end
+ end
+
+ @impl Runic.Runner.Store
+ def load_fact(fact_hash, %{facts_table: facts_table}) do
+ case :mnesia.dirty_read(facts_table, fact_hash) do
+ [{^facts_table, ^fact_hash, value}] -> {:ok, value}
+ [] -> {:error, :not_found}
+ end
+ end
+
+ # --- Lifecycle ---
+
+ @impl Runic.Runner.Store
+ def delete(workflow_id, %{table: table} = state) do
+ result =
+ case :mnesia.transaction(fn -> :mnesia.delete({table, workflow_id}) end) do
+ {:atomic, :ok} -> :ok
+ {:aborted, reason} -> {:error, {:mnesia_delete_failed, reason}}
+ end
+
+ delete_events(workflow_id, state)
+ result
+ end
+
+ @impl Runic.Runner.Store
+ def list(%{table: table}) do
+ ids = :mnesia.dirty_all_keys(table)
+ {:ok, ids}
+ end
+
+ @impl Runic.Runner.Store
+ def exists?(workflow_id, %{table: table, counters_table: counters_table}) do
+ case :mnesia.dirty_read(table, workflow_id) do
+ [_ | _] ->
+ true
+
+ [] ->
+ case :mnesia.dirty_read(counters_table, workflow_id) do
+ [_ | _] -> true
+ [] -> false
+ end
+ end
+ end
+
+ defp delete_events(workflow_id, %{events_table: events_table, counters_table: counters_table}) do
+ case :mnesia.dirty_read(counters_table, workflow_id) do
+ [{^counters_table, ^workflow_id, count}] ->
+ :mnesia.transaction(fn ->
+ for seq <- 1..count do
+ :mnesia.delete({events_table, {workflow_id, seq}})
+ end
+
+ :mnesia.delete({counters_table, workflow_id})
+ end)
+
+ [] ->
+ :ok
+ end
+ end
+
+ # --- GenServer (table lifecycle) ---
+
+ def start_link(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+ GenServer.start_link(__MODULE__, opts, name: Module.concat(runner_name, Store))
+ end
+
+ def child_spec(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+
+ %{
+ id: {__MODULE__, runner_name},
+ start: {__MODULE__, :start_link, [opts]},
+ type: :worker
+ }
+ end
+
+ @impl GenServer
+ def init(opts) do
+ runner_name = Keyword.fetch!(opts, :runner_name)
+ disc_copies? = Keyword.get(opts, :disc_copies, true)
+ nodes = Keyword.get(opts, :nodes, [node()])
+
+ table = table_name(runner_name)
+ events_table = events_table_name(runner_name)
+ counters_table = counters_table_name(runner_name)
+ facts_table = facts_table_name(runner_name)
+
+ ensure_schema(disc_copies?, nodes)
+ ensure_mnesia_started()
+ ensure_table(table, disc_copies?, nodes)
+ ensure_events_table(events_table, disc_copies?, nodes)
+ ensure_counters_table(counters_table, disc_copies?, nodes)
+ ensure_facts_table(facts_table, disc_copies?, nodes)
+
+ {:ok, %{table: table, runner_name: runner_name}}
+ end
+
+ # --- Mnesia Setup ---
+
+ defp ensure_schema(true = _disc_copies?, nodes) do
+ # create_schema fails if already exists — that's fine
+ case :mnesia.create_schema(nodes) do
+ :ok -> :ok
+ {:error, {_, {:already_exists, _}}} -> :ok
+ {:error, reason} -> raise "Failed to create Mnesia schema: #{inspect(reason)}"
+ end
+ end
+
+ defp ensure_schema(false, _nodes), do: :ok
+
+ defp ensure_mnesia_started do
+ case Application.ensure_all_started(:mnesia) do
+ {:ok, _} -> :ok
+ {:error, reason} -> raise "Failed to start Mnesia: #{inspect(reason)}"
+ end
+ end
+
+ defp ensure_table(table, disc_copies?, nodes) do
+ storage_type = if disc_copies?, do: :disc_copies, else: :ram_copies
+
+ table_def =
+ [
+ {storage_type, nodes},
+ attributes: [:workflow_id, :log, :updated_at],
+ type: :set
+ ]
+
+ case :mnesia.create_table(table, table_def) do
+ {:atomic, :ok} ->
+ :ok
+
+ {:aborted, {:already_exists, ^table}} ->
+ :ok
+
+ {:aborted, reason} ->
+ raise "Failed to create Mnesia table #{inspect(table)}: #{inspect(reason)}"
+ end
+
+ :ok = :mnesia.wait_for_tables([table], 15_000)
+ end
+
+ defp ensure_events_table(table, disc_copies?, nodes) do
+ storage_type = if disc_copies?, do: :disc_copies, else: :ram_copies
+
+ table_def =
+ [
+ {storage_type, nodes},
+ attributes: [:key, :event],
+ type: :ordered_set
+ ]
+
+ case :mnesia.create_table(table, table_def) do
+ {:atomic, :ok} ->
+ :ok
+
+ {:aborted, {:already_exists, ^table}} ->
+ :ok
+
+ {:aborted, reason} ->
+ raise "Failed to create Mnesia events table #{inspect(table)}: #{inspect(reason)}"
+ end
+
+ :ok = :mnesia.wait_for_tables([table], 15_000)
+ end
+
+ defp ensure_counters_table(table, disc_copies?, nodes) do
+ storage_type = if disc_copies?, do: :disc_copies, else: :ram_copies
+
+ table_def =
+ [
+ {storage_type, nodes},
+ attributes: [:workflow_id, :count],
+ type: :set
+ ]
+
+ case :mnesia.create_table(table, table_def) do
+ {:atomic, :ok} ->
+ :ok
+
+ {:aborted, {:already_exists, ^table}} ->
+ :ok
+
+ {:aborted, reason} ->
+ raise "Failed to create Mnesia counters table #{inspect(table)}: #{inspect(reason)}"
+ end
+
+ :ok = :mnesia.wait_for_tables([table], 15_000)
+ end
+
+ defp table_name(runner_name) do
+ Module.concat(runner_name, WorkflowStore)
+ end
+
+ defp events_table_name(runner_name) do
+ Module.concat(runner_name, EventStream)
+ end
+
+ defp counters_table_name(runner_name) do
+ Module.concat(runner_name, EventCounters)
+ end
+
+ defp facts_table_name(runner_name) do
+ Module.concat(runner_name, FactStore)
+ end
+
+ defp ensure_facts_table(table, disc_copies?, nodes) do
+ storage_type = if disc_copies?, do: :disc_copies, else: :ram_copies
+
+ table_def =
+ [
+ {storage_type, nodes},
+ attributes: [:fact_hash, :value],
+ type: :set
+ ]
+
+ case :mnesia.create_table(table, table_def) do
+ {:atomic, :ok} ->
+ :ok
+
+ {:aborted, {:already_exists, ^table}} ->
+ :ok
+
+ {:aborted, reason} ->
+ raise "Failed to create Mnesia facts table #{inspect(table)}: #{inspect(reason)}"
+ end
+
+ :ok = :mnesia.wait_for_tables([table], 15_000)
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/telemetry.ex b/vendor/runic/lib/runic/runner/telemetry.ex
new file mode 100644
index 0000000..1ba9435
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/telemetry.ex
@@ -0,0 +1,160 @@
+defmodule Runic.Runner.Telemetry do
+ @moduledoc """
+ Telemetry event definitions for Runic.Runner.
+
+ All events are emitted under the `[:runic, :runner, ...]` prefix.
+
+ ## Event Groups
+
+ ### Workflow Events
+ * `[:runic, :runner, :workflow, :start]` — workflow started
+ * `[:runic, :runner, :workflow, :stop]` — workflow completed
+ * `[:runic, :runner, :workflow, :exception]` — workflow exception
+
+ ### Runnable Events
+ * `[:runic, :runner, :runnable, :start]` — runnable dispatched
+ * `[:runic, :runner, :runnable, :stop]` — runnable completed
+ * `[:runic, :runner, :runnable, :exception]` — runnable failed
+
+ ### Store Events
+ * `[:runic, :runner, :store, :start]` — store operation started
+ * `[:runic, :runner, :store, :stop]` — store operation completed
+ * `[:runic, :runner, :store, :exception]` — store operation failed
+
+ ### Promise Events
+ * `[:runic, :runner, :promise, :start]` — promise dispatched
+ * `[:runic, :runner, :promise, :stop]` — promise completed
+ """
+
+ @workflow_start [:runic, :runner, :workflow, :start]
+ @workflow_stop [:runic, :runner, :workflow, :stop]
+ @workflow_exception [:runic, :runner, :workflow, :exception]
+
+ @runnable_start [:runic, :runner, :runnable, :start]
+ @runnable_stop [:runic, :runner, :runnable, :stop]
+ @runnable_exception [:runic, :runner, :runnable, :exception]
+
+ @store_start [:runic, :runner, :store, :start]
+ @store_stop [:runic, :runner, :store, :stop]
+ @store_exception [:runic, :runner, :store, :exception]
+
+ @promise_start [:runic, :runner, :promise, :start]
+ @promise_stop [:runic, :runner, :promise, :stop]
+
+ @rehydration_complete [:runic, :runner, :rehydration, :complete]
+
+ @doc """
+ Wraps a workflow lifecycle operation in a telemetry span.
+
+ Emits `[:runic, :runner, :workflow, :start]` and
+ `[:runic, :runner, :workflow, :stop]` (or `:exception`).
+ """
+ def workflow_span(metadata, fun) do
+ :telemetry.span([:runic, :runner, :workflow], metadata, fn ->
+ result = fun.()
+ {result, metadata}
+ end)
+ end
+
+ @doc """
+ Emits a workflow lifecycle event.
+
+ ## Event Types
+
+ * `:start` — workflow started
+ * `:stop` — workflow completed (includes duration measurement)
+ """
+ def workflow_event(:start, metadata) do
+ :telemetry.execute(@workflow_start, %{system_time: System.system_time()}, metadata)
+ end
+
+ def workflow_event(:stop, measurements, metadata) do
+ :telemetry.execute(@workflow_stop, measurements, metadata)
+ end
+
+ @doc """
+ Emits a runnable lifecycle event.
+
+ ## Event Types
+
+ * `:dispatch` — runnable dispatched for execution
+ * `:complete` — runnable completed successfully
+ * `:exception` — runnable failed
+ """
+ def runnable_event(:dispatch, metadata) do
+ :telemetry.execute(@runnable_start, %{system_time: System.system_time()}, metadata)
+ end
+
+ def runnable_event(:exception, metadata) do
+ :telemetry.execute(@runnable_exception, %{}, metadata)
+ end
+
+ @doc """
+ Emits a runnable completion event with measurements.
+ """
+ def runnable_event(:complete, measurements, metadata) do
+ :telemetry.execute(@runnable_stop, measurements, metadata)
+ end
+
+ @doc """
+ Wraps a store operation in a telemetry span.
+
+ Emits `[:runic, :runner, :store, :start]` and
+ `[:runic, :runner, :store, :stop]` (or `:exception`).
+ """
+ def store_span(operation, metadata, fun) do
+ :telemetry.span(
+ [:runic, :runner, :store],
+ Map.put(metadata, :operation, operation),
+ fn ->
+ result = fun.()
+ {result, metadata}
+ end
+ )
+ end
+
+ @doc """
+ Emits a promise lifecycle event.
+
+ ## Event Types
+
+ * `:start` — promise dispatched for execution
+ * `:stop` — promise completed (includes duration measurement)
+ """
+ def promise_event(:start, metadata) do
+ :telemetry.execute(@promise_start, %{system_time: System.system_time()}, metadata)
+ end
+
+ def promise_event(:stop, measurements, metadata) do
+ :telemetry.execute(@promise_stop, measurements, metadata)
+ end
+
+ @doc """
+ Emits a rehydration completion event with memory measurements.
+ """
+ def rehydration_event(:complete, measurements, metadata) do
+ :telemetry.execute(@rehydration_complete, measurements, metadata)
+ end
+
+ @doc """
+ Returns all telemetry event names emitted by the Runner.
+
+ Useful for `:telemetry.list_handlers/1` and handler setup.
+ """
+ def event_names do
+ [
+ @workflow_start,
+ @workflow_stop,
+ @workflow_exception,
+ @runnable_start,
+ @runnable_stop,
+ @runnable_exception,
+ @store_start,
+ @store_stop,
+ @store_exception,
+ @promise_start,
+ @promise_stop,
+ @rehydration_complete
+ ]
+ end
+end
diff --git a/vendor/runic/lib/runic/runner/worker.ex b/vendor/runic/lib/runic/runner/worker.ex
new file mode 100644
index 0000000..352ad2e
--- /dev/null
+++ b/vendor/runic/lib/runic/runner/worker.ex
@@ -0,0 +1,1310 @@
+defmodule Runic.Runner.Worker do
+ @moduledoc """
+ GenServer managing a single workflow's execution lifecycle.
+
+ The Worker implements the dispatch loop: plan → prepare → dispatch → apply,
+ using an `Executor` behaviour for fault-isolated task execution and
+ `PolicyDriver` for policy-aware invocation.
+
+ Workers are started under the Runner's DynamicSupervisor and registered
+ in the Runner's Registry for lookup by workflow ID.
+
+ ## Executor
+
+ The executor controls _how_ runnables are dispatched to compute. By default,
+ `Runic.Runner.Executor.Task` is used (wrapping `Task.Supervisor.async_nolink`).
+ Pass `executor: MyExecutor` and `executor_opts: [...]` to use a custom executor.
+
+ The special value `executor: :inline` executes runnables synchronously in the
+ Worker process — useful for sub-millisecond computations where task spawn
+ overhead dominates.
+
+ ## Per-Component Executor Overrides
+
+ When a `SchedulerPolicy` for a runnable includes an `:executor` field, the
+ Worker dispatches that runnable through the override executor instead of the
+ default. This allows mixing execution strategies within a single workflow.
+
+ ## Scheduler
+
+ The scheduler controls _what_ gets dispatched together and _when_.
+ Pass `scheduler: MyScheduler` and `scheduler_opts: [...]` to use a
+ custom strategy. Built-in schedulers:
+
+ - `Runic.Runner.Scheduler.Default` — dispatches each runnable individually (default)
+ - `Runic.Runner.Scheduler.ChainBatching` — batches linear chains into Promises
+
+ The `promise_opts: [min_chain_length: N]` shorthand is equivalent to
+ `scheduler: Runic.Runner.Scheduler.ChainBatching, scheduler_opts: [min_chain_length: N]`.
+ An explicit `:scheduler` takes precedence over `:promise_opts`.
+
+ ## Hooks
+
+ Lifecycle hooks allow observability and light customization without replacing
+ the Worker. Pass `hooks: [...]` in Worker opts:
+
+ - `on_dispatch: fn runnable, worker_state -> :ok end`
+ - `on_complete: fn runnable, duration_ms, worker_state -> :ok end`
+ - `on_failed: fn runnable, reason, worker_state -> :ok end`
+ - `on_idle: fn worker_state -> :ok end`
+ - `transform_runnables: fn runnables, workflow -> runnables end`
+
+ Hook exceptions are logged but do not crash the Worker.
+ """
+
+ use GenServer
+
+ require Logger
+
+ alias Runic.Workflow
+ alias Runic.Workflow.{Runnable, FactRef, FactResolver}
+ alias Runic.Workflow.Events.FactProduced
+ alias Runic.Workflow.SchedulerPolicy
+ alias Runic.Workflow.PolicyDriver
+ alias Runic.Runner.{Telemetry, Promise}
+
+ defstruct [
+ :id,
+ :runner,
+ :workflow,
+ :store,
+ :task_supervisor,
+ :max_concurrency,
+ :on_complete,
+ :checkpoint_strategy,
+ :resolver,
+ :executor,
+ :executor_opts,
+ :executor_state,
+ :scheduler,
+ :scheduler_opts,
+ :scheduler_state,
+ status: :idle,
+ active_tasks: %{},
+ active_promises: %{},
+ dispatch_times: %{},
+ cycle_count: 0,
+ started_at: nil,
+ event_cursor: 0,
+ uncommitted_events: [],
+ hooks: %{},
+ override_executors: %{},
+ promise_opts: []
+ ]
+
+ # --- Child Spec ---
+
+ def child_spec(opts) do
+ id = Keyword.fetch!(opts, :workflow_id)
+
+ %{
+ id: {__MODULE__, id},
+ start: {__MODULE__, :start_link, [opts]},
+ restart: :transient,
+ type: :worker
+ }
+ end
+
+ def start_link(opts) do
+ runner = Keyword.fetch!(opts, :runner)
+ workflow_id = Keyword.fetch!(opts, :workflow_id)
+ name = Runic.Runner.via(runner, workflow_id)
+ GenServer.start_link(__MODULE__, opts, name: name)
+ end
+
+ # --- GenServer Callbacks ---
+
+ @impl GenServer
+ def init(opts) do
+ runner = Keyword.fetch!(opts, :runner)
+ workflow_id = Keyword.fetch!(opts, :workflow_id)
+ workflow = Keyword.fetch!(opts, :workflow)
+
+ {store_mod, store_state} = Runic.Runner.get_store(runner)
+
+ workflow =
+ if Runic.Runner.Store.supports_stream?(store_mod) do
+ Workflow.enable_event_emission(workflow)
+ else
+ workflow
+ end
+
+ resolver =
+ case Keyword.get(opts, :resolver) do
+ nil ->
+ if function_exported?(store_mod, :load_fact, 2) do
+ Runic.Workflow.FactResolver.new({store_mod, store_state})
+ else
+ nil
+ end
+
+ resolver ->
+ resolver
+ end
+
+ task_supervisor = Module.concat(runner, TaskSupervisor)
+
+ # Initialize executor
+ executor = Keyword.get(opts, :executor, Runic.Runner.Executor.Task)
+ executor_opts = Keyword.get(opts, :executor_opts, [])
+
+ {executor_state, executor} =
+ init_executor(executor, executor_opts, task_supervisor)
+
+ # Parse hooks
+ hooks = parse_hooks(Keyword.get(opts, :hooks, []))
+
+ # Promise options (backward compat shorthand for ChainBatching scheduler)
+ promise_opts = Keyword.get(opts, :promise_opts, [])
+
+ # Initialize scheduler
+ {scheduler, scheduler_opts} =
+ resolve_scheduler_config(
+ Keyword.get(opts, :scheduler),
+ Keyword.get(opts, :scheduler_opts, []),
+ promise_opts
+ )
+
+ scheduler_state = init_scheduler(scheduler, scheduler_opts)
+
+ state = %__MODULE__{
+ id: workflow_id,
+ runner: runner,
+ workflow: workflow,
+ store: {store_mod, store_state},
+ task_supervisor: task_supervisor,
+ max_concurrency: Keyword.get(opts, :max_concurrency, System.schedulers_online()),
+ on_complete: Keyword.get(opts, :on_complete),
+ checkpoint_strategy: Keyword.get(opts, :checkpoint_strategy, :every_cycle),
+ resolver: resolver,
+ started_at: System.monotonic_time(:millisecond),
+ executor: executor,
+ executor_opts: executor_opts,
+ executor_state: executor_state,
+ scheduler: scheduler,
+ scheduler_opts: scheduler_opts,
+ scheduler_state: scheduler_state,
+ hooks: hooks,
+ promise_opts: promise_opts
+ }
+
+ Telemetry.workflow_event(:start, %{id: workflow_id, workflow_name: workflow.name})
+
+ # Persist initial build events for event-sourced stores (skip on resume)
+ resumed = Keyword.get(opts, :resumed, false)
+ state = maybe_persist_build_log(state, resumed)
+ state = maybe_recover_in_flight(state)
+
+ {:ok, state}
+ end
+
+ @impl GenServer
+ def handle_cast({:run, input, opts}, %__MODULE__{status: status} = state)
+ when status in [:idle, :running] do
+ policies = merge_runtime_policies(opts, state.workflow.scheduler_policies)
+
+ workflow =
+ state.workflow
+ |> maybe_set_policies(policies, state.workflow.scheduler_policies)
+ |> maybe_apply_run_context(opts)
+ |> Workflow.plan_eagerly(input)
+
+ state = %{state | workflow: workflow, status: :running}
+ state = dispatch_runnables(state)
+
+ state = maybe_transition_to_idle(state)
+
+ {:noreply, state}
+ end
+
+ def handle_cast({:run, _input, _opts}, state) do
+ {:noreply, state}
+ end
+
+ @impl GenServer
+ def handle_call(:get_results, _from, state) do
+ {:reply, {:ok, Workflow.raw_productions(state.workflow)}, state}
+ end
+
+ def handle_call({:get_results, opts}, _from, state) do
+ component_names = Keyword.get(opts, :components)
+ {:reply, {:ok, Workflow.results(state.workflow, component_names, opts)}, state}
+ end
+
+ def handle_call(:get_workflow, _from, state) do
+ {:reply, {:ok, state.workflow}, state}
+ end
+
+ def handle_call({:stop, opts}, _from, state) do
+ persist? = Keyword.get(opts, :persist, true)
+ state = if persist?, do: maybe_persist(state), else: state
+ cleanup_executors(state)
+ {:stop, :normal, :ok, state}
+ end
+
+ def handle_call(:checkpoint, _from, state) do
+ state = do_checkpoint(state)
+ {:reply, :ok, state}
+ end
+
+ # Task completed successfully — the result is a %Runnable{}
+ @impl GenServer
+ def handle_info({ref, %Runnable{} = executed}, state) when is_reference(ref) do
+ Process.demonitor(ref, [:flush])
+ state = handle_task_result(ref, executed, [], state)
+ {:noreply, state}
+ end
+
+ # Task completed with durable events — {%Runnable{}, [event]}
+ def handle_info({ref, {%Runnable{} = executed, events}}, state)
+ when is_reference(ref) and is_list(events) do
+ Process.demonitor(ref, [:flush])
+ state = handle_task_result(ref, executed, events, state)
+ {:noreply, state}
+ end
+
+ # Task crashed
+ def handle_info({:DOWN, ref, :process, _pid, reason}, state) when is_reference(ref) do
+ case Map.pop(state.active_tasks, ref) do
+ {nil, _} ->
+ {:noreply, state}
+
+ {{:promise, promise_id}, active_tasks} ->
+ if reason != :normal do
+ Logger.warning(
+ "Runner promise task crashed for workflow #{inspect(state.id)}, " <>
+ "promise #{inspect(promise_id)}: #{inspect(reason)}"
+ )
+ end
+
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+ {promise, active_promises} = Map.pop(state.active_promises, promise_id)
+
+ state = %{
+ state
+ | active_tasks: active_tasks,
+ dispatch_times: dispatch_times,
+ active_promises: active_promises
+ }
+
+ # Mark all runnables in the promise as crashed
+ state =
+ if promise do
+ Enum.reduce(promise.runnables, state, fn r, acc ->
+ mark_crashed_runnable(acc, r.id, reason, dispatch_time)
+ end)
+ else
+ state
+ end
+
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ state = maybe_transition_to_idle(state)
+
+ {:noreply, state}
+
+ {runnable_id, active_tasks} ->
+ if reason != :normal do
+ Logger.warning(
+ "Runner task crashed for workflow #{inspect(state.id)}, " <>
+ "runnable #{inspect(runnable_id)}: #{inspect(reason)}"
+ )
+ end
+
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+ state = %{state | active_tasks: active_tasks, dispatch_times: dispatch_times}
+
+ state = mark_crashed_runnable(state, runnable_id, reason, dispatch_time)
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ state = maybe_transition_to_idle(state)
+
+ {:noreply, state}
+ end
+ end
+
+ # Promise completed — batch of executed runnables
+ def handle_info({ref, {:promise_result, promise_id, executed_runnables}}, state)
+ when is_reference(ref) do
+ Process.demonitor(ref, [:flush])
+
+ {_tag, active_tasks} = Map.pop(state.active_tasks, ref)
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+ {promise, active_promises} = Map.pop(state.active_promises, promise_id)
+
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+
+ state = %{
+ state
+ | active_tasks: active_tasks,
+ dispatch_times: dispatch_times,
+ active_promises: active_promises
+ }
+
+ # Apply all runnables from the promise sequentially
+ state = apply_promise_results(state, executed_runnables)
+
+ # Emit promise completion telemetry
+ Telemetry.promise_event(:stop, %{duration: duration}, %{
+ promise_id: promise_id,
+ runnable_count:
+ if(promise, do: length(promise.runnables), else: length(executed_runnables)),
+ node_hashes: if(promise, do: promise.node_hashes, else: MapSet.new())
+ })
+
+ state =
+ if promise, do: notify_scheduler_complete(state, {:promise, promise}, duration), else: state
+
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ state = maybe_transition_to_idle(state)
+
+ {:noreply, state}
+ end
+
+ # Promise partially failed — some runnables completed, one failed
+ def handle_info({ref, {:promise_partial, promise_id, completed, failed}}, state)
+ when is_reference(ref) do
+ Process.demonitor(ref, [:flush])
+
+ {_tag, active_tasks} = Map.pop(state.active_tasks, ref)
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+ {promise, active_promises} = Map.pop(state.active_promises, promise_id)
+
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+
+ state = %{
+ state
+ | active_tasks: active_tasks,
+ dispatch_times: dispatch_times,
+ active_promises: active_promises
+ }
+
+ # Apply completed runnables first (partial commit)
+ state = apply_promise_results(state, completed)
+
+ # Handle the failed runnable through existing error path
+ state = apply_promise_results(state, [failed])
+
+ Telemetry.promise_event(:stop, %{duration: duration}, %{
+ promise_id: promise_id,
+ runnable_count: if(promise, do: length(promise.runnables), else: 0),
+ node_hashes: if(promise, do: promise.node_hashes, else: MapSet.new()),
+ partial_failure: true
+ })
+
+ state =
+ if promise, do: notify_scheduler_complete(state, {:promise, promise}, duration), else: state
+
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ state = maybe_transition_to_idle(state)
+
+ {:noreply, state}
+ end
+
+ def handle_info(_msg, state) do
+ {:noreply, state}
+ end
+
+ @impl GenServer
+ def terminate(_reason, state) do
+ cleanup_executors(state)
+ :ok
+ end
+
+ # --- Private ---
+
+ defp init_executor(:inline, _opts, _task_supervisor) do
+ {nil, :inline}
+ end
+
+ defp init_executor(executor_mod, executor_opts, task_supervisor) do
+ # Default TaskExecutor needs the task_supervisor
+ opts =
+ if executor_mod == Runic.Runner.Executor.Task do
+ Keyword.put_new(executor_opts, :task_supervisor, task_supervisor)
+ else
+ executor_opts
+ end
+
+ case executor_mod.init(opts) do
+ {:ok, executor_state} ->
+ {executor_state, executor_mod}
+
+ {:error, reason} ->
+ raise "Failed to initialize executor #{inspect(executor_mod)}: #{inspect(reason)}"
+ end
+ end
+
+ defp resolve_scheduler_config(nil, _scheduler_opts, []),
+ do: {Runic.Runner.Scheduler.Default, []}
+
+ defp resolve_scheduler_config(nil, _scheduler_opts, promise_opts),
+ do: {Runic.Runner.Scheduler.ChainBatching, promise_opts}
+
+ defp resolve_scheduler_config(scheduler, scheduler_opts, _promise_opts),
+ do: {scheduler, scheduler_opts}
+
+ defp init_scheduler(scheduler, scheduler_opts) do
+ case scheduler.init(scheduler_opts) do
+ {:ok, scheduler_state} ->
+ scheduler_state
+
+ {:error, reason} ->
+ raise "Failed to initialize scheduler #{inspect(scheduler)}: #{inspect(reason)}"
+ end
+ end
+
+ defp notify_scheduler_complete(state, dispatch_unit, duration) do
+ if function_exported?(state.scheduler, :on_complete, 3) do
+ scheduler_state =
+ state.scheduler.on_complete(dispatch_unit, duration, state.scheduler_state)
+
+ %{state | scheduler_state: scheduler_state}
+ else
+ state
+ end
+ rescue
+ e ->
+ Logger.warning("Scheduler on_complete raised: #{inspect(e)}")
+ state
+ end
+
+ defp cleanup_executors(%__MODULE__{executor: executor, executor_state: executor_state} = state) do
+ # Cleanup default executor
+ if executor != :inline and function_exported?(executor, :cleanup, 1) do
+ executor.cleanup(executor_state)
+ end
+
+ # Cleanup override executors
+ Enum.each(state.override_executors, fn {mod, es} ->
+ if function_exported?(mod, :cleanup, 1), do: mod.cleanup(es)
+ end)
+ end
+
+ defp mark_crashed_runnable(state, runnable_id, reason, dispatch_time) do
+ # Find the runnable in the workflow graph so we can mark it as failed.
+ # Without this, the :runnable edge stays in the graph and is_runnable?
+ # returns true forever — causing a deadlock.
+ case find_runnable_by_id(state.workflow, runnable_id) do
+ nil ->
+ state
+
+ runnable ->
+ failed = Runnable.fail(runnable, {:task_crashed, reason})
+ invoke_hook(state.hooks, :on_failed, [runnable, reason, state])
+ emit_runnable_result(failed, state.id, dispatch_time)
+ workflow = Workflow.apply_runnable(state.workflow, failed)
+ %{state | workflow: workflow}
+ end
+ end
+
+ defp find_runnable_by_id(workflow, runnable_id) do
+ Workflow.prepared_runnables(workflow)
+ |> Enum.find(fn r -> r.id == runnable_id end)
+ end
+
+ defp handle_task_result(ref, executed, events, state) do
+ {_runnable_id, active_tasks} = Map.pop(state.active_tasks, ref)
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+ emit_runnable_result(executed, state.id, dispatch_time)
+
+ case executed.status do
+ :completed ->
+ invoke_hook(state.hooks, :on_complete, [executed, duration, state])
+
+ :failed ->
+ invoke_hook(state.hooks, :on_failed, [executed, executed.error, state])
+
+ _ ->
+ :ok
+ end
+
+ workflow = Workflow.apply_runnable(state.workflow, executed)
+
+ workflow =
+ if events != [] do
+ Workflow.append_runnable_events(workflow, events)
+ else
+ workflow
+ end
+
+ # Collect uncommitted events from the workflow for event-sourced persistence.
+ # Events are stored in reverse order by apply_runnable/2 (prepend for O(1)),
+ # so reverse here to restore chronological order.
+ {store_mod, store_state} = state.store
+ new_uncommitted = Enum.reverse(workflow.uncommitted_events)
+ # Include durable lifecycle events in the stream too
+ all_new_events = new_uncommitted ++ events
+
+ # Persist fact values to the content-addressed fact store before checkpointing
+ # events that reference them by hash.
+ flush_pending_facts(all_new_events, store_mod, store_state)
+
+ # Strip values from FactProduced events when the store supports fact-level
+ # storage. Values are already persisted via flush_pending_facts above;
+ # keeping only hashes in the event stream enables lean replay on recovery.
+ lean_events =
+ if function_exported?(store_mod, :save_fact, 3) do
+ strip_fact_values(all_new_events)
+ else
+ all_new_events
+ end
+
+ state =
+ if Runic.Runner.Store.supports_stream?(store_mod) and lean_events != [] do
+ %{
+ state
+ | workflow: %{workflow | uncommitted_events: []},
+ active_tasks: active_tasks,
+ dispatch_times: dispatch_times,
+ uncommitted_events: state.uncommitted_events ++ lean_events,
+ event_cursor: state.event_cursor + length(lean_events)
+ }
+ else
+ %{
+ state
+ | workflow: workflow,
+ active_tasks: active_tasks,
+ dispatch_times: dispatch_times
+ }
+ end
+
+ state = notify_scheduler_complete(state, {:runnable, executed}, duration)
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ maybe_transition_to_idle(state)
+ end
+
+ defp dispatch_runnables(%__MODULE__{} = state) do
+ {workflow, runnables} = Workflow.prepare_for_dispatch(state.workflow)
+ state = %{state | workflow: workflow}
+
+ # Build set of active runnable IDs and promise-covered node hashes
+ active_runnable_ids =
+ MapSet.new(Map.values(state.active_tasks), fn
+ {:promise, _id} -> nil
+ id -> id
+ end)
+
+ promise_covered_hashes =
+ state.active_promises
+ |> Map.values()
+ |> Enum.reduce(MapSet.new(), fn p, acc -> MapSet.union(acc, p.node_hashes) end)
+
+ # Pass the full filtered candidate list to the Scheduler without capping
+ # to available_slots. This lets schedulers like FlowBatch see all
+ # independent runnables (e.g., 10,000 FanOut items) and group them into
+ # parallel Promises. The Worker caps dispatch to available slots below.
+ candidates =
+ Enum.reject(runnables, fn r ->
+ MapSet.member?(active_runnable_ids, r.id) or
+ MapSet.member?(promise_covered_hashes, r.node.hash)
+ end)
+
+ # Apply transform_runnables hook
+ candidates = apply_transform_hook(state.hooks, candidates, workflow)
+
+ # Resolve input facts for all candidates
+ candidates =
+ Enum.map(candidates, &maybe_resolve_input_fact(&1, state.resolver))
+
+ dispatch_via_scheduler(candidates, state)
+ end
+
+ defp dispatch_via_scheduler(runnables, state) do
+ {units, scheduler_state} =
+ safe_plan_dispatch(state.scheduler, state.workflow, runnables, state.scheduler_state)
+
+ state = %{state | scheduler_state: scheduler_state}
+
+ # Dispatch units until available slots are exhausted.
+ # Each unit (runnable or promise) costs 1 slot regardless of internal count.
+ Enum.reduce_while(units, state, fn unit, acc ->
+ available = acc.max_concurrency - map_size(acc.active_tasks)
+
+ if available <= 0 do
+ {:halt, acc}
+ else
+ acc =
+ case unit do
+ {:runnable, runnable} ->
+ policy = SchedulerPolicy.resolve(runnable, acc.workflow.scheduler_policies)
+ invoke_hook(acc.hooks, :on_dispatch, [runnable, acc])
+ dispatch_single_runnable(runnable, policy, acc)
+
+ {:promise, promise} ->
+ Enum.each(promise.runnables, fn r ->
+ invoke_hook(acc.hooks, :on_dispatch, [r, acc])
+ end)
+
+ dispatch_promise(promise, acc)
+ end
+
+ {:cont, acc}
+ end
+ end)
+ end
+
+ defp safe_plan_dispatch(scheduler, workflow, runnables, scheduler_state) do
+ scheduler.plan_dispatch(workflow, runnables, scheduler_state)
+ rescue
+ e ->
+ Logger.warning(
+ "Scheduler #{inspect(scheduler)} raised in plan_dispatch: #{inspect(e)}, " <>
+ "falling back to individual dispatch"
+ )
+
+ {Enum.map(runnables, &{:runnable, &1}), scheduler_state}
+ end
+
+ defp dispatch_single_runnable(runnable, policy, state) do
+ work_fn = build_work_fn(runnable, policy)
+
+ # Determine which executor to use: per-component override or default
+ {executor, executor_state, state} = resolve_executor(policy, state)
+
+ now = System.monotonic_time(:millisecond)
+
+ Telemetry.runnable_event(:dispatch, %{
+ workflow_id: state.id,
+ node_name: node_name(runnable.node),
+ runnable_id: runnable.id,
+ policy: policy
+ })
+
+ case executor do
+ :inline ->
+ # Execute synchronously in the Worker process.
+ # Skip timeout enforcement for inline (per design doc); preserve retry/fallback.
+ inline_policy = %{policy | timeout_ms: :infinity}
+ result = PolicyDriver.execute(runnable, inline_policy)
+
+ # Simulate the async completion path synchronously
+ ref = make_ref()
+
+ state = %{
+ state
+ | active_tasks: Map.put(state.active_tasks, ref, runnable.id),
+ dispatch_times: Map.put(state.dispatch_times, ref, now)
+ }
+
+ case result do
+ {%Runnable{} = executed, events} when is_list(events) ->
+ handle_task_result(ref, executed, events, state)
+
+ %Runnable{} = executed ->
+ handle_task_result(ref, executed, [], state)
+ end
+
+ executor_mod ->
+ {handle, new_executor_state} = executor_mod.dispatch(work_fn, [], executor_state)
+
+ state = update_executor_state(state, executor_mod, new_executor_state)
+
+ %{
+ state
+ | active_tasks: Map.put(state.active_tasks, handle, runnable.id),
+ dispatch_times: Map.put(state.dispatch_times, handle, now)
+ }
+ end
+ end
+
+ defp build_work_fn(runnable, policy) do
+ fn ->
+ if policy.execution_mode == :durable do
+ PolicyDriver.execute(runnable, policy, emit_events: true)
+ else
+ PolicyDriver.execute(runnable, policy)
+ end
+ end
+ end
+
+ # --- Promise Dispatch ---
+
+ defp dispatch_promise(%Promise{} = promise, state) do
+ workflow = state.workflow
+ policies = workflow.scheduler_policies
+
+ work_fn = fn ->
+ resolve_promise(promise, workflow, policies)
+ end
+
+ # Dispatch via the default executor (promises don't use per-component overrides)
+ {executor, executor_state, state} =
+ {state.executor, state.executor_state, state}
+
+ now = System.monotonic_time(:millisecond)
+
+ Telemetry.promise_event(:start, %{
+ promise_id: promise.id,
+ runnable_count: length(promise.runnables),
+ node_hashes: promise.node_hashes
+ })
+
+ case executor do
+ :inline ->
+ # Execute promise synchronously
+ result = resolve_promise(promise, workflow, policies)
+ ref = make_ref()
+
+ state = %{
+ state
+ | active_tasks: Map.put(state.active_tasks, ref, {:promise, promise.id}),
+ active_promises: Map.put(state.active_promises, promise.id, promise),
+ dispatch_times: Map.put(state.dispatch_times, ref, now)
+ }
+
+ # Simulate async path synchronously
+ case result do
+ {:promise_result, promise_id, executed} ->
+ handle_promise_result_inline(ref, promise_id, executed, state)
+
+ {:promise_partial, promise_id, completed, failed} ->
+ handle_promise_partial_inline(ref, promise_id, completed, failed, state)
+ end
+
+ executor_mod ->
+ {handle, new_executor_state} = executor_mod.dispatch(work_fn, [], executor_state)
+
+ state = update_executor_state(state, executor_mod, new_executor_state)
+
+ %{
+ state
+ | active_tasks: Map.put(state.active_tasks, handle, {:promise, promise.id}),
+ active_promises: Map.put(state.active_promises, promise.id, promise),
+ dispatch_times: Map.put(state.dispatch_times, handle, now)
+ }
+ end
+ end
+
+ defp resolve_promise(%Promise{strategy: :parallel} = promise, _workflow, policies) do
+ resolve_promise_parallel(promise, policies)
+ end
+
+ defp resolve_promise(%Promise{} = promise, workflow, policies) do
+ # Start with the initial runnables in the promise, then follow the chain
+ resolve_promise_loop(promise, workflow, policies, promise.runnables, [])
+ end
+
+ defp resolve_promise_loop(promise, _workflow, _policies, [], completed) do
+ {:promise_result, promise.id, Enum.reverse(completed)}
+ end
+
+ defp resolve_promise_loop(promise, workflow, policies, [runnable | _rest], completed) do
+ policy = SchedulerPolicy.resolve(runnable, policies)
+
+ executed =
+ if policy.execution_mode == :durable do
+ PolicyDriver.execute(runnable, policy, emit_events: true)
+ else
+ PolicyDriver.execute(runnable, policy)
+ end
+
+ # Normalize to {runnable, events}
+ {executed_runnable, _events} =
+ case executed do
+ {%Runnable{} = r, events} -> {r, events}
+ %Runnable{} = r -> {r, []}
+ end
+
+ case executed_runnable.status do
+ :failed ->
+ {:promise_partial, promise.id, Enum.reverse(completed), executed}
+
+ _ ->
+ # Apply to local workflow copy so next runnable sees updated state
+ wf = Workflow.apply_runnable(workflow, executed_runnable)
+ # Prepare next runnables and find those in our chain
+ {wf, next_runnables} = Workflow.prepare_for_dispatch(wf)
+
+ chain_runnables =
+ Enum.filter(next_runnables, fn r ->
+ MapSet.member?(promise.node_hashes, r.node.hash)
+ end)
+
+ resolve_promise_loop(promise, wf, policies, chain_runnables, [executed | completed])
+ end
+ end
+
+ # --- Parallel Promise Resolution ---
+
+ defp resolve_promise_parallel(%Promise{} = promise, policies) do
+ runnables = promise.runnables
+ flow_opts = promise.flow_opts
+
+ stages =
+ Keyword.get(flow_opts, :stages, min(length(runnables), System.schedulers_online()))
+
+ max_demand = Keyword.get(flow_opts, :max_demand, 1)
+
+ execute_fn = fn runnable ->
+ policy = SchedulerPolicy.resolve(runnable, policies)
+
+ try do
+ if policy.execution_mode == :durable do
+ PolicyDriver.execute(runnable, policy, emit_events: true)
+ else
+ PolicyDriver.execute(runnable, policy)
+ end
+ rescue
+ e ->
+ Runnable.fail(runnable, {:execution_error, e})
+ catch
+ kind, reason ->
+ Runnable.fail(runnable, {kind, reason})
+ end
+ end
+
+ results = resolve_with_flow(runnables, execute_fn, stages, max_demand)
+
+ {:promise_result, promise.id, results}
+ end
+
+ defp resolve_with_flow(runnables, execute_fn, stages, max_demand) do
+ runnables
+ |> Flow.from_enumerable(stages: stages, max_demand: max_demand)
+ |> Flow.map(execute_fn)
+ |> Enum.to_list()
+ end
+
+ defp handle_promise_result_inline(ref, promise_id, executed, state) do
+ {_tag, active_tasks} = Map.pop(state.active_tasks, ref)
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+ {promise, active_promises} = Map.pop(state.active_promises, promise_id)
+
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+
+ state = %{
+ state
+ | active_tasks: active_tasks,
+ dispatch_times: dispatch_times,
+ active_promises: active_promises
+ }
+
+ state = apply_promise_results(state, executed)
+
+ Telemetry.promise_event(:stop, %{duration: duration}, %{
+ promise_id: promise_id,
+ runnable_count: if(promise, do: length(promise.runnables), else: length(executed)),
+ node_hashes: if(promise, do: promise.node_hashes, else: MapSet.new())
+ })
+
+ state =
+ if promise, do: notify_scheduler_complete(state, {:promise, promise}, duration), else: state
+
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ maybe_transition_to_idle(state)
+ end
+
+ defp handle_promise_partial_inline(ref, promise_id, completed, failed, state) do
+ {_tag, active_tasks} = Map.pop(state.active_tasks, ref)
+ {dispatch_time, dispatch_times} = Map.pop(state.dispatch_times, ref)
+ {promise, active_promises} = Map.pop(state.active_promises, promise_id)
+
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+
+ state = %{
+ state
+ | active_tasks: active_tasks,
+ dispatch_times: dispatch_times,
+ active_promises: active_promises
+ }
+
+ state = apply_promise_results(state, completed)
+ state = apply_promise_results(state, [failed])
+
+ Telemetry.promise_event(:stop, %{duration: duration}, %{
+ promise_id: promise_id,
+ runnable_count: if(promise, do: length(promise.runnables), else: 0),
+ node_hashes: if(promise, do: promise.node_hashes, else: MapSet.new()),
+ partial_failure: true
+ })
+
+ state =
+ if promise, do: notify_scheduler_complete(state, {:promise, promise}, duration), else: state
+
+ state = maybe_checkpoint(state)
+ state = dispatch_runnables(state)
+ maybe_transition_to_idle(state)
+ end
+
+ defp apply_promise_results(state, executed_list) do
+ Enum.reduce(executed_list, state, fn executed, acc ->
+ # Normalize to {runnable, events}
+ {executed_runnable, events} =
+ case executed do
+ {%Runnable{} = r, evts} when is_list(evts) -> {r, evts}
+ %Runnable{} = r -> {r, []}
+ end
+
+ emit_runnable_result(executed_runnable, acc.id, nil)
+
+ case executed_runnable.status do
+ :completed ->
+ invoke_hook(acc.hooks, :on_complete, [executed_runnable, 0, acc])
+
+ :failed ->
+ invoke_hook(acc.hooks, :on_failed, [executed_runnable, executed_runnable.error, acc])
+
+ _ ->
+ :ok
+ end
+
+ workflow = Workflow.apply_runnable(acc.workflow, executed_runnable)
+
+ workflow =
+ if events != [] do
+ Workflow.append_runnable_events(workflow, events)
+ else
+ workflow
+ end
+
+ {store_mod, store_state} = acc.store
+ new_uncommitted = Enum.reverse(workflow.uncommitted_events)
+ all_new_events = new_uncommitted ++ events
+
+ flush_pending_facts(all_new_events, store_mod, store_state)
+
+ lean_events =
+ if function_exported?(store_mod, :save_fact, 3) do
+ strip_fact_values(all_new_events)
+ else
+ all_new_events
+ end
+
+ if Runic.Runner.Store.supports_stream?(store_mod) and lean_events != [] do
+ %{
+ acc
+ | workflow: %{workflow | uncommitted_events: []},
+ uncommitted_events: acc.uncommitted_events ++ lean_events,
+ event_cursor: acc.event_cursor + length(lean_events)
+ }
+ else
+ %{acc | workflow: workflow}
+ end
+ end)
+ end
+
+ defp resolve_executor(policy, state) do
+ override_executor = Map.get(policy, :executor)
+ override_opts = Map.get(policy, :executor_opts, [])
+
+ case override_executor do
+ nil ->
+ {state.executor, state.executor_state, state}
+
+ :inline ->
+ {:inline, nil, state}
+
+ override_mod ->
+ case Map.get(state.override_executors, override_mod) do
+ nil ->
+ # Lazy-init the override executor
+ {es, _mod} = init_executor(override_mod, override_opts, state.task_supervisor)
+
+ state = %{
+ state
+ | override_executors: Map.put(state.override_executors, override_mod, es)
+ }
+
+ {override_mod, es, state}
+
+ es ->
+ {override_mod, es, state}
+ end
+ end
+ end
+
+ defp update_executor_state(state, executor_mod, new_executor_state) do
+ if executor_mod == state.executor do
+ %{state | executor_state: new_executor_state}
+ else
+ %{
+ state
+ | override_executors: Map.put(state.override_executors, executor_mod, new_executor_state)
+ }
+ end
+ end
+
+ defp maybe_resolve_input_fact(runnable, nil), do: runnable
+
+ defp maybe_resolve_input_fact(%Runnable{input_fact: %FactRef{} = ref} = runnable, resolver) do
+ case FactResolver.resolve(ref, resolver) do
+ {:ok, fact} ->
+ %{runnable | input_fact: fact}
+
+ {:error, reason} ->
+ Logger.warning(
+ "Failed to resolve FactRef #{inspect(ref.hash)} for runnable " <>
+ "#{inspect(runnable.id)}: #{inspect(reason)}"
+ )
+
+ runnable
+ end
+ end
+
+ defp maybe_resolve_input_fact(runnable, _resolver), do: runnable
+
+ defp maybe_transition_to_idle(%__MODULE__{active_tasks: tasks, workflow: wf} = state)
+ when map_size(tasks) == 0 do
+ if not Workflow.is_runnable?(wf) do
+ if state.status == :running do
+ duration = System.monotonic_time(:millisecond) - state.started_at
+
+ Telemetry.workflow_event(:stop, %{duration: duration}, %{
+ id: state.id,
+ workflow_name: state.workflow.name
+ })
+
+ state = maybe_persist(state)
+ maybe_notify_complete(state)
+ invoke_hook(state.hooks, :on_idle, [state])
+ end
+
+ %{state | status: :idle}
+ else
+ state
+ end
+ end
+
+ defp maybe_transition_to_idle(state), do: state
+
+ defp maybe_recover_in_flight(%__MODULE__{workflow: workflow} = state) do
+ case Workflow.pending_runnables(workflow) do
+ [] ->
+ state
+
+ pending ->
+ Logger.info(
+ "Worker #{inspect(state.id)} recovering #{length(pending)} in-flight runnables"
+ )
+
+ workflow = Workflow.plan_eagerly(workflow)
+ state = %{state | workflow: workflow, status: :running}
+ state = dispatch_runnables(state)
+ maybe_transition_to_idle(state)
+ end
+ end
+
+ defp merge_runtime_policies(opts, workflow_policies) do
+ case Keyword.get(opts, :scheduler_policies) do
+ nil -> workflow_policies
+ overrides -> SchedulerPolicy.merge_policies(overrides, workflow_policies)
+ end
+ end
+
+ defp maybe_set_policies(workflow, policies, current) when policies == current, do: workflow
+
+ defp maybe_set_policies(workflow, policies, _current),
+ do: Workflow.set_scheduler_policies(workflow, policies)
+
+ defp maybe_apply_run_context(workflow, opts) do
+ case Keyword.get(opts, :run_context) do
+ nil -> workflow
+ ctx when is_map(ctx) -> Workflow.put_run_context(workflow, ctx)
+ end
+ end
+
+ # --- Hooks ---
+
+ defp parse_hooks(hook_list) when is_list(hook_list) do
+ %{
+ on_dispatch: Keyword.get(hook_list, :on_dispatch),
+ on_complete: Keyword.get(hook_list, :on_complete),
+ on_failed: Keyword.get(hook_list, :on_failed),
+ on_idle: Keyword.get(hook_list, :on_idle),
+ transform_runnables: Keyword.get(hook_list, :transform_runnables)
+ }
+ end
+
+ defp parse_hooks(_), do: %{}
+
+ defp invoke_hook(hooks, key, args) do
+ case Map.get(hooks, key) do
+ nil -> :ok
+ hook when is_function(hook) -> safe_invoke_hook(hook, args)
+ end
+ end
+
+ defp safe_invoke_hook(hook, args) do
+ apply(hook, args)
+ rescue
+ e ->
+ Logger.warning("Worker hook raised: #{inspect(e)}")
+ :ok
+ end
+
+ defp apply_transform_hook(hooks, runnables, workflow) do
+ case Map.get(hooks, :transform_runnables) do
+ nil ->
+ runnables
+
+ hook when is_function(hook, 2) ->
+ try do
+ hook.(runnables, workflow)
+ rescue
+ e ->
+ Logger.warning("transform_runnables hook raised: #{inspect(e)}")
+ runnables
+ end
+ end
+ end
+
+ # --- Telemetry Helpers ---
+
+ defp emit_runnable_result(%Runnable{status: :completed} = r, workflow_id, dispatch_time) do
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+
+ Telemetry.runnable_event(:complete, %{duration: duration}, %{
+ workflow_id: workflow_id,
+ node_name: node_name(r.node),
+ runnable_id: r.id,
+ status: :completed
+ })
+ end
+
+ defp emit_runnable_result(%Runnable{status: :failed} = r, workflow_id, _dispatch_time) do
+ Telemetry.runnable_event(:exception, %{
+ workflow_id: workflow_id,
+ node_name: node_name(r.node),
+ runnable_id: r.id,
+ error: r.error
+ })
+ end
+
+ defp emit_runnable_result(%Runnable{status: :skipped} = r, workflow_id, dispatch_time) do
+ duration = if dispatch_time, do: System.monotonic_time(:millisecond) - dispatch_time, else: 0
+
+ Telemetry.runnable_event(:complete, %{duration: duration}, %{
+ workflow_id: workflow_id,
+ node_name: node_name(r.node),
+ runnable_id: r.id,
+ status: :skipped
+ })
+ end
+
+ defp emit_runnable_result(_runnable, _workflow_id, _dispatch_time), do: :ok
+
+ defp node_name(%{name: name}), do: name
+ defp node_name(node), do: Map.get(node, :hash)
+
+ # --- Fact Persistence ---
+
+ defp flush_pending_facts(events, store_mod, store_state) do
+ if function_exported?(store_mod, :save_fact, 3) do
+ Enum.each(events, fn
+ %FactProduced{hash: h, value: v} -> store_mod.save_fact(h, v, store_state)
+ _ -> :ok
+ end)
+ end
+ end
+
+ defp strip_fact_values(events) do
+ Enum.map(events, fn
+ %FactProduced{} = e -> %{e | value: nil}
+ other -> other
+ end)
+ end
+
+ # --- Checkpointing ---
+
+ defp maybe_checkpoint(%{store: nil} = state), do: state
+ defp maybe_checkpoint(%{checkpoint_strategy: :manual} = state), do: state
+ defp maybe_checkpoint(%{checkpoint_strategy: :on_complete} = state), do: state
+
+ defp maybe_checkpoint(%{checkpoint_strategy: :every_cycle} = state) do
+ do_checkpoint(state)
+ end
+
+ defp maybe_checkpoint(%{checkpoint_strategy: {:every_n, n}} = state) do
+ new_count = state.cycle_count + 1
+ state = %{state | cycle_count: new_count}
+ if rem(new_count, n) == 0, do: do_checkpoint(state), else: state
+ end
+
+ defp do_checkpoint(%{store: {store_mod, store_state}, id: id} = state) do
+ if Runic.Runner.Store.supports_stream?(store_mod) do
+ # Event-sourced path: append only uncommitted events
+ unless Enum.empty?(state.uncommitted_events) do
+ Telemetry.store_span(:checkpoint, %{workflow_id: id}, fn ->
+ store_mod.append(id, state.uncommitted_events, store_state)
+ end)
+ end
+
+ %{state | uncommitted_events: []}
+ else
+ # Legacy path: save full log
+ Telemetry.store_span(:checkpoint, %{workflow_id: id}, fn ->
+ log = Workflow.event_log(state.workflow)
+
+ if function_exported?(store_mod, :checkpoint, 3) do
+ store_mod.checkpoint(id, log, store_state)
+ else
+ store_mod.save(id, log, store_state)
+ end
+ end)
+
+ state
+ end
+ end
+
+ # --- Persistence ---
+
+ defp maybe_persist_build_log(
+ %{store: {store_mod, store_state}, id: id, workflow: wf} = state,
+ resumed
+ ) do
+ if Runic.Runner.Store.supports_stream?(store_mod) and not resumed do
+ build_events = Workflow.build_log(wf)
+
+ unless Enum.empty?(build_events) do
+ {:ok, cursor} = store_mod.append(id, build_events, store_state)
+ %{state | event_cursor: cursor}
+ else
+ state
+ end
+ else
+ state
+ end
+ end
+
+ defp maybe_persist(%{store: nil} = state), do: state
+
+ defp maybe_persist(%{store: {store_mod, store_state}, id: id} = state) do
+ if Runic.Runner.Store.supports_stream?(store_mod) do
+ # Event-sourced: flush any remaining uncommitted events
+ unless Enum.empty?(state.uncommitted_events) do
+ Telemetry.store_span(:save, %{workflow_id: id}, fn ->
+ store_mod.append(id, state.uncommitted_events, store_state)
+ end)
+ end
+
+ %{state | uncommitted_events: []}
+ else
+ # Legacy: save full log snapshot
+ Telemetry.store_span(:save, %{workflow_id: id}, fn ->
+ store_mod.save(id, Workflow.event_log(state.workflow), store_state)
+ end)
+
+ state
+ end
+ end
+
+ # --- Completion Callbacks ---
+
+ defp maybe_notify_complete(%{on_complete: nil}), do: :ok
+
+ defp maybe_notify_complete(%{on_complete: {m, f, a}} = state) do
+ apply(m, f, [state.id, state.workflow | a])
+ end
+
+ defp maybe_notify_complete(%{on_complete: callback} = state)
+ when is_function(callback, 2) do
+ callback.(state.id, state.workflow)
+ end
+end
diff --git a/vendor/runic/lib/workflow.ex b/vendor/runic/lib/workflow.ex
new file mode 100644
index 0000000..5f6439a
--- /dev/null
+++ b/vendor/runic/lib/workflow.ex
@@ -0,0 +1,4248 @@
+defmodule Runic.Workflow do
+ @moduledoc """
+ Runtime evaluation engine for Runic workflows.
+
+ Runic Workflows are used to compose many branching steps, rules and accumulations/reductions
+ at runtime for lazy or eager evaluation. You can think of Runic Workflows as a recipe of rules
+ that when fed a stream of facts may react.
+
+ ## Quick Start
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(
+ steps: [
+ {Runic.step(fn x -> x + 1 end, name: :add),
+ [Runic.step(fn x -> x * 2 end, name: :double)]}
+ ]
+ )
+
+ workflow
+ |> Workflow.react_until_satisfied(5)
+ |> Workflow.raw_productions()
+ # => [12]
+
+ ## Three-Phase Execution Model
+
+ All workflow evaluation uses a three-phase execution model that enables parallel execution
+ and external scheduler integration:
+
+ 1. **Prepare** - Extract minimal context from the workflow into `%Runnable{}` structs
+ 2. **Execute** - Run node work functions in isolation (can be parallelized)
+ 3. **Apply** - Reduce results back into the workflow
+
+ ### Basic Execution
+
+ For simple use cases, use `react/2` for a single cycle or `react_until_satisfied/3`
+ to run to completion:
+
+ # Single cycle
+ workflow = Workflow.react(workflow, input)
+
+ # Run to completion (recommended for simple use)
+ workflow = Workflow.react_until_satisfied(workflow, input)
+
+ ### Parallel Execution
+
+ Enable parallel execution for I/O-bound or CPU-intensive workflows:
+
+ workflow = Workflow.react_until_satisfied(workflow, input,
+ async: true,
+ max_concurrency: 8,
+ timeout: :infinity
+ )
+
+ ### External Scheduler Integration
+
+ For custom schedulers, worker pools, or distributed execution, use the low-level
+ three-phase APIs directly:
+
+ # Phase 1: Prepare runnables for dispatch
+ workflow = Workflow.plan_eagerly(workflow, input)
+ {workflow, runnables} = Workflow.prepare_for_dispatch(workflow)
+
+ # Phase 2: Execute (dispatch to worker pool, external service, etc.)
+ executed = Task.async_stream(runnables, fn runnable ->
+ Runic.Workflow.Invokable.execute(runnable.node, runnable)
+ end, timeout: :infinity)
+
+ # Phase 3: Apply results back to workflow
+ workflow = Enum.reduce(executed, workflow, fn {:ok, runnable}, wrk ->
+ Workflow.apply_runnable(wrk, runnable)
+ end)
+
+ # Continue if more work is available
+ if Workflow.is_runnable?(workflow), do: # repeat...
+
+ Key APIs for external scheduling:
+
+ - `prepare_for_dispatch/1` - Returns `{workflow, [%Runnable{}]}` for dispatch
+ - `apply_runnable/2` - Applies a completed runnable back to the workflow
+ - `Invokable.execute/2` - Executes a runnable in isolation (no workflow access)
+
+ ## Runtime Context
+
+ Runtime context provides a way to inject external, runtime-scoped values (API keys,
+ database connections, tenant IDs, feature flags) into workflow components without
+ baking them into closures or the workflow graph.
+
+ Components declare their context dependencies using `context/1` expressions in the
+ `Runic` DSL:
+
+ step = Runic.step(fn _x -> context(:api_key) end, name: :call_llm)
+
+ rule = Runic.rule name: :gated do
+ given(val: v)
+ where(v > context(:threshold))
+ then(fn %{val: v} -> {:ok, v} end)
+ end
+
+ Context is provided at runtime via `put_run_context/2` or the `:run_context` option:
+
+ # Set context directly
+ workflow = Workflow.put_run_context(workflow, %{
+ call_llm: %{api_key: "sk-..."},
+ _global: %{workspace_id: "ws1"}
+ })
+
+ # Or pass via options
+ Workflow.react_until_satisfied(workflow, input,
+ run_context: %{call_llm: %{api_key: "sk-..."}}
+ )
+
+ Context values are:
+
+ - **Scoped by component name** with an optional `_global` key for shared values
+ - **Not part of the workflow hash** — two workflows with different contexts are structurally identical
+ - **Not serialized** in the event log or fact graph
+ - **Resolved during the prepare phase** of the three-phase execution model
+
+ Use `required_context_keys/1` and `validate_run_context/2` to introspect and validate
+ context requirements before execution.
+
+ ## Workflow Composition
+
+ Workflows can be composed together using `merge/2` or by adding components with `add/3`:
+
+ # Merge two workflows
+ combined = Workflow.merge(workflow1, workflow2)
+
+ # Add components dynamically
+ workflow = Workflow.new()
+ |> Workflow.add(step1)
+ |> Workflow.add(step2, to: :step1)
+ |> Workflow.add(join_step, to: [:branch_a, :branch_b])
+
+ Any component implementing the `Runic.Transmutable` protocol can be merged into a workflow.
+
+ ## Introspection APIs
+
+ Query workflow structure and state:
+
+ # List all components by type
+ Workflow.steps(workflow) # All Step structs
+ Workflow.conditions(workflow) # All Condition structs
+
+ # Get components by name
+ Workflow.get_component(workflow, :my_step)
+
+ # Query execution state
+ Workflow.is_runnable?(workflow) # Any work pending?
+ Workflow.next_runnables(workflow) # List of {node, fact} pairs
+
+ # Traverse workflow structure
+ Workflow.next_steps(workflow, parent_step) # Children of a step
+
+ ## Result Extraction
+
+ Extract results after workflow execution:
+
+ # Structured results using output port contract (recommended)
+ Workflow.results(workflow) # => %{total: 42.50}
+
+ # Explicit component selection
+ Workflow.results(workflow, [:add, :mult]) # => %{add: 6, mult: 10}
+
+ # With options
+ Workflow.results(workflow, [:price], facts: true) # => %{price: %Fact{}}
+ Workflow.results(workflow, nil, all: true) # => %{total: [v1, v2]}
+
+ # Raw values (low-level)
+ Workflow.raw_productions(workflow) # All leaf outputs
+ Workflow.raw_productions(workflow, :step_name) # From specific component
+
+ # Full Fact structs with ancestry
+ Workflow.productions(workflow)
+
+ # All facts including inputs
+ Workflow.facts(workflow)
+
+ ## Serialization
+
+ Serialize workflows for persistence and visualization:
+
+ # Build log for persistence
+ log = Workflow.build_log(workflow)
+ serialized = :erlang.term_to_binary(log)
+
+ # Rebuild from log
+ workflow = Workflow.from_log(:erlang.binary_to_term(serialized))
+
+ # Visualization formats
+ Workflow.to_mermaid(workflow) # Mermaid flowchart
+ Workflow.to_dot(workflow) # Graphviz DOT format
+ Workflow.to_cytoscape(workflow) # Cytoscape.js JSON
+ Workflow.to_edgelist(workflow) # Edge list tuples
+
+ ## When to Use Runic Workflows
+
+ Runic Workflows are intended for use cases where your program is built or modified at runtime.
+ They are useful for:
+
+ - Complex data-dependent pipelines
+ - Expert systems and rule engines
+ - User-defined logical systems (low-code tools, DSLs)
+ - Dynamic workflow composition at runtime
+
+ If your model can be expressed in advance with compiled code using the usual control flow
+ and concurrency tools available in Elixir/Erlang, Runic Workflows may not be necessary.
+ There are performance trade-offs of doing more compilation and evaluation at runtime.
+
+ See the [Cheatsheet](cheatsheet.html) and [Usage Rules](usage-rules.html) guides for more.
+ """
+ require Logger
+ alias Runic.Closure
+ alias Runic.Workflow.ReactionOccurred
+ alias Runic.Component
+ alias Runic.Transmutable
+ alias Runic.Workflow.Components
+ alias Runic.Workflow.Root
+ alias Runic.Workflow.Step
+ alias Runic.Workflow.Condition
+ alias Runic.Workflow.Fact
+ alias Runic.Workflow.FactRef
+ alias Runic.Workflow.Rule
+ alias Runic.Workflow.Join
+ alias Runic.Workflow.Invokable
+ alias Runic.Workflow.ComponentAdded
+ alias Runic.Workflow.ComponentRemoved
+ alias Runic.Workflow.ReactionOccurred
+ alias Runic.Workflow.Runnable
+ alias Runic.Workflow.SchedulerPolicy
+ alias Runic.Workflow.PolicyDriver
+ alias Runic.Workflow.RunnableDispatched
+ alias Runic.Workflow.RunnableCompleted
+ alias Runic.Workflow.RunnableFailed
+ alias Runic.Workflow.Private
+ alias Runic.Workflow.Events.FactProduced
+ alias Runic.Workflow.Events.ActivationConsumed
+ alias Runic.Workflow.Events.RunnableActivated
+ alias Runic.Workflow.Events.ConditionSatisfied
+ alias Runic.Workflow.Events.MapReduceTracked
+ alias Runic.Workflow.Events.StateInitiated
+ alias Runic.Workflow.Events.JoinFactReceived
+ alias Runic.Workflow.Events.JoinCompleted
+ alias Runic.Workflow.Events.JoinEdgeRelabeled
+ alias Runic.Workflow.Events.FanOutFactEmitted
+ alias Runic.Workflow.Events.FanInCompleted
+
+ @type t() :: %__MODULE__{
+ name: String.t(),
+ graph: Graph.t(),
+ hash: binary(),
+ # name -> component hash
+ components: map(),
+ # node_hash -> list(hook_functions)
+ before_hooks: map(),
+ after_hooks: map(),
+ # hash of parent fact for fan_out -> path_to_fan_in e.g. [fan_out, step1, step2, fan_in]
+ mapped: map(),
+ # input_fact_hash -> MapSet.new([produced_facts])
+ inputs: map(),
+ # port contract for workflow boundary inputs (keyword list or nil)
+ input_ports: keyword() | nil,
+ # port contract for workflow boundary outputs (keyword list or nil)
+ output_ports: keyword() | nil,
+ # list of {matcher, policy_map} tuples for scheduler policies
+ scheduler_policies: list(),
+ # accumulated runnable lifecycle events for durable execution
+ runnable_events: list(),
+ # when true, apply_runnable/2 buffers events into uncommitted_events
+ emit_events: boolean(),
+ # runtime-scoped external values (secrets, tenant IDs, etc.) keyed by component name
+ run_context: map()
+ }
+
+ @type runnable() :: {fun(), term()}
+
+ defstruct name: nil,
+ hash: nil,
+ graph: nil,
+ components: %{},
+ before_hooks: %{},
+ after_hooks: %{},
+ mapped: %{},
+ build_log: [],
+ inputs: %{},
+ input_ports: nil,
+ output_ports: nil,
+ scheduler_policies: [],
+ runnable_events: [],
+ emit_events: false,
+ uncommitted_events: [],
+ run_context: %{}
+
+ @doc """
+ Creates an empty workflow with no components.
+
+ ## Example
+
+ iex> alias Runic.Workflow
+ iex> workflow = Workflow.new()
+ iex> workflow.__struct__
+ Runic.Workflow
+ """
+ def new(), do: new([])
+
+ @doc """
+ Constructs a new Runic Workflow with the given name or parameters.
+
+ ## Examples
+
+ iex> alias Runic.Workflow
+ iex> workflow = Workflow.new(:my_workflow)
+ iex> workflow.name
+ :my_workflow
+ """
+ def new(name) when is_binary(name) or is_atom(name) do
+ new(name: name)
+ end
+
+ def new(params) when is_list(params) or is_map(params) do
+ struct!(__MODULE__, params)
+ |> Map.put(:graph, new_graph())
+ |> Map.put_new(:name, Uniq.UUID.uuid4())
+ |> Map.put(:components, %{})
+ |> Map.put(:before_hooks, %{})
+ |> Map.put(:after_hooks, %{})
+ |> Map.put(:build_log, [])
+ |> Map.put(:inputs, %{})
+ |> Map.put(:mapped, %{mapped_paths: MapSet.new(), mapped_path_fan_outs: %{}})
+ |> Map.put_new(:scheduler_policies, [])
+ |> Map.put_new(:runnable_events, [])
+ end
+
+ defp new_graph do
+ Graph.new(
+ vertex_identifier: &Components.vertex_id_of/1,
+ multigraph: true
+ )
+ |> Graph.add_vertex(root(), :root)
+ end
+
+ @doc false
+ def root(), do: Private.root()
+
+ @doc """
+ Replaces the workflow's scheduler policies list entirely.
+ """
+ @spec set_scheduler_policies(t(), list()) :: t()
+ def set_scheduler_policies(%__MODULE__{} = workflow, policies) when is_list(policies) do
+ %{workflow | scheduler_policies: policies}
+ end
+
+ @doc """
+ Prepends a `{matcher, policy}` rule to the workflow's scheduler policies (higher priority).
+ """
+ @spec add_scheduler_policy(t(), term(), map()) :: t()
+ def add_scheduler_policy(%__MODULE__{} = workflow, matcher, policy) when is_map(policy) do
+ %{workflow | scheduler_policies: [{matcher, policy} | workflow.scheduler_policies]}
+ end
+
+ @doc """
+ Appends a `{matcher, policy}` rule to the workflow's scheduler policies (lower priority).
+ """
+ @spec append_scheduler_policy(t(), term(), map()) :: t()
+ def append_scheduler_policy(%__MODULE__{} = workflow, matcher, policy) when is_map(policy) do
+ %{workflow | scheduler_policies: workflow.scheduler_policies ++ [{matcher, policy}]}
+ end
+
+ @doc """
+ Merges the given context map into the workflow's run context.
+
+ Run context provides external, runtime-scoped values (secrets, tenant IDs,
+ database connections) to components during execution. Values are keyed by
+ component name for scoped access, with an optional `:_global` key for
+ values available to all components.
+
+ Run context is NOT part of the workflow's content hash, NOT serialized
+ in the event log, and NOT visible in the fact graph.
+
+ ## Example
+
+ workflow = Workflow.put_run_context(workflow, %{
+ call_llm: %{api_key: "sk-..."},
+ _global: %{workspace_id: "ws1"}
+ })
+ """
+ @spec put_run_context(t(), map()) :: t()
+ def put_run_context(%__MODULE__{} = workflow, context) when is_map(context) do
+ %{workflow | run_context: Map.merge(workflow.run_context, context)}
+ end
+
+ @doc """
+ Returns the full run context map.
+
+ ## Example
+
+ Workflow.get_run_context(workflow)
+ # => %{call_llm: %{api_key: "sk-..."}, _global: %{workspace_id: "ws1"}}
+ """
+ @spec get_run_context(t()) :: map()
+ def get_run_context(%__MODULE__{run_context: ctx}), do: ctx
+
+ @doc """
+ Returns the resolved run context for a specific component.
+
+ Merges `_global` context (if any) with the component-specific context.
+ Component-specific keys take precedence over global keys.
+
+ ## Example
+
+ Workflow.get_run_context(workflow, :call_llm)
+ # => %{workspace_id: "ws1", api_key: "sk-..."}
+ """
+ @spec get_run_context(t(), atom() | String.t()) :: map()
+ def get_run_context(%__MODULE__{run_context: ctx}, component_name) do
+ global = Map.get(ctx, :_global, %{})
+ component = Map.get(ctx, component_name, %{})
+ Map.merge(global, component)
+ end
+
+ @doc """
+ Returns a map of component names to their context key requirements.
+
+ Only includes components that use `context/1` or `context/2` meta expressions.
+ Components without context requirements are omitted.
+
+ Keys are annotated with whether they have defaults:
+
+ ## Example
+
+ Workflow.required_context_keys(workflow)
+ # => %{call_llm: [api_key: :required, model: {:optional, "gpt-4"}]}
+ """
+ @spec required_context_keys(t()) :: %{atom() => keyword()}
+ def required_context_keys(%__MODULE__{graph: graph} = workflow) do
+ for {_hash, vertex} <- graph.vertices,
+ is_map_key(vertex, :meta_refs),
+ refs = vertex.meta_refs,
+ refs != [],
+ context_refs = Enum.filter(refs, &(&1.kind == :context)),
+ context_refs != [],
+ name = resolve_component_name(workflow, vertex),
+ not is_nil(name),
+ reduce: %{} do
+ acc ->
+ entries =
+ Enum.map(context_refs, fn ref ->
+ case Map.get(ref, :default) do
+ nil -> {ref.context_key, :required}
+ default -> {ref.context_key, {:optional, default}}
+ end
+ end)
+
+ existing = Map.get(acc, name, [])
+ merged = Enum.uniq_by(existing ++ entries, &elem(&1, 0))
+ Map.put(acc, name, merged)
+ end
+ end
+
+ defp resolve_component_name(_workflow, %{name: name}) when not is_nil(name), do: name
+
+ defp resolve_component_name(%__MODULE__{graph: graph}, vertex) do
+ (Graph.in_edges(graph, vertex, by: :flow) ++
+ Graph.in_edges(graph, vertex, by: :component_of))
+ |> Enum.find_value(fn edge ->
+ case edge.v1 do
+ %Rule{name: name} -> name
+ _ -> nil
+ end
+ end)
+ end
+
+ @doc """
+ Validates that the given run_context satisfies all `context/1` references
+ in the workflow.
+
+ Returns `:ok` if all required keys are present, or `{:error, missing}` with
+ a map of component names to their missing context keys. Keys with defaults
+ (from `context/2`) are not reported as missing.
+
+ ## Example
+
+ Workflow.validate_run_context(workflow, %{call_llm: %{api_key: "sk-..."}})
+ # => :ok
+
+ Workflow.validate_run_context(workflow, %{})
+ # => {:error, %{call_llm: [:api_key], db_query: [:repo, :tenant_id]}}
+ """
+ @spec validate_run_context(t(), map()) :: :ok | {:error, %{atom() => [atom()]}}
+ def validate_run_context(%__MODULE__{} = workflow, context) when is_map(context) do
+ required = required_context_keys(workflow)
+ global = Map.get(context, :_global, %{})
+
+ missing =
+ for {component_name, entries} <- required,
+ component_ctx = Map.get(context, component_name, %{}),
+ available = Map.merge(global, component_ctx),
+ # Only check keys marked as :required (no default)
+ required_keys =
+ for({k, :required} <- entries, !Map.has_key?(available, k), do: k),
+ required_keys != [],
+ into: %{} do
+ {component_name, required_keys}
+ end
+
+ if map_size(missing) == 0, do: :ok, else: {:error, missing}
+ end
+
+ @doc """
+ Executes a list of runnables with the given scheduler policies.
+
+ Resolves each runnable's policy and executes through the PolicyDriver.
+ For use by external schedulers calling `prepare_for_dispatch/1` directly.
+ """
+ @spec execute_with_policies([Runnable.t()], list()) :: [Runnable.t()]
+ def execute_with_policies(runnables, policies) when is_list(runnables) do
+ Enum.map(runnables, fn runnable ->
+ policy = SchedulerPolicy.resolve(runnable, policies)
+ PolicyDriver.execute(runnable, policy)
+ end)
+ end
+
+ @doc """
+ Appends runnable lifecycle events to the workflow's event accumulator.
+
+ Used by schedulers to record `%RunnableDispatched{}`, `%RunnableCompleted{}`,
+ and `%RunnableFailed{}` events produced by `PolicyDriver.execute/3` with
+ `emit_events: true`.
+ """
+ @spec append_runnable_events(t(), list()) :: t()
+ def append_runnable_events(%__MODULE__{} = workflow, events) when is_list(events) do
+ %{workflow | runnable_events: workflow.runnable_events ++ events}
+ end
+
+ @doc """
+ Identifies dispatched-but-not-completed runnables from the workflow's runnable events.
+
+ Returns a list of `%RunnableDispatched{}` events that have no corresponding
+ `%RunnableCompleted{}` or `%RunnableFailed{}` event. Useful for crash recovery
+ to find in-flight work that needs to be re-dispatched.
+ """
+ @spec pending_runnables(t()) :: [RunnableDispatched.t()]
+ def pending_runnables(%__MODULE__{runnable_events: events}) do
+ {dispatched, resolved} =
+ Enum.reduce(events, {%{}, MapSet.new()}, fn
+ %RunnableDispatched{} = event, {d, r} ->
+ key = {event.runnable_id, event.attempt}
+ {Map.put(d, key, event), r}
+
+ %RunnableCompleted{} = event, {d, r} ->
+ {d, MapSet.put(r, event.runnable_id)}
+
+ %RunnableFailed{} = event, {d, r} ->
+ {d, MapSet.put(r, event.runnable_id)}
+
+ _other, acc ->
+ acc
+ end)
+
+ dispatched
+ |> Enum.reject(fn {{runnable_id, _attempt}, _event} ->
+ MapSet.member?(resolved, runnable_id)
+ end)
+ |> Enum.map(fn {_key, event} -> event end)
+ end
+
+ @doc """
+ Adds a component to the workflow, connecting it to the parent step or root if no parent is specified.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> step = Runic.step(fn x -> x + 1 end, name: :add_one)
+ iex> workflow = Workflow.new() |> Workflow.add(step)
+ iex> Workflow.get_component(workflow, :add_one) |> Map.get(:name)
+ :add_one
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> s1 = Runic.step(fn x -> x + 1 end, name: :first)
+ iex> s2 = Runic.step(fn x -> x * 2 end, name: :second)
+ iex> workflow = Workflow.new() |> Workflow.add(s1) |> Workflow.add(s2, to: :first)
+ iex> workflow |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions() |> Enum.sort()
+ [6, 12]
+
+ When `:to` is a list of parent names, a Join is created so the dependent step
+ waits for all parents to produce facts before running:
+
+ require Runic
+ alias Runic.Workflow
+
+ a_step = Runic.step(fn x -> x + 1 end, name: :a)
+ b_step = Runic.step(fn x -> x * 2 end, name: :b)
+ sum_step = Runic.step(fn a, b -> a + b end, name: :sum)
+
+ workflow =
+ Workflow.new()
+ |> Workflow.add(a_step)
+ |> Workflow.add(b_step)
+ |> Workflow.add(sum_step, to: [:a, :b])
+
+ result =
+ workflow
+ |> Workflow.react_until_satisfied(5)
+ |> Workflow.raw_reactions()
+
+ 6 in result # :a produced 5 + 1
+ 10 in result # :b produced 5 * 2
+ 16 in result # :sum produced 6 + 10
+
+ ## Port Validation
+
+ By default, `add/3` validates that the producer's output ports are type-compatible
+ with the consumer's input ports. Control this with the `:validate` option:
+
+ - `:error` (default) — raises `Runic.IncompatiblePortError` on type mismatch
+ - `:warn` — logs a warning but allows the connection
+ - `:off` — skips validation entirely (useful for prototyping)
+
+ # Bypass validation during prototyping
+ Workflow.add(workflow, step, to: :parent, validate: :off)
+
+ Untyped components (all ports default to `type: :any`) always pass validation,
+ preserving Runic's gradual typing philosophy.
+ """
+ def add(%__MODULE__{} = workflow, component, opts \\ []) do
+ case opts[:to] do
+ nil ->
+ # If no parent is specified, we assume the component is a root step
+ parent_step = root()
+ do_add_component(workflow, component, parent_step, opts)
+
+ {component_name, _component_kind} = name
+ when is_atom(component_name) or is_binary(component_name) ->
+ parent_step = get_component!(workflow, name) |> List.first()
+
+ do_add_component(workflow, component, parent_step, opts)
+
+ component_name when is_atom(component_name) or is_binary(component_name) ->
+ parent_step = get_component!(workflow, component_name)
+ do_add_component(workflow, component, parent_step, opts)
+
+ hash when is_integer(hash) ->
+ parent_step = get_by_hash(workflow, hash)
+ do_add_component(workflow, component, parent_step, opts)
+
+ %{} = parent_step ->
+ do_add_component(workflow, component, parent_step, opts)
+
+ parent_steps when is_list(parent_steps) ->
+ parent_steps = Enum.map(parent_steps, &get_component!(workflow, &1))
+
+ do_add_component(workflow, component, parent_steps, opts)
+ end
+ end
+
+ defp do_add_component(%__MODULE__{} = workflow, component, parent, opts) do
+ should_log = Keyword.get(opts, :log, true)
+
+ case {component, parent} do
+ {%{__struct__: struct}, %Root{}} ->
+ if struct not in Components.component_impls() do
+ transmuted = Transmutable.to_component(component)
+ do_add_component(workflow, transmuted, parent, opts)
+ else
+ workflow = Component.connect(component, parent, workflow)
+
+ if should_log do
+ append_build_log(workflow, component, parent)
+ else
+ workflow
+ end
+ end
+
+ {%{__struct__: struct} = component, _parent} ->
+ if struct not in Components.component_impls() do
+ transmuted = Transmutable.to_component(component)
+ do_add_component(workflow, transmuted, parent, opts)
+ else
+ validate_ports(component, parent, opts)
+
+ workflow =
+ component
+ |> Component.connect(parent, workflow)
+ |> maybe_draw_connects_to(component, parent)
+
+ if should_log do
+ append_build_log(workflow, component, parent)
+ else
+ workflow
+ end
+ end
+
+ _otherwise ->
+ transmuted = Transmutable.to_component(component)
+ do_add_component(workflow, transmuted, parent, opts)
+ end
+ end
+
+ defp validate_ports(consumer, parents, opts) when is_list(parents) do
+ Enum.each(parents, fn parent -> validate_ports(consumer, parent, opts) end)
+ end
+
+ defp validate_ports(consumer, producer, opts) do
+ validation = Keyword.get(opts, :validate, :error)
+
+ if validation == :off or Component.impl_for(producer) == nil do
+ :ok
+ else
+ producer_outputs = Component.outputs(producer)
+ consumer_inputs = Component.inputs(consumer)
+
+ case Component.TypeCompatibility.ports_compatible?(producer_outputs, consumer_inputs) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reasons} ->
+ case validation do
+ :warn ->
+ Logger.warning(
+ "Port incompatibility connecting #{inspect(component_name(consumer))} to #{inspect(component_name(producer))}: #{inspect(reasons)}"
+ )
+
+ :error ->
+ raise Runic.IncompatiblePortError,
+ producer: producer,
+ consumer: consumer,
+ reasons: reasons
+ end
+ end
+ end
+ end
+
+ defp component_name(%{name: name}) when not is_nil(name), do: name
+ defp component_name(%{hash: hash}), do: hash
+ defp component_name(other), do: inspect(other)
+
+ defp maybe_draw_connects_to(workflow, component, parents) when is_list(parents) do
+ Enum.reduce(parents, workflow, fn parent, wrk ->
+ maybe_draw_connects_to(wrk, component, parent)
+ end)
+ end
+
+ defp maybe_draw_connects_to(workflow, _component, %Root{}), do: workflow
+
+ defp maybe_draw_connects_to(workflow, component, parent) do
+ parent_component = find_owning_component(workflow, parent)
+
+ if parent_component do
+ Private.draw_connection(workflow, parent_component, component, :connects_to)
+ else
+ workflow
+ end
+ end
+
+ defp find_owning_component(%__MODULE__{components: components, graph: g}, node) do
+ node_hash = if is_map(node) and Map.has_key?(node, :hash), do: node.hash, else: nil
+
+ case Enum.find(components, fn {_name, hash} -> hash == node_hash end) do
+ {_name, hash} ->
+ Map.get(g.vertices, hash)
+
+ nil ->
+ # The node might be a sub-component; find the component that owns it via :component_of
+ case Graph.in_edges(g, node, by: :component_of) do
+ [%{v1: owner} | _] -> owner
+ _ -> nil
+ end
+ end
+ end
+
+ @doc """
+ Adds a component to the workflow and returns the updated workflow along with
+ the `%ComponentAdded{}` events produced.
+
+ This is useful for event-sourced workflow construction where you need to
+ capture the events for later replay via `apply_events/2`.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ step = Runic.step(fn x -> x + 1 end, name: :add_one)
+ {workflow, events} = Workflow.add_with_events(Workflow.new(), step)
+
+ # Events can rebuild the same workflow
+ rebuilt = Workflow.apply_events(Workflow.new(), events)
+ """
+ def add_with_events(%__MODULE__{} = workflow, component, opts \\ []) do
+ to = opts[:to]
+
+ parent_step =
+ if not is_nil(to) do
+ get_component(workflow, to) ||
+ get_by_hash(workflow, to) ||
+ root()
+ else
+ root()
+ end
+
+ events = build_events(component, to)
+
+ workflow =
+ component
+ |> Component.connect(parent_step, workflow)
+ |> append_build_log(events)
+
+ # |> maybe_put_component(component)
+
+ {workflow, events}
+ end
+
+ defp build_events(component, parents) when is_list(parents) do
+ Enum.reduce(parents, [], fn parent, events ->
+ events ++ build_events(component, parent)
+ end)
+ end
+
+ defp build_events(component, %{name: name}) do
+ build_events(component, name)
+ end
+
+ defp build_events(component, parent) do
+ closure = Map.get(component, :closure)
+
+ [
+ %ComponentAdded{
+ closure: closure,
+ name: component.name,
+ to: parent,
+ hash: Map.get(component, :hash),
+ # Backward compatibility: also set source/bindings for old deserialization
+ source: Component.source(component),
+ bindings: if(closure, do: closure.bindings, else: %{})
+ }
+ ]
+ end
+
+ defp append_build_log(%__MODULE__{build_log: bl} = workflow, events) when is_list(events) do
+ %__MODULE__{
+ workflow
+ | build_log: bl ++ events
+ }
+ end
+
+ defp append_build_log(%__MODULE__{} = workflow, component, %Root{} = parent) do
+ do_append_build_log(workflow, component, parent)
+ end
+
+ defp append_build_log(%__MODULE__{} = workflow, component, parents) when is_list(parents) do
+ Enum.reduce(parents, workflow, fn parent, wrk ->
+ append_build_log(wrk, component, parent)
+ end)
+ end
+
+ defp append_build_log(%__MODULE__{} = workflow, component, %{name: name}) do
+ do_append_build_log(workflow, component, name)
+ end
+
+ defp append_build_log(%__MODULE__{} = workflow, component, to) do
+ do_append_build_log(workflow, component, to)
+ end
+
+ defp do_append_build_log(%__MODULE__{build_log: bl} = workflow, component, parent) do
+ # Use new closure field if available, otherwise fall back to old format
+ event =
+ case Map.get(component, :closure) do
+ %Closure{} = closure ->
+ %ComponentAdded{
+ closure: closure,
+ name: component.name,
+ to: parent,
+ hash: Map.get(component, :hash)
+ }
+
+ nil ->
+ # Backward compatibility: use old source + bindings format
+ %ComponentAdded{
+ source: Component.source(component),
+ name: component.name,
+ to: parent,
+ hash: Map.get(component, :hash),
+ bindings: Map.get(component, :bindings, %{})
+ }
+ end
+
+ %__MODULE__{
+ workflow
+ | build_log: [event | bl]
+ }
+ end
+
+ @doc """
+ Returns a list of `%ComponentAdded{}` events for serialization and recovery.
+
+ Use with `from_log/1` to persist and rebuild workflows.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ workflow = Workflow.new() |> Workflow.add(step)
+
+ # Get the build log for serialization
+ log = Workflow.build_log(workflow)
+ serialized = :erlang.term_to_binary(log)
+
+ # Later, rebuild from log
+ restored_log = :erlang.binary_to_term(serialized)
+ restored = Workflow.from_log(restored_log)
+
+ ## Returns
+
+ A list of `%ComponentAdded{}` events in order of addition, each containing
+ the closure needed to rebuild the component.
+ """
+ def build_log(wrk) do
+ # BFS reduce the graph following only flow edges and accumulate into a list of ComponentAdded events
+ # Or accumulate connections of added components in graph by keeping the source with a `component_of` edge to invokables.
+ Enum.reverse(wrk.build_log)
+ end
+
+ @doc """
+ Applies a single `%ComponentAdded{}` event to the workflow, adding the
+ component described by the event.
+
+ Part of the event sourcing system — use with events captured from
+ `add_with_events/2` or `build_log/1` to reconstruct a workflow incrementally.
+ """
+ def apply_event(%__MODULE__{} = wrk, %ComponentAdded{} = event) do
+ component = component_from_added(event)
+
+ add(wrk, component, to: event.to)
+ end
+
+ def apply_event(%__MODULE__{} = wf, %FactProduced{} = e) do
+ fact = Fact.new(hash: e.hash, value: e.value, ancestry: e.ancestry, meta: e.meta)
+ wf = log_fact(wf, fact)
+
+ case e.ancestry do
+ {producer_hash, _parent_fact_hash} ->
+ producer = Map.get(wf.graph.vertices, producer_hash)
+
+ if producer do
+ wf
+ |> draw_connection(producer, fact, e.producer_label, weight: e.weight || 0)
+ |> maybe_track_latest_state_fact(e)
+ else
+ wf
+ end
+
+ nil ->
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %ActivationConsumed{} = e) do
+ node = Map.get(wf.graph.vertices, e.node_hash)
+ fact = Map.get(wf.graph.vertices, e.fact_hash)
+
+ if node && fact do
+ mark_runnable_as_ran(wf, node, fact)
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %ConditionSatisfied{} = e) do
+ fact = Map.get(wf.graph.vertices, e.fact_hash)
+ cond_node = Map.get(wf.graph.vertices, e.condition_hash)
+
+ if fact && cond_node do
+ draw_connection(wf, fact, cond_node, :satisfied, weight: e.weight || 0)
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %RunnableActivated{} = e) do
+ fact = Map.get(wf.graph.vertices, e.fact_hash)
+ node = Map.get(wf.graph.vertices, e.node_hash)
+
+ if fact && node do
+ draw_connection(wf, fact, node, e.activation_kind)
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %MapReduceTracked{} = e) do
+ seen_key = {e.fan_out_hash, e.source_fact_hash, e.step_hash}
+ seen = Map.get(wf.mapped, seen_key, %{})
+ seen = Map.put(seen, e.fan_out_fact_hash, e.result_fact_hash)
+
+ mapped =
+ wf.mapped
+ |> Map.put(seen_key, seen)
+ |> Map.put({:fan_out_for_batch, e.source_fact_hash}, e.fan_out_hash)
+
+ %{wf | mapped: mapped}
+ end
+
+ def apply_event(%__MODULE__{} = wf, %StateInitiated{} = e) do
+ init_fact = Fact.new(hash: e.init_fact_hash, value: e.init_value, ancestry: e.init_ancestry)
+ acc = Map.get(wf.graph.vertices, e.accumulator_hash)
+
+ if acc do
+ wf
+ |> log_fact(init_fact)
+ |> draw_connection(acc, init_fact, :state_initiated, weight: e.weight || 0)
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %JoinFactReceived{} = e) do
+ fact = Map.get(wf.graph.vertices, e.fact_hash)
+ join = Map.get(wf.graph.vertices, e.join_hash)
+
+ if fact && join do
+ draw_connection(wf, fact, join, :joined, weight: e.weight || 0)
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %JoinCompleted{} = e) do
+ join = Map.get(wf.graph.vertices, e.join_hash)
+
+ result_fact =
+ Fact.new(hash: e.result_fact_hash, value: e.result_value, ancestry: e.result_ancestry)
+
+ if join do
+ wf
+ |> log_fact(result_fact)
+ |> draw_connection(join, result_fact, :produced, weight: e.weight || 0)
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %JoinEdgeRelabeled{} = e) do
+ fact = Map.get(wf.graph.vertices, e.fact_hash)
+ join = Map.get(wf.graph.vertices, e.join_hash)
+
+ if fact && join do
+ case Graph.update_labelled_edge(wf.graph, fact, join, e.from_label, label: e.to_label) do
+ %Graph{} = updated -> %{wf | graph: updated}
+ _ -> wf
+ end
+ else
+ wf
+ end
+ end
+
+ def apply_event(%__MODULE__{} = wf, %FanOutFactEmitted{} = e) do
+ fact =
+ Fact.new(
+ hash: e.emitted_fact_hash,
+ value: e.emitted_value,
+ ancestry: e.emitted_ancestry,
+ meta: %{
+ item_index: e.item_index,
+ items_total: e.items_total,
+ fan_out_hash: e.fan_out_hash
+ }
+ )
+
+ fan_out = Map.get(wf.graph.vertices, e.fan_out_hash)
+
+ wf = log_fact(wf, fact)
+
+ wf =
+ if fan_out do
+ draw_connection(wf, fan_out, fact, :fan_out, weight: e.weight || 0)
+ else
+ wf
+ end
+
+ # Update mapped tracking for downstream FanIn coordination
+ key = {e.source_fact_hash, e.fan_out_hash}
+ expected = Map.get(wf.mapped, key, [])
+ %{wf | mapped: Map.put(wf.mapped, key, [e.emitted_fact_hash | expected])}
+ end
+
+ def apply_event(%__MODULE__{} = wf, %FanInCompleted{} = e) do
+ fan_in = Map.get(wf.graph.vertices, e.fan_in_hash)
+
+ result_fact =
+ Fact.new(hash: e.result_fact_hash, value: e.result_value, ancestry: e.result_ancestry)
+
+ if fan_in do
+ wf
+ |> log_fact(result_fact)
+ |> draw_connection(fan_in, result_fact, :reduced, weight: e.weight || 0)
+ |> fan_in_mark_completed(e)
+ |> fan_in_cleanup_mapped(e)
+ else
+ wf
+ end
+ end
+
+ # Fallback: protocol dispatch for custom events from external Invokable implementations
+ def apply_event(%__MODULE__{} = wf, event) when is_struct(event) do
+ Runic.Workflow.EventApplicator.apply(event, wf)
+ end
+
+ # Lean replay: creates FactRef instead of Fact when value was stripped.
+ # Falls back to full Fact for legacy events that still carry values (migration).
+ defp apply_event_lean(%__MODULE__{} = wf, %FactProduced{value: nil} = e) do
+ ref = %FactRef{hash: e.hash, ancestry: e.ancestry, meta: e.meta}
+ wf = log_fact(wf, ref)
+
+ case e.ancestry do
+ {producer_hash, _parent_fact_hash} ->
+ producer = Map.get(wf.graph.vertices, producer_hash)
+
+ if producer do
+ wf
+ |> draw_connection(producer, ref, e.producer_label, weight: e.weight || 0)
+ |> maybe_track_latest_state_fact(e)
+ else
+ wf
+ end
+
+ nil ->
+ wf
+ end
+ end
+
+ defp apply_event_lean(%__MODULE__{} = wf, %FactProduced{} = e) do
+ apply_event(wf, e)
+ end
+
+ defp maybe_track_latest_state_fact(%__MODULE__{} = wf, %FactProduced{
+ producer_label: :state_produced,
+ ancestry: {producer_hash, _parent_fact_hash},
+ hash: fact_hash
+ }) do
+ %{wf | mapped: Map.put(wf.mapped, {:latest_state_fact, producer_hash}, fact_hash)}
+ end
+
+ defp maybe_track_latest_state_fact(%__MODULE__{} = wf, _event), do: wf
+
+ defp fan_in_mark_completed(%__MODULE__{} = wf, %FanInCompleted{} = e) do
+ completed_key = {:fan_in_completed, e.source_fact_hash, e.fan_in_hash}
+ %{wf | mapped: Map.put(wf.mapped, completed_key, true)}
+ end
+
+ defp fan_in_cleanup_mapped(%__MODULE__{} = wf, %FanInCompleted{} = e) do
+ mapped =
+ wf.mapped
+ |> Map.delete(e.seen_key)
+ |> maybe_delete_expected_batch(e.expected_key, e.source_fact_hash, wf)
+
+ %{wf | mapped: mapped}
+ end
+
+ defp maybe_delete_expected_batch(
+ mapped,
+ {_source_fact_hash, fan_out_hash} = expected_key,
+ source_fact_hash,
+ wf
+ ) do
+ if all_fan_ins_completed_for_batch?(wf, fan_out_hash, source_fact_hash) do
+ mapped
+ |> Map.delete(expected_key)
+ |> Map.delete({:fan_out_for_batch, source_fact_hash})
+ else
+ mapped
+ end
+ end
+
+ defp all_fan_ins_completed_for_batch?(%__MODULE__{} = wf, fan_out_hash, source_fact_hash) do
+ case Map.get(wf.graph.vertices, fan_out_hash) do
+ %Runic.Workflow.FanOut{} = fan_out ->
+ wf.graph
+ |> Graph.out_edges(fan_out)
+ |> Enum.filter(&(&1.label == :fan_in))
+ |> Enum.map(& &1.v2.hash)
+ |> Enum.all?(fn fan_in_hash ->
+ Map.get(wf.mapped, {:fan_in_completed, source_fact_hash, fan_in_hash}, false)
+ end)
+
+ _ ->
+ true
+ end
+ end
+
+ @doc """
+ Applies a list of `%ComponentAdded{}` events to the workflow.
+
+ Batch version of `apply_event/2`. Events are applied in order via `Enum.reduce/3`.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ step1 = Runic.step(fn x -> x + 1 end, name: :add_one)
+ step2 = Runic.step(fn x -> x * 2 end, name: :double)
+
+ {workflow, events1} = Workflow.add_with_events(Workflow.new(), step1)
+ {_workflow, events2} = Workflow.add_with_events(workflow, step2, to: :add_one)
+
+ rebuilt = Workflow.apply_events(Workflow.new(), events1 ++ events2)
+ """
+ def apply_events(%__MODULE__{} = wrk, events) when is_list(events) do
+ Enum.reduce(events, wrk, fn event, acc ->
+ apply_event(acc, event)
+ end)
+ end
+
+ defp component_from_added(
+ %ComponentAdded{source: source, bindings: bindings, closure: closure} = event
+ ) do
+ component =
+ cond do
+ # New format: use closure if available
+ not is_nil(closure) ->
+ {comp, _} = Closure.eval(closure)
+ comp
+
+ # Old format: source + bindings with __caller_context__
+ not is_nil(source) ->
+ component_from_source_and_bindings(source, bindings)
+
+ # Fallback: shouldn't happen
+ true ->
+ raise "ComponentAdded event has neither closure nor source"
+ end
+
+ component =
+ component
+ |> Map.put(:name, event.name)
+
+ # Restore original hash if stored — ensures hash stability across rebuilds
+ if event.hash do
+ Map.put(component, :hash, event.hash)
+ else
+ component
+ end
+ end
+
+ # Backward compatibility: evaluate source with old __caller_context__ approach
+ defp component_from_source_and_bindings(source, bindings) do
+ caller_context = bindings[:__caller_context__]
+
+ # Build evaluation environment
+ {env, clean_bindings} =
+ case caller_context do
+ nil ->
+ {build_eval_env(), bindings}
+
+ %Macro.Env{context_modules: cms} = caller_context ->
+ env =
+ Enum.reduce(cms, caller_context, fn module, ctx ->
+ case Macro.Env.define_import(ctx, [], module) do
+ {:ok, new_ctx} ->
+ new_ctx
+
+ {:error, msg} ->
+ Logger.error(msg)
+ end
+ end)
+ |> Macro.Env.prune_compile_info()
+ |> Code.env_for_eval()
+
+ {env, Map.delete(bindings, :__caller_context__)}
+ end
+
+ # Convert bindings map to keyword list for evaluation
+ binding_list = Map.to_list(clean_bindings)
+
+ # Evaluate the source with bindings
+ {component, _binding} = Code.eval_quoted(source, binding_list, env)
+
+ component
+ end
+
+ @doc """
+ Rebuilds a workflow from a list of `%ComponentAdded{}` and/or `%ReactionOccurred{}` events.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ workflow = Workflow.new() |> Workflow.add(step)
+ log = Workflow.build_log(workflow)
+
+ restored = Workflow.from_log(log)
+ restored |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions()
+ # => [10]
+ """
+ def from_log(events) do
+ Enum.reduce(events, new(), fn
+ %ComponentAdded{} = event, wrk ->
+ component = component_from_added(event)
+
+ # Add the component to the workflow
+ add(wrk, component, to: event.to)
+
+ %ReactionOccurred{reaction: :generation}, wrk ->
+ # Skip legacy generation edges - generation counters removed
+ wrk
+
+ %ReactionOccurred{} = ro, wrk ->
+ # Rebuild getter_fn for :meta_ref edges during restoration
+ properties = restore_meta_ref_properties(ro.reaction, ro.properties)
+
+ reaction_edge =
+ Graph.Edge.new(
+ ro.from,
+ ro.to,
+ label: ro.reaction,
+ weight: ro.weight,
+ properties: properties
+ )
+
+ %__MODULE__{
+ wrk
+ | graph: Graph.add_edge(wrk.graph, reaction_edge)
+ }
+
+ %RunnableDispatched{} = event, wrk ->
+ %{wrk | runnable_events: wrk.runnable_events ++ [event]}
+
+ %RunnableCompleted{} = event, wrk ->
+ %{wrk | runnable_events: wrk.runnable_events ++ [event]}
+
+ %RunnableFailed{} = event, wrk ->
+ %{wrk | runnable_events: wrk.runnable_events ++ [event]}
+
+ %ComponentRemoved{name: name}, wrk ->
+ remove_component(wrk, name)
+ end)
+ end
+
+ @doc """
+ Rebuilds a workflow from a mixed stream of build and runtime events.
+
+ Separates `%ComponentAdded{}` events (workflow structure) from runtime events
+ (e.g. `%FactProduced{}`, `%ActivationConsumed{}`), rebuilds the workflow
+ structure via `from_log/1`, then replays runtime events via `apply_event/2`.
+
+ When a `base_workflow` is provided, skips structure reconstruction and
+ replays only runtime events on top of the base.
+
+ This is the primary recovery path for event-sourced stores using
+ `append/3` and `stream/2`.
+
+ ## Examples
+
+ # Full rebuild from event stream
+ {:ok, event_stream} = store.stream(workflow_id, store_state)
+ workflow = Workflow.from_events(Enum.to_list(event_stream))
+
+ # Replay on an existing workflow structure
+ workflow = Workflow.from_events(runtime_events, base_workflow)
+ """
+ @spec from_events(Enumerable.t(), t() | nil) :: t()
+ def from_events(events, base_workflow \\ nil) do
+ do_from_events(events, base_workflow, [])
+ end
+
+ @doc """
+ Like `from_events/2` but accepts options for replay control.
+
+ ## Options
+
+ * `:fact_mode` — `:full` (default) creates `Fact` vertices from `FactProduced`
+ events. `:ref` creates lightweight `FactRef` vertices instead, enabling
+ lean replay that avoids loading cold fact values into memory.
+
+ ## Example
+
+ # Lean replay: creates FactRef vertices, resolve hot ones later
+ workflow = Workflow.from_events(events, nil, fact_mode: :ref)
+ """
+ @spec from_events(Enumerable.t(), t() | nil, keyword()) :: t()
+ def from_events(events, base_workflow, opts) when is_list(opts) do
+ do_from_events(events, base_workflow, opts)
+ end
+
+ defp do_from_events(events, base_workflow, opts) do
+ fact_mode = Keyword.get(opts, :fact_mode, :full)
+
+ {build_events, runtime_events} =
+ Enum.split_with(events, fn event ->
+ is_struct(event, ComponentAdded) or
+ is_struct(event, ReactionOccurred) or
+ is_struct(event, RunnableDispatched) or
+ is_struct(event, RunnableCompleted) or
+ is_struct(event, RunnableFailed)
+ end)
+
+ base =
+ if base_workflow do
+ replay_snapshot_onto_base(base_workflow, build_events)
+ else
+ from_log(build_events)
+ end
+
+ runtime_events
+ |> Enum.reduce(base, fn event, wf ->
+ case {fact_mode, event} do
+ {:ref, %FactProduced{}} -> apply_event_lean(wf, event)
+ _ -> apply_event(wf, event)
+ end
+ end)
+ |> rebuild_coordination_state()
+ end
+
+ defp replay_snapshot_onto_base(base_workflow, build_events) do
+ Enum.reduce(build_events, base_workflow, fn
+ %ComponentAdded{}, wf ->
+ wf
+
+ %ReactionOccurred{reaction: :generation}, wf ->
+ wf
+
+ %ReactionOccurred{} = reaction, wf ->
+ apply_reaction_event_to_base(wf, reaction)
+
+ %RunnableDispatched{} = event, wf ->
+ %{wf | runnable_events: wf.runnable_events ++ [event]}
+
+ %RunnableCompleted{} = event, wf ->
+ %{wf | runnable_events: wf.runnable_events ++ [event]}
+
+ %RunnableFailed{} = event, wf ->
+ %{wf | runnable_events: wf.runnable_events ++ [event]}
+ end)
+ end
+
+ defp apply_reaction_event_to_base(%__MODULE__{} = workflow, %ReactionOccurred{} = event) do
+ properties = restore_meta_ref_properties(event.reaction, event.properties)
+ from = normalize_replayed_vertex(workflow, event.from)
+ to = normalize_replayed_vertex(workflow, event.to)
+
+ if reaction_edge_exists?(workflow.graph, from, to, event.reaction) do
+ workflow
+ else
+ reaction_edge =
+ Graph.Edge.new(
+ from,
+ to,
+ label: event.reaction,
+ weight: event.weight,
+ properties: properties
+ )
+
+ %{workflow | graph: Graph.add_edge(workflow.graph, reaction_edge)}
+ end
+ end
+
+ defp normalize_replayed_vertex(%__MODULE__{} = workflow, %{hash: hash} = vertex) do
+ Map.get(workflow.graph.vertices, hash, vertex)
+ end
+
+ defp normalize_replayed_vertex(_workflow, vertex), do: vertex
+
+ defp reaction_edge_exists?(graph, from, to, label) do
+ graph
+ |> Graph.out_edges(from)
+ |> Enum.any?(fn edge -> edge.v2 == to and edge.label == label end)
+ end
+
+ # Reconstructs the `mapped` coordination state after replaying events onto a
+ # base workflow. This is necessary because `replay_snapshot_onto_base/2` only
+ # appends runnable lifecycle events — it does not re-run the invoke cycle that
+ # normally builds this state incrementally during live execution.
+ #
+ # Without this pass, a workflow rehydrated mid-fan-out (e.g. a downstream step
+ # was waiting on a timer when the worker passivated) would lose track of which
+ # fan-out items were already processed. The FanIn readiness check compares the
+ # expected set (from FanOutFactEmitted events, which *are* replayed) against the
+ # seen set (from Step.invoke, which is *not* replayed). This function rebuilds
+ # the seen set and related bookkeeping from the graph so the FanIn can fire.
+ #
+ # Three sub-phases:
+ # 1. expected_batches — partially redundant with apply_event(FanOutFactEmitted)
+ # but also sets the {:fan_out_for_batch, _} lookup key which apply_event skips
+ # 2. map_reduce_tracks — rebuilds the {fan_out, source, step} → seen maps
+ # 3. fan_in_completions — marks fan-ins that already reduced before passivation
+ defp rebuild_coordination_state(%__MODULE__{} = workflow) do
+ mapped =
+ workflow.mapped
+ |> rebuild_expected_batches(workflow)
+ |> rebuild_map_reduce_tracks(workflow)
+ |> rebuild_fan_in_completions(workflow)
+
+ %{workflow | mapped: mapped}
+ end
+
+ defp rebuild_expected_batches(mapped, %__MODULE__{graph: graph}) do
+ graph
+ |> Graph.edges(by: :fan_out)
+ |> Enum.reduce(%{}, fn
+ %Graph.Edge{
+ v1: %Runic.Workflow.FanOut{hash: fan_out_hash},
+ v2: %Fact{hash: fact_hash, ancestry: {fan_out_hash, source_fact_hash}, meta: meta}
+ },
+ acc ->
+ item_index = meta |> Kernel.||(%{}) |> Map.get(:item_index)
+
+ Map.update(
+ acc,
+ {source_fact_hash, fan_out_hash},
+ [{item_index, fact_hash}],
+ &[{item_index, fact_hash} | &1]
+ )
+
+ _edge,
+ acc ->
+ acc
+ end)
+ |> Enum.reduce(mapped, fn {{source_fact_hash, fan_out_hash}, entries}, acc ->
+ emitted_fact_hashes =
+ entries
+ |> Enum.sort_by(fn {item_index, fact_hash} ->
+ {is_nil(item_index), item_index || 0, fact_hash}
+ end)
+ |> Enum.map(&elem(&1, 1))
+ |> Enum.reverse()
+
+ acc
+ |> Map.put({source_fact_hash, fan_out_hash}, emitted_fact_hashes)
+ |> Map.put({:fan_out_for_batch, source_fact_hash}, fan_out_hash)
+ end)
+ end
+
+ defp rebuild_map_reduce_tracks(mapped, %__MODULE__{} = workflow) do
+ path_fan_outs = Map.get(workflow.mapped, :mapped_path_fan_outs, %{})
+
+ Enum.reduce(workflow.graph.vertices, mapped, fn
+ {result_fact_hash, %Fact{ancestry: {producer_hash, _parent_hash}} = fact}, acc ->
+ case {Map.get(path_fan_outs, producer_hash), Map.get(workflow.graph.vertices, producer_hash)} do
+ {nil, _producer} ->
+ acc
+
+ {_fan_outs, %Runic.Workflow.FanOut{}} ->
+ acc
+
+ {_fan_outs, %Runic.Workflow.FanIn{}} ->
+ acc
+
+ {fan_outs, _producer} ->
+ Enum.reduce(fan_outs, acc, fn fan_out_hash, mapped_acc ->
+ case find_replayed_fan_out_info(workflow, fact, fan_out_hash) do
+ {source_fact_hash, ^fan_out_hash, fan_out_fact_hash} ->
+ seen_key = {fan_out_hash, source_fact_hash, producer_hash}
+ seen = Map.get(mapped_acc, seen_key, %{})
+ seen = Map.put(seen, fan_out_fact_hash, result_fact_hash)
+
+ mapped_acc
+ |> Map.put(seen_key, seen)
+ |> Map.put({:fan_out_for_batch, source_fact_hash}, fan_out_hash)
+
+ nil ->
+ mapped_acc
+ end
+ end)
+ end
+
+ _vertex, acc ->
+ acc
+ end)
+ end
+
+ defp rebuild_fan_in_completions(mapped, %__MODULE__{} = workflow) do
+ workflow.graph
+ |> Graph.edges(by: :reduced)
+ |> Enum.reduce(mapped, fn
+ %Graph.Edge{v1: %Runic.Workflow.FanIn{hash: fan_in_hash}, v2: %Fact{} = fact}, acc ->
+ case find_replayed_fan_out_source_fact_hash(workflow, fact) do
+ nil -> acc
+ source_fact_hash -> Map.put(acc, {:fan_in_completed, source_fact_hash, fan_in_hash}, true)
+ end
+
+ _edge, acc ->
+ acc
+ end)
+ end
+
+ defp find_replayed_fan_out_info(
+ workflow,
+ %Fact{hash: fact_hash, ancestry: {producer_hash, input_fact_hash}},
+ target_fan_out_hash
+ ) do
+ case Map.get(workflow.graph.vertices, producer_hash) do
+ %Runic.Workflow.FanOut{} = fan_out when fan_out.hash == target_fan_out_hash ->
+ {input_fact_hash, fan_out.hash, fact_hash}
+
+ _ ->
+ do_find_replayed_fan_out_info(workflow, input_fact_hash, target_fan_out_hash)
+ end
+ end
+
+ defp find_replayed_fan_out_info(_workflow, _fact, _target_fan_out_hash), do: nil
+
+ defp do_find_replayed_fan_out_info(_workflow, nil, _target_fan_out_hash), do: nil
+
+ defp do_find_replayed_fan_out_info(workflow, fact_hash, target_fan_out_hash) do
+ case Map.get(workflow.graph.vertices, fact_hash) do
+ %Fact{ancestry: {producer_hash, parent_fact_hash}} ->
+ case Map.get(workflow.graph.vertices, producer_hash) do
+ %Runic.Workflow.FanOut{} = fan_out when fan_out.hash == target_fan_out_hash ->
+ {parent_fact_hash, fan_out.hash, fact_hash}
+
+ _ ->
+ do_find_replayed_fan_out_info(workflow, parent_fact_hash, target_fan_out_hash)
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp find_replayed_fan_out_source_fact_hash(
+ workflow,
+ %Fact{ancestry: {producer_hash, input_fact_hash}}
+ ) do
+ case Map.get(workflow.graph.vertices, producer_hash) do
+ %Runic.Workflow.FanOut{} ->
+ input_fact_hash
+
+ _ ->
+ do_find_replayed_fan_out_source_fact_hash(workflow, input_fact_hash)
+ end
+ end
+
+ defp find_replayed_fan_out_source_fact_hash(_workflow, _fact), do: nil
+
+ defp do_find_replayed_fan_out_source_fact_hash(_workflow, nil), do: nil
+
+ defp do_find_replayed_fan_out_source_fact_hash(workflow, fact_hash) do
+ case Map.get(workflow.graph.vertices, fact_hash) do
+ %Fact{ancestry: {producer_hash, parent_fact_hash}} ->
+ case Map.get(workflow.graph.vertices, producer_hash) do
+ %Runic.Workflow.FanOut{} ->
+ parent_fact_hash
+
+ _ ->
+ do_find_replayed_fan_out_source_fact_hash(workflow, parent_fact_hash)
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ # Rebuild getter_fn for :meta_ref edges during from_log restoration
+ # The getter_fn was stripped during serialization and must be rebuilt
+ defp restore_meta_ref_properties(:meta_ref, properties) when is_map(properties) do
+ getter_fn = build_getter_fn(properties)
+ Map.put(properties, :getter_fn, getter_fn)
+ end
+
+ defp restore_meta_ref_properties(_label, properties), do: properties
+
+ defp build_eval_env() do
+ require Runic
+ import Runic, warn: false
+ alias Runic.Workflow, warn: false
+ __ENV__
+ end
+
+ @doc """
+ Returns a complete event snapshot for the workflow.
+
+ The returned list contains `%ComponentAdded{}` events followed by
+ `%ReactionOccurred{}` events, providing a full serializable snapshot of
+ the workflow structure and execution state. Use with `from_events/2` to
+ persist and restore both the workflow definition and its runtime state.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(steps: [Runic.step(fn x -> x + 1 end, name: :add)])
+ ran = Workflow.react_until_satisfied(workflow, 5)
+
+ events = Workflow.event_log(ran)
+ restored = Workflow.from_events(events)
+ """
+ @spec event_log(t()) :: list()
+ def event_log(wrk) do
+ build_log(wrk) ++ reactions_occurred(wrk) ++ wrk.runnable_events
+ end
+
+ @doc """
+ Returns the complete event log combining `build_log/1` and reaction events.
+
+ Deprecated in favor of `event_log/1` for full snapshots, or
+ `build_log/1 + workflow.uncommitted_events` for incremental event-sourced
+ persistence.
+ """
+ @deprecated "Use build_log/1 + workflow.uncommitted_events with from_events/2 instead"
+ def log(wrk) do
+ event_log(wrk)
+ end
+
+ defp reactions_occurred(%__MODULE__{graph: g}) do
+ reaction_edge_kinds =
+ Map.keys(g.edge_index)
+ |> Enum.reject(&(&1 == :flow))
+
+ for %Graph.Edge{} = edge <-
+ Graph.edges(g, by: reaction_edge_kinds) do
+ # Strip getter_fn from :meta_ref edges - it cannot be serialized
+ # and will be rebuilt during from_log restoration
+ properties = strip_getter_fn_for_serialization(edge.label, edge.properties)
+
+ %ReactionOccurred{
+ from: edge.v1,
+ to: edge.v2,
+ reaction: edge.label,
+ weight: edge.weight,
+ properties: properties
+ }
+ end
+ end
+
+ # Strip getter_fn from :meta_ref edge properties for serialization
+ # The getter_fn is rebuilt during from_log restoration using build_getter_fn/1
+ defp strip_getter_fn_for_serialization(:meta_ref, properties) when is_map(properties) do
+ Map.delete(properties, :getter_fn)
+ end
+
+ defp strip_getter_fn_for_serialization(_label, properties), do: properties
+
+ # def replay(%__MODULE__{}, log) when is_list(log) do
+ # Enum.reduce(log, new(), fn
+ # %Fact{ancestry: nil} = input_fact, wrk ->
+ # parent_step = root()
+ # Invokable.replay(parent_step, wrk, input_fact)
+
+ # %Fact{} = fact, wrk ->
+ # {parent_step_hash, _} = fact.ancestry
+ # parent_step = get_by_hash(wrk, parent_step_hash)
+ # Invokable.replay(parent_step, wrk, fact)
+ # end)
+ # end
+
+ @doc false
+ def register_component(workflow, component), do: Private.register_component(workflow, component)
+
+ @doc """
+ Returns a map of all registered components in the workflow by the registered component name.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [
+ ...> Runic.step(fn x -> x + 1 end, name: :add),
+ ...> Runic.step(fn x -> x * 2 end, name: :mult)
+ ...> ])
+ iex> components = Workflow.components(workflow)
+ iex> is_map(components)
+ true
+ iex> Map.keys(components) |> Enum.sort()
+ [:add, :mult]
+ """
+ def components(%__MODULE__{} = workflow) do
+ Map.new(workflow.components, fn {name, hash} ->
+ {name, Map.get(workflow.graph.vertices, hash)}
+ end)
+ end
+
+ @doc """
+ Returns a graph containing only the registered components as vertices
+ and `:connects_to` edges showing how components are connected to each other.
+
+ This provides a high-level projected view of the workflow suitable for
+ visualization in no-code builders or canvas UIs, without exposing the
+ internal invokable nodes (Steps, Conditions, Joins, etc.).
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ step1 = Runic.step(fn x -> x + 1 end, name: :add)
+ step2 = Runic.step(fn x -> x * 2 end, name: :double)
+
+ workflow = Workflow.new()
+ |> Workflow.add(step1)
+ |> Workflow.add(step2, to: :add)
+
+ component_graph = Workflow.connected_components(workflow)
+ # => Graph with :add and :double vertices, edge :add -> :double
+ """
+ def connected_components(%__MODULE__{graph: g, components: components}) do
+ component_vertices =
+ Map.new(components, fn {_name, hash} ->
+ {hash, Map.get(g.vertices, hash)}
+ end)
+
+ component_edges = Graph.edges(g, by: :connects_to)
+
+ Enum.reduce(component_edges, Graph.new(type: :directed), fn edge, cg ->
+ v1 = Map.get(component_vertices, edge.v1.hash, edge.v1)
+ v2 = Map.get(component_vertices, edge.v2.hash, edge.v2)
+ Graph.add_edge(cg, v1, v2, label: :connects_to)
+ end)
+ |> then(fn cg ->
+ # Ensure all registered components appear as vertices, even if unconnected
+ Enum.reduce(component_vertices, cg, fn {_hash, vertex}, acc ->
+ if vertex, do: Graph.add_vertex(acc, vertex), else: acc
+ end)
+ end)
+ end
+
+ @doc """
+ Returns a keyword list of sub-components of the given component by kind.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> rule = Runic.rule(fn x when x > 0 -> :positive end, name: :pos_check)
+ iex> workflow = Workflow.new() |> Workflow.add(rule)
+ iex> subs = Workflow.sub_components(workflow, :pos_check)
+ iex> Keyword.keys(subs) |> Enum.sort()
+ [:condition, :reaction]
+ """
+ def sub_components(%__MODULE__{} = workflow, component_name) do
+ component = get_component(workflow, component_name)
+
+ workflow.graph
+ |> Graph.out_edges(component, by: :component_of)
+ |> Enum.map(fn edge -> {edge.properties.kind, edge.v2} end)
+ end
+
+ @doc """
+ Returns a list of components in the workflow graph that are compatible for
+ connection with the given component.
+
+ Compatibility is determined by matching arity and requiring the vertex to
+ implement the `Component` protocol. Accepts a component name or struct.
+ """
+ # extend with I/O contract checks
+ def connectables(%__MODULE__{} = wrk, name)
+ when is_binary(name) or is_atom(name) or is_tuple(name) do
+ component = get_component(wrk, name)
+
+ connectables(wrk, component)
+ end
+
+ def connectables(%__MODULE__{graph: g} = _wrk, %{} = component) do
+ impls = Components.component_impls()
+
+ arity = Components.arity_of(component)
+
+ g
+ |> Graph.vertices()
+ |> Enum.filter(fn %{__struct__: module} = v ->
+ v_arity = Components.arity_of(v)
+
+ module in impls and
+ v_arity == arity
+ end)
+ end
+
+ @doc """
+ Checks whether a component can be connected at a given point in the workflow.
+
+ When called with `to: component_name`, validates that the target component
+ exists, arities match, and the components are connectable per the `Component`
+ protocol. Returns `:ok` on success or `{:error, reason}` on failure.
+
+ The 2-arity version (no `:to` option) always returns `:ok`, since any
+ component can be added to the workflow root.
+ """
+ def connectable?(wrk, component, to: component_name) do
+ with {:ok, added_to} <- fetch_component(wrk, component_name),
+ :ok <- arity_match(component, added_to),
+ {:ok, _} <-
+ Component.TypeCompatibility.ports_compatible?(
+ Component.outputs(added_to),
+ Component.inputs(component)
+ ) do
+ :ok
+ else
+ {:error, reasons} when is_list(reasons) -> {:error, {:incompatible_ports, reasons}}
+ otherwise -> otherwise
+ end
+ end
+
+ def connectable?(wrk, component, []) do
+ connectable?(wrk, component)
+ end
+
+ def connectable?(_wrk, _component) do
+ :ok
+ end
+
+ defp arity_match(component, add_to_component) do
+ if Components.arity_of(component) == Components.arity_of(add_to_component) do
+ :ok
+ else
+ {:error, :arity_mismatch}
+ end
+ end
+
+ @doc false
+ def add_before_hooks(workflow, hooks), do: Private.add_before_hooks(workflow, hooks)
+ @doc false
+ def add_after_hooks(workflow, hooks), do: Private.add_after_hooks(workflow, hooks)
+
+ defp resolve_component_to_node(%__MODULE__{} = workflow, {component_name, sub_component_kind}) do
+ get_component(workflow, {component_name, sub_component_kind}) |> List.first()
+ end
+
+ defp resolve_component_to_node(%__MODULE__{} = workflow, component_name)
+ when is_atom(component_name) or is_binary(component_name) do
+ get_component!(workflow, component_name)
+ end
+
+ defp resolve_component_to_node(%__MODULE__{} = workflow, hash) when is_integer(hash) do
+ get_by_hash(workflow, hash)
+ end
+
+ @doc """
+ Attaches a hook function to be run before a given component step.
+
+ The hook is a 3-arity function receiving `(step, workflow, input_fact)` and
+ must return the (possibly modified) workflow.
+
+ ## Example
+
+ workflow
+ |> Workflow.attach_before_hook(:my_step, fn step, workflow, input_fact ->
+ IO.inspect(input_fact, label: "Input fact")
+ workflow
+ end)
+ """
+ def attach_before_hook(%__MODULE__{} = workflow, component_name, hook)
+ when is_function(hook, 3) do
+ node = resolve_component_to_node(workflow, component_name)
+ node_hash = node.hash
+ hooks_for_component = Map.get(workflow.before_hooks, node_hash, [])
+
+ %__MODULE__{
+ workflow
+ | before_hooks:
+ Map.put(
+ workflow.before_hooks,
+ node_hash,
+ Enum.reverse([hook | hooks_for_component])
+ )
+ }
+ end
+
+ @doc """
+ Attaches a hook function to be run after a given component step.
+
+ ## Examples
+
+ ```
+ workflow
+ |> Workflow.attach_after_hook("my_component", fn step, workflow, output_fact ->
+ IO.inspect(output_fact, label: "Output fact")
+ IO.inspect(step, label: "Step")
+ workflow
+ end)
+ ```
+ """
+ def attach_after_hook(%__MODULE__{} = workflow, component_name, hook)
+ when is_function(hook, 3) do
+ node = resolve_component_to_node(workflow, component_name)
+ node_hash = node.hash
+ hooks_for_component = Map.get(workflow.after_hooks, node_hash, [])
+
+ %__MODULE__{
+ workflow
+ | after_hooks:
+ Map.put(
+ workflow.after_hooks,
+ node_hash,
+ Enum.reverse([hook | hooks_for_component])
+ )
+ }
+ end
+
+ @doc false
+ def run_before_hooks(workflow, step, input_fact),
+ do: Private.run_before_hooks(workflow, step, input_fact)
+
+ @doc false
+ def run_after_hooks(workflow, step, output_fact),
+ do: Private.run_after_hooks(workflow, step, output_fact)
+
+ @doc """
+ Applies a list of hook apply functions to the workflow.
+
+ This is used during the apply phase to execute deferred workflow
+ modifications returned by hooks during the execute phase.
+
+ ## Example
+
+ workflow
+ |> Workflow.apply_hook_fns(before_apply_fns)
+ |> do_main_apply_logic()
+ |> Workflow.apply_hook_fns(after_apply_fns)
+ """
+ @spec apply_hook_fns(t(), [function()]) :: t()
+ def apply_hook_fns(%__MODULE__{} = workflow, apply_fns) when is_list(apply_fns) do
+ Enum.reduce(apply_fns, workflow, fn apply_fn, wrk -> apply_fn.(wrk) end)
+ end
+
+ def apply_hook_fns(%__MODULE__{} = workflow, nil), do: workflow
+
+ @doc """
+ Removes a component and its owned invokable nodes from the workflow.
+
+ Invokable nodes that are shared with other components (due to content-addressable
+ hashing) are preserved. Downstream nodes of the removed component are rewired
+ to the removed component's upstream parents so the rest of the workflow stays
+ connected.
+
+ Also removes any `:connects_to` edges involving the component and appends
+ a `%ComponentRemoved{}` event to the build log.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ step1 = Runic.step(fn x -> x + 1 end, name: :add)
+ step2 = Runic.step(fn x -> x * 2 end, name: :double)
+ step3 = Runic.step(fn x -> x - 1 end, name: :subtract)
+
+ workflow = Workflow.new()
+ |> Workflow.add(step1)
+ |> Workflow.add(step2, to: :add)
+ |> Workflow.add(step3, to: :double)
+
+ workflow = Workflow.remove_component(workflow, :double)
+ # :add now flows directly to :subtract
+ """
+ def remove_component(%__MODULE__{} = workflow, component_name) do
+ component = get_component(workflow, component_name)
+
+ if is_nil(component) do
+ workflow
+ else
+ do_remove_component(workflow, component, component_name)
+ end
+ end
+
+ defp do_remove_component(workflow, component, component_name) do
+ # 1. Find all invokable nodes owned by this component via :component_of edges
+ owned_edges = Graph.out_edges(workflow.graph, component, by: :component_of)
+ owned_nodes = Enum.map(owned_edges, & &1.v2)
+
+ # Include the component itself if it's also an invokable node (e.g. Step, Condition, Accumulator)
+ all_owned =
+ if component in owned_nodes do
+ owned_nodes
+ else
+ [component | owned_nodes]
+ end
+ |> Enum.uniq_by(& &1.hash)
+
+ # 2. Determine which nodes are safe to remove (not owned by another component)
+ {safe_to_remove, _shared} =
+ Enum.split_with(all_owned, fn node ->
+ owners =
+ Graph.in_edges(workflow.graph, node, by: :component_of)
+ |> Enum.map(& &1.v1)
+ |> Enum.reject(&(&1 == component))
+
+ owners == []
+ end)
+
+ safe_hashes = MapSet.new(safe_to_remove, & &1.hash)
+
+ # 3. Find upstream parents and downstream children for rewiring
+ # Upstream: :flow in-edges into any safe-to-remove node from nodes NOT being removed
+ # Downstream: :flow out-edges from any safe-to-remove node to nodes NOT being removed
+ upstream_parents =
+ safe_to_remove
+ |> Enum.flat_map(fn node ->
+ Graph.in_edges(workflow.graph, node, by: :flow)
+ |> Enum.reject(fn edge ->
+ case edge.v1 do
+ %Root{} -> false
+ node -> MapSet.member?(safe_hashes, node.hash)
+ end
+ end)
+ |> Enum.map(& &1.v1)
+ end)
+ |> Enum.uniq_by(fn
+ %Root{} -> :root
+ node -> node.hash
+ end)
+
+ downstream_children =
+ safe_to_remove
+ |> Enum.flat_map(fn node ->
+ Graph.out_edges(workflow.graph, node, by: :flow)
+ |> Enum.reject(fn edge -> MapSet.member?(safe_hashes, edge.v2.hash) end)
+ |> Enum.map(& &1.v2)
+ end)
+ |> Enum.uniq_by(& &1.hash)
+
+ # 4. Rewire: connect each upstream parent to each downstream child
+ # Add both :flow edges (for runtime) and :connects_to edges (for component graph)
+ graph =
+ Enum.reduce(upstream_parents, workflow.graph, fn parent, g ->
+ Enum.reduce(downstream_children, g, fn child, g ->
+ g = Graph.add_edge(g, parent, child, label: :flow, weight: 0)
+
+ # Find the owning components for the parent/child to add :connects_to edges
+ temp_wrk = %{workflow | graph: g}
+ parent_component = find_owning_component(temp_wrk, parent)
+ child_component = find_owning_component(temp_wrk, child)
+
+ if parent_component && child_component && parent_component != child_component do
+ Graph.add_edge(g, parent_component, child_component, label: :connects_to)
+ else
+ g
+ end
+ end)
+ end)
+
+ # 5. Delete the safe-to-remove vertices (also removes all their edges)
+ graph = Graph.delete_vertices(graph, safe_to_remove)
+
+ # 6. Remove from components registry (including any sub-component entries like {name, :kind})
+ components =
+ Enum.reject(workflow.components, fn
+ {^component_name, _hash} -> true
+ {{^component_name, _kind}, _hash} -> true
+ _ -> false
+ end)
+ |> Map.new()
+
+ # 7. Clean up hooks
+ component_hash = Component.hash(component)
+
+ before_hooks = Map.delete(workflow.before_hooks, component_hash)
+ after_hooks = Map.delete(workflow.after_hooks, component_hash)
+
+ # 8. Append ComponentRemoved event to build log
+ event = %ComponentRemoved{name: component_name, hash: component_hash}
+
+ %__MODULE__{
+ workflow
+ | graph: graph,
+ components: components,
+ before_hooks: before_hooks,
+ after_hooks: after_hooks,
+ build_log: workflow.build_log ++ [event]
+ }
+ end
+
+ defp get_by_hash(%__MODULE__{graph: g}, %{hash: hash}) do
+ Map.get(g.vertices, hash)
+ end
+
+ defp get_by_hash(%__MODULE__{graph: g}, hash) when is_integer(hash) do
+ Map.get(g.vertices, hash)
+ end
+
+ defp get_by_hash(_, _hash) do
+ nil
+ end
+
+ @doc """
+ Adds a step to the root of the workflow that is always evaluated with a new fact.
+ """
+ def add_step(workflow, child_step), do: Private.add_step(workflow, child_step)
+
+ @doc """
+ Adds a dependent step to some other step in a workflow by name.
+
+ The dependent step is fed signed facts produced by the parent step during a reaction.
+
+ Adding dependent steps is the most low-level way of building a dataflow execution graph as it assumes no conditional, branching logic.
+
+ If you're just building a pipeline, dependent steps can be sufficient, however you might want Rules for conditional branching logic.
+ """
+ def add_step(workflow, parent_step, child_step),
+ do: Private.add_step(workflow, parent_step, child_step)
+
+ @doc """
+ Adds a list of rules to the workflow root.
+
+ Each rule is added via `add/2`. Passing `nil` is a no-op.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ rules = [
+ Runic.rule(fn x when x > 0 -> :positive end, name: :pos),
+ Runic.rule(fn x when x < 0 -> :negative end, name: :neg)
+ ]
+
+ workflow = Workflow.new() |> Workflow.add_rules(rules)
+ """
+ def add_rules(workflow, nil), do: workflow
+
+ def add_rules(workflow, rules) do
+ Enum.reduce(rules, workflow, fn %Rule{} = rule, wrk ->
+ add(wrk, rule)
+ end)
+ end
+
+ @doc """
+ Adds a batch of steps to the workflow, supporting pipelines and joins.
+
+ Accepts a list where each element is one of:
+
+ - `%Step{}` — added directly to the workflow root
+ - `{%Step{}, dependent_steps}` — a pipeline: the parent step is added to root,
+ then dependent steps are connected downstream
+ - `{[%Step{}, ...], dependent_steps}` — multiple parent steps joined: all parents
+ are added to root, a `Join` node is created, and dependents follow the join
+
+ Passing `nil` is a no-op.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Workflow.new() |> Workflow.add_steps([
+ {Runic.step(fn x -> x + 1 end, name: :add),
+ [Runic.step(fn x -> x * 2 end, name: :double)]}
+ ])
+ """
+ def add_steps(workflow, steps) when is_list(steps) do
+ # root level pass
+ Enum.reduce(steps, workflow, fn
+ %Step{} = step, wrk ->
+ add(wrk, step)
+
+ {[_step | _] = parent_steps, dependent_steps}, wrk ->
+ wrk =
+ Enum.reduce(parent_steps, wrk, fn step, wrk ->
+ # add_step(wrk, step)
+ add(wrk, step)
+ end)
+
+ join =
+ parent_steps
+ |> Enum.map(& &1.hash)
+ |> Join.new()
+
+ wrk = add_step(wrk, parent_steps, join)
+
+ add_dependent_steps(wrk, {join, dependent_steps})
+
+ {step, _dependent_steps} = pipeline, wrk ->
+ wrk = add(wrk, step)
+ add_dependent_steps(wrk, pipeline)
+ end)
+ end
+
+ def add_steps(workflow, nil), do: workflow
+
+ @doc false
+ def add_dependent_steps(workflow, parent_and_deps),
+ do: Private.add_dependent_steps(workflow, parent_and_deps)
+
+ def maybe_put_component(workflow, step), do: Private.maybe_put_component(workflow, step)
+
+ def get_named_vertex(%__MODULE__{graph: g, mapped: mapped}, name) do
+ hash = Map.get(mapped, name)
+ g.vertices |> Map.get(hash)
+ end
+
+ @doc """
+ Retrieves a component from the workflow by name.
+
+ Returns the component struct or `nil` if not found.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end, name: :double)])
+ iex> step = Workflow.get_component(workflow, :double)
+ iex> step.name
+ :double
+
+ ## Subcomponent Access
+
+ For composite components like rules, access subcomponents with a tuple:
+
+ # Get the condition of a rule
+ Workflow.get_component(workflow, {:my_rule, :condition})
+
+ # Get the reaction of a rule
+ Workflow.get_component(workflow, {:my_rule, :reaction})
+ """
+ def get_component(
+ %__MODULE__{} = wrk,
+ {component_name, subcomponent_kind_or_name}
+ ) do
+ cmp = get_component(wrk, component_name)
+
+ for edge <-
+ Graph.out_edges(wrk.graph, cmp,
+ by: :component_of,
+ where: fn edge ->
+ edge.properties[:kind] == subcomponent_kind_or_name or
+ Map.get(edge.v2, :name) == subcomponent_kind_or_name
+ end
+ ) do
+ edge.v2
+ end
+ end
+
+ def get_component(%__MODULE__{} = wrk, %{name: name}) do
+ get_component(wrk, name)
+ end
+
+ def get_component(%__MODULE__{} = workflow, names) when is_list(names) do
+ Enum.map(names, &get_component(workflow, &1))
+ end
+
+ def get_component(
+ %__MODULE__{components: components, graph: g},
+ component_name
+ ) do
+ component_hash = Map.get(components, component_name)
+ Map.get(g.vertices, component_hash)
+ end
+
+ @doc """
+ Retrieves a component from the workflow by name, raising if not found.
+
+ Same as `get_component/2` but raises `KeyError` if no component matches.
+
+ ## Example
+
+ step = Workflow.get_component!(workflow, :my_step)
+ """
+ def get_component!(wrk, name) do
+ get_component(wrk, name) || raise(KeyError, "No component found with name #{name}")
+ end
+
+ @doc """
+ Retrieves a component from the workflow by name, returning an ok/error tuple.
+
+ Returns `{:ok, component}` if found, or `{:error, :no_component_by_name}` if
+ no component is registered with the given name.
+
+ ## Example
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Workflow.new() |> Workflow.add(Runic.step(fn x -> x end, name: :identity))
+ iex> {:ok, step} = Workflow.fetch_component(workflow, :identity)
+ iex> step.name
+ :identity
+ iex> Workflow.fetch_component(workflow, :nonexistent)
+ {:error, :no_component_by_name}
+ """
+ def fetch_component(%__MODULE__{} = wrk, %{name: name}) do
+ fetch_component(wrk, name)
+ end
+
+ def fetch_component(%__MODULE__{components: components} = wrk, name) do
+ case Map.fetch(components, name) do
+ :error ->
+ {:error, :no_component_by_name}
+
+ {:ok, cmp_hash} ->
+ component = Map.get(wrk.graph.vertices, cmp_hash)
+ {:ok, component}
+ end
+ end
+
+ @doc """
+ Returns the child steps connected via dataflow edges from a parent step.
+
+ Useful for traversing the workflow graph structure.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(steps: [
+ {Runic.step(fn x -> x + 1 end, name: :add),
+ [Runic.step(fn x -> x * 2 end, name: :double)]}
+ ])
+
+ add_step = Workflow.get_component(workflow, :add)
+ [double_step] = Workflow.next_steps(workflow, add_step)
+ double_step.name # => :double
+ """
+ def next_steps(%__MODULE__{graph: g}, parent_step) do
+ next_steps(g, parent_step)
+ end
+
+ def next_steps(%Graph{} = g, parent_steps) when is_list(parent_steps) do
+ Enum.flat_map(parent_steps, fn parent_step ->
+ next_steps(g, parent_step)
+ end)
+ end
+
+ def next_steps(%Graph{} = g, parent_step) do
+ for e <- Graph.out_edges(g, parent_step, by: :flow), do: e.v2
+ end
+
+ @doc false
+ def add_rule(workflow, rule), do: Private.add_rule(workflow, rule)
+
+ @doc """
+ Merges the second workflow into the first, maintaining the name of the first.
+
+ All root-level components from `workflow2` are connected to the root of `workflow`,
+ making them siblings to the existing root components.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> w1 = Runic.workflow(steps: [Runic.step(fn x -> x + 1 end, name: :add)])
+ iex> w2 = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end, name: :mult)])
+ iex> merged = Workflow.merge(w1, w2)
+ iex> merged |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions() |> Enum.sort()
+ [6, 10]
+
+ ## Merging Other Types
+
+ Any value implementing the `Runic.Transmutable` protocol can be merged:
+
+ workflow = Workflow.merge(workflow, rule)
+ workflow = Workflow.merge(workflow, step)
+
+ ## Use Cases
+
+ - Combining modular workflow fragments at runtime
+ - Building workflows dynamically from configuration
+ - Composing reusable workflow templates
+ """
+ def merge(%__MODULE__{} = workflow, %__MODULE__{} = workflow2) do
+ Component.connect(workflow2, %Root{}, workflow)
+ end
+
+ # def merge(
+ # %__MODULE__{graph: g1, components: c1, mapped: m1} = workflow,
+ # %__MODULE__{graph: g2, components: c2, mapped: m2} = _workflow2
+ # ) do
+ # merged_graph =
+ # Graph.Reducers.Bfs.reduce(g2, g1, fn
+ # %Root{} = root, g ->
+ # out_edges = Enum.uniq(Graph.out_edges(g, root) ++ Graph.out_edges(g2, root))
+
+ # g =
+ # Enum.reduce(out_edges, Graph.add_vertex(g, root), fn edge, g ->
+ # Graph.add_edge(g, edge)
+ # end)
+
+ # {:next, g}
+
+ # generation, g when is_integer(generation) ->
+ # out_edges = Enum.uniq(Graph.out_edges(g, generation) ++ Graph.out_edges(g2, generation))
+
+ # g =
+ # g
+ # |> Graph.add_vertex(generation)
+ # |> Graph.add_edges(out_edges)
+
+ # {:next, g}
+
+ # v, g ->
+ # g = Graph.add_vertex(g, v, v.hash)
+
+ # out_edges = Enum.uniq(Graph.out_edges(g, v) ++ Graph.out_edges(g2, v))
+
+ # g =
+ # Enum.reduce(out_edges, g, fn
+ # %{v1: %Fact{} = _fact_v1, v2: _v2, label: :generation} = memory2_edge, mem ->
+ # Graph.add_edge(mem, memory2_edge)
+
+ # %{v1: %Fact{} = fact_v1, v2: v2, label: label} = memory2_edge, mem
+ # when label in [:matchable, :runnable, :ran] ->
+ # out_edge_labels_of_into_mem_for_edge =
+ # mem
+ # |> Graph.out_edges(fact_v1)
+ # |> Enum.filter(&(&1.v2 == v2))
+ # |> MapSet.new(& &1.label)
+
+ # cond do
+ # label in [:matchable, :runnable] and
+ # MapSet.member?(out_edge_labels_of_into_mem_for_edge, :ran) ->
+ # mem
+
+ # label == :ran and
+ # MapSet.member?(out_edge_labels_of_into_mem_for_edge, :runnable) ->
+ # Graph.update_labelled_edge(mem, fact_v1, v2, :runnable, label: :ran)
+
+ # true ->
+ # Graph.add_edge(mem, memory2_edge)
+ # end
+
+ # %{v1: _v1, v2: _v2} = memory2_edge, mem ->
+ # Graph.add_edge(mem, memory2_edge)
+ # end)
+
+ # {:next, g}
+ # end)
+
+ # %__MODULE__{
+ # workflow
+ # | graph: merged_graph,
+ # components: Map.merge(c1, c2),
+ # mapped:
+ # Enum.reduce(m1, m2, fn
+ # {:mapped_paths, mapset}, acc ->
+ # Map.put(acc, :mapped_paths, MapSet.union(acc.mapped_paths, mapset))
+
+ # {k, v}, acc ->
+ # Map.put(
+ # acc,
+ # k,
+ # [v | acc[k] || []]
+ # |> List.flatten()
+ # |> Enum.reject(&is_nil/1)
+ # |> Enum.reverse()
+ # |> Enum.uniq()
+ # )
+ # end)
+ # }
+ # end
+
+ def merge(%__MODULE__{} = workflow, flowable) do
+ merge(workflow, Transmutable.transmute(flowable))
+ end
+
+ def merge(flowable_1, flowable_2) do
+ merge(Transmutable.transmute(flowable_1), Transmutable.transmute(flowable_2))
+ end
+
+ @doc """
+ Lists all `%Step{}` structs in the workflow.
+
+ Useful for introspecting workflow structure.
+
+ ## Example
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [
+ ...> Runic.step(fn x -> x + 1 end, name: :add),
+ ...> Runic.step(fn x -> x * 2 end, name: :mult)
+ ...> ])
+ iex> steps = Workflow.steps(workflow)
+ iex> length(steps)
+ 2
+ iex> Enum.map(steps, & &1.name) |> Enum.sort()
+ [:add, :mult]
+ """
+ def steps(%__MODULE__{graph: g}) do
+ Enum.filter(Graph.vertices(g), &match?(%Step{}, &1))
+ end
+
+ @doc """
+ Lists all `%Condition{}` structs in the workflow.
+
+ Conditions are the "left-hand side" predicates of rules.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(rules: [
+ Runic.rule(fn x when x > 0 -> :positive end)
+ ])
+
+ [condition] = Workflow.conditions(workflow)
+ """
+ def conditions(%__MODULE__{graph: g}) do
+ Enum.filter(Graph.vertices(g), &match?(%Condition{}, &1))
+ end
+
+ @doc false
+ def satisfied_condition_hashes(workflow, fact),
+ do: Private.satisfied_condition_hashes(workflow, fact)
+
+ @spec raw_reactions(Runic.Workflow.t()) :: list(any())
+ @doc """
+ Returns raw (output value) side effects of the workflow - i.e. facts resulting from the execution of a Runic.Step
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> workflow |> Workflow.react(5) |> Workflow.raw_reactions() |> Enum.sort()
+ [5, 10]
+ """
+ def raw_reactions(%__MODULE__{} = wrk) do
+ wrk
+ |> reactions()
+ |> Enum.map(& &1.value)
+ end
+
+ @spec reactions(Runic.Workflow.t()) :: list(Runic.Workflow.Fact.t())
+ @doc """
+ Returns raw (output value) side effects of the workflow - i.e. facts resulting from the execution of a Runic.Step
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> facts = workflow |> Workflow.react(5) |> Workflow.reactions()
+ iex> Enum.map(facts, & &1.value) |> Enum.sort()
+ [5, 10]
+ """
+ def reactions(%__MODULE__{graph: graph}) do
+ for %Graph.Edge{} = edge <-
+ Graph.edges(
+ graph,
+ by: [:produced, :ran],
+ where: fn edge -> match?(%Fact{}, edge.v1) or match?(%Fact{}, edge.v2) end
+ ),
+ uniq: true do
+ if(match?(%Fact{}, edge.v1), do: edge.v1, else: edge.v2)
+ end
+ end
+
+ @doc """
+ Returns all `%Fact{}` structs produced by the workflow.
+
+ Unlike `raw_productions/1`, this returns the full Fact structs including
+ ancestry information for causal tracing. Does not include input facts.
+
+ ## Example
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> [fact] = workflow |> Workflow.react(5) |> Workflow.productions()
+ iex> fact.value
+ 10
+ """
+ def productions(%__MODULE__{graph: graph}) do
+ for %Graph.Edge{} = edge <-
+ Graph.edges(graph, by: [:produced, :state_produced, :state_initiated, :reduced]) do
+ edge.v2
+ end
+ end
+
+ @doc """
+ Returns all productions of a component or sub component by name.
+
+ Many components are made up of sub components so this may return multiple facts for each part.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end, name: :double)])
+ iex> [fact] = workflow |> Workflow.react(5) |> Workflow.productions(:double)
+ iex> fact.value
+ 10
+ """
+ def productions(%__MODULE__{} = wrk, component_name)
+ when is_atom(component_name) or is_binary(component_name) do
+ cmp = get_component(wrk, component_name)
+
+ productions(wrk, cmp)
+ end
+
+ def productions(%__MODULE__{} = wrk, component) do
+ wrk.graph
+ |> Graph.out_edges(component, by: :component_of)
+ |> Enum.flat_map(fn %{v2: invokable} ->
+ for edge <-
+ Graph.out_edges(wrk.graph, invokable,
+ by: [:produced, :state_produced, :state_initiated, :reduced]
+ ) do
+ edge.v2
+ end
+ end)
+ end
+
+ @doc """
+ Returns all facts produced in the workflow so far by component name and sub component.
+
+ Returns a map where each key is the name of the component and the value is a list of facts produced by that component.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [
+ ...> Runic.step(fn x -> x + 1 end, name: :add),
+ ...> Runic.step(fn x -> x * 2 end, name: :mult)
+ ...> ])
+ iex> pbc = workflow |> Workflow.react(5) |> Workflow.productions_by_component()
+ iex> is_map(pbc)
+ true
+ iex> Map.keys(pbc) |> Enum.sort()
+ [:add, :mult]
+ """
+ def productions_by_component(%__MODULE__{components: components} = wrk) do
+ Map.new(components, fn {name, component} ->
+ productions = productions(wrk, component)
+
+ {name, productions}
+ end)
+ end
+
+ @doc """
+ Returns the raw values from all produced facts.
+
+ This is the most common way to extract results from a workflow.
+ Returns unwrapped values without the `%Fact{}` struct metadata.
+
+ ## Example
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> workflow |> Workflow.react(5) |> Workflow.raw_productions()
+ [10]
+
+ ## By Component Name
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(
+ ...> steps: [Runic.step(fn x -> x * 2 end, name: :double)]
+ ...> )
+ iex> workflow |> Workflow.react(5) |> Workflow.raw_productions(:double)
+ [10]
+ """
+ def raw_productions(%__MODULE__{graph: graph}) do
+ for %Graph.Edge{v2: %Fact{value: value}} <-
+ Graph.edges(graph, by: [:produced, :state_produced, :state_initiated, :reduced]) do
+ value
+ end
+ end
+
+ @doc """
+ Returns a map of component name to raw production values for all components.
+
+ Like `raw_productions/1` but grouped by component name.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(
+ steps: [
+ {Runic.step(fn x -> x + 1 end, name: "step 1"),
+ [Runic.step(fn x -> x + 2 end, name: "step 2")]}
+ ]
+ )
+
+ %{"step 1" => [2], "step 2" => [4]} =
+ workflow
+ |> Workflow.react_until_satisfied(1)
+ |> Workflow.raw_productions_by_component()
+ """
+ def raw_productions_by_component(%__MODULE__{components: components} = wrk) do
+ Map.new(components, fn {name, component} ->
+ productions = raw_productions(wrk, component)
+
+ {name, productions}
+ end)
+ end
+
+ def raw_productions(%__MODULE__{} = wrk, component_name)
+ when is_atom(component_name) or is_binary(component_name) do
+ cmp = get_component(wrk, component_name)
+
+ raw_productions(wrk, cmp)
+ end
+
+ def raw_productions(%__MODULE__{} = wrk, component) do
+ wrk.graph
+ |> Graph.out_edges(component, by: :component_of)
+ |> Enum.flat_map(fn %{v2: invokable} ->
+ for %Graph.Edge{v2: %Fact{value: value}} <-
+ Graph.out_edges(wrk.graph, invokable,
+ by: [:produced, :state_produced, :state_initiated, :reduced]
+ ) do
+ value
+ end
+ end)
+ end
+
+ @doc """
+ Extracts structured results from a workflow.
+
+ When `component_names` is a list, extracts the last produced value for each
+ named component, independent of output port declarations.
+
+ When `component_names` is `nil` (default) and the workflow declares
+ `output_ports`, returns a map keyed by port name with values extracted
+ according to each port's `:from` binding and `:cardinality`.
+
+ When `component_names` is `nil` and no `output_ports` are declared, falls
+ back to `raw_productions_by_component/1`.
+
+ ## Options
+
+ - `:facts` — when `true`, returns `%Fact{}` structs instead of raw values.
+ Default `false`.
+ - `:all` — when `true`, returns all produced values as a list instead of
+ just the last one, regardless of port cardinality. Default `false`.
+
+ ## Examples
+
+ # With output ports
+ workflow = Runic.workflow(
+ name: :pipeline,
+ steps: [{Runic.step(&parse/1, name: :parse),
+ [Runic.step(&price/1, name: :price)]}],
+ output_ports: [total: [type: :float, from: :price]]
+ )
+
+ %{total: 42.50} =
+ workflow
+ |> Workflow.react_until_satisfied(order)
+ |> Workflow.results()
+
+ # Explicit component selection
+ %{add: 6, mult: 10} =
+ workflow
+ |> Workflow.react_until_satisfied(5)
+ |> Workflow.results([:add, :mult])
+
+ # With options
+ %{price: [%Fact{}, %Fact{}]} =
+ Workflow.results(workflow, [:price], facts: true, all: true)
+
+ # Use output ports with options
+ %{total: [42.50, 43.00]} =
+ Workflow.results(workflow, nil, all: true)
+ """
+ @spec results(t(), [atom() | binary()] | nil, keyword()) :: map()
+ def results(workflow, component_names \\ nil, opts \\ [])
+
+ # Explicit component selection
+ def results(%__MODULE__{} = workflow, component_names, opts)
+ when is_list(component_names) do
+ return_facts = Keyword.get(opts, :facts, false)
+ return_all = Keyword.get(opts, :all, false)
+
+ Map.new(component_names, fn name ->
+ values = extract_productions(workflow, name, return_facts)
+ {name, select_value(values, :one, return_all)}
+ end)
+ end
+
+ # Port-driven extraction
+ def results(%__MODULE__{output_ports: ports} = workflow, nil, opts)
+ when is_list(ports) do
+ return_facts = Keyword.get(opts, :facts, false)
+ return_all = Keyword.get(opts, :all, false)
+
+ Map.new(ports, fn {port_name, port_opts} ->
+ component_name = Keyword.get(port_opts, :from, port_name)
+ cardinality = Keyword.get(port_opts, :cardinality, :one)
+ values = extract_productions(workflow, component_name, return_facts)
+ {port_name, select_value(values, cardinality, return_all)}
+ end)
+ end
+
+ # Fallback: no ports, no explicit names
+ def results(%__MODULE__{} = workflow, nil, opts) do
+ if Keyword.get(opts, :facts, false) do
+ productions_by_component(workflow)
+ else
+ raw_productions_by_component(workflow)
+ end
+ end
+
+ defp extract_productions(workflow, component_name, return_facts) do
+ case get_component(workflow, component_name) do
+ nil ->
+ []
+
+ component ->
+ if return_facts do
+ productions(workflow, component)
+ else
+ raw_productions(workflow, component)
+ end
+ end
+ end
+
+ defp select_value(values, _cardinality, true = _return_all), do: values
+ defp select_value(values, :many, false = _return_all), do: values
+ defp select_value([], :one, false = _return_all), do: nil
+ defp select_value(values, :one, false = _return_all), do: List.last(values)
+
+ @spec facts(Runic.Workflow.t()) :: list(Runic.Workflow.Fact.t())
+ @doc """
+ Returns all facts in the workflow, including inputs and productions.
+
+ Unlike `productions/1`, this includes input facts which have `ancestry: nil`.
+ Useful for tracing the full causal chain of workflow execution.
+
+ ## Example
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> facts = workflow |> Workflow.react(5) |> Workflow.facts()
+ iex> length(facts)
+ 2
+ iex> Enum.map(facts, & &1.value) |> Enum.sort()
+ [5, 10]
+
+ ## Ancestry
+
+ - Input facts have `ancestry: nil`
+ - Produced facts have `ancestry: {producer_hash, parent_fact_hash}`
+ """
+ def facts(%__MODULE__{graph: graph}) do
+ for v <- Graph.vertices(graph), match?(%Fact{}, v), do: v
+ end
+
+ @doc false
+ def matches(workflow), do: Private.matches(workflow)
+
+ @doc """
+ Executes a single reaction cycle using the three-phase model.
+
+ This function advances the workflow by one "generation" - executing all currently
+ runnable steps/rules. Use `react_until_satisfied/3` to run to completion.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> workflow = Workflow.react(workflow, 5)
+ iex> Workflow.raw_productions(workflow)
+ [10]
+
+ ## Options
+
+ - `:async` - When `true`, executes runnables in parallel using `Task.async_stream`.
+ Useful for I/O-bound workflows. Default: `false` (serial execution)
+ - `:max_concurrency` - Maximum parallel tasks when `async: true`. Default: `System.schedulers_online()`
+ - `:timeout` - Timeout for each task when `async: true`. Default: `:infinity`
+
+ ## Parallel Execution
+
+ workflow = Workflow.react(workflow, 5, async: true, max_concurrency: 4)
+ """
+ @spec react(t(), keyword()) :: t()
+ def react(workflow, opts \\ [])
+
+ def react(%__MODULE__{} = workflow, opts) when is_list(opts) do
+ if is_runnable?(workflow) do
+ {workflow, runnables} = prepare_for_dispatch(workflow)
+
+ if Keyword.get(opts, :async, false) do
+ execute_runnables_async(workflow, runnables, opts)
+ else
+ execute_runnables_serial(workflow, runnables, opts)
+ end
+ else
+ workflow
+ end
+ end
+
+ def react(%__MODULE__{} = wrk, %Fact{ancestry: nil} = fact) do
+ react(wrk, fact, [])
+ end
+
+ def react(%__MODULE__{} = wrk, raw_fact) when not is_list(raw_fact) do
+ react(wrk, Fact.new(value: raw_fact), [])
+ end
+
+ @doc """
+ Executes a single reaction cycle with the given input value.
+
+ Plans through the match phase and executes one cycle of runnables.
+ Commonly used with a raw value to start workflow processing.
+
+ ## Options
+
+ - `:async` - When `true`, executes runnables in parallel. Default: `false`
+ - `:max_concurrency` - Maximum parallel tasks when `async: true`
+ - `:timeout` - Timeout for each task when `async: true`
+ - `:run_context` - A map of external values for `context/1` expressions.
+ See `put_run_context/2` and `react_until_satisfied/3`.
+ """
+ @spec react(t(), Fact.t() | term(), keyword()) :: t()
+ def react(%__MODULE__{} = wrk, %Fact{ancestry: nil} = fact, opts) do
+ wrk
+ |> maybe_apply_run_context(opts)
+ |> invoke(root(), fact)
+ |> react(opts)
+ end
+
+ def react(%__MODULE__{} = wrk, raw_fact, opts) do
+ react(wrk, Fact.new(value: raw_fact), opts)
+ end
+
+ defp execute_runnables_serial(workflow, runnables, opts) do
+ policies = resolve_effective_policies(workflow, opts)
+ driver_opts = build_driver_opts(opts)
+
+ runnables
+ |> Enum.map(fn runnable ->
+ if policies == [] do
+ Invokable.execute(runnable.node, runnable)
+ else
+ policy = SchedulerPolicy.resolve(runnable, policies)
+ PolicyDriver.execute(runnable, policy, driver_opts)
+ end
+ end)
+ |> Enum.reduce(workflow, fn executed, wrk -> apply_runnable(wrk, executed) end)
+ end
+
+ defp execute_runnables_async(workflow, runnables, opts) do
+ max_concurrency = Keyword.get(opts, :max_concurrency, System.schedulers_online())
+ timeout = Keyword.get(opts, :timeout, :infinity)
+ policies = resolve_effective_policies(workflow, opts)
+ driver_opts = build_driver_opts(opts)
+
+ runnables
+ |> Task.async_stream(
+ fn runnable ->
+ if policies == [] do
+ Invokable.execute(runnable.node, runnable)
+ else
+ policy = SchedulerPolicy.resolve(runnable, policies)
+ PolicyDriver.execute(runnable, policy, driver_opts)
+ end
+ end,
+ max_concurrency: max_concurrency,
+ timeout: timeout
+ )
+ |> Enum.reduce(workflow, fn
+ {:ok, executed}, wrk ->
+ apply_runnable(wrk, executed)
+
+ {:exit, reason}, wrk ->
+ Logger.warning("Async execution failed: #{inspect(reason)}")
+ wrk
+ end)
+ end
+
+ defp build_driver_opts(opts) do
+ case Keyword.get(opts, :deadline_at) do
+ nil -> []
+ deadline_at -> [deadline_at: deadline_at]
+ end
+ end
+
+ defp maybe_apply_run_context(workflow, opts) do
+ case Keyword.get(opts, :run_context) do
+ nil -> workflow
+ ctx when is_map(ctx) -> put_run_context(workflow, ctx)
+ end
+ end
+
+ defp resolve_effective_policies(workflow, opts) do
+ runtime = Keyword.get(opts, :scheduler_policies)
+ mode = Keyword.get(opts, :scheduler_policies_mode, :merge)
+ base = workflow.scheduler_policies
+
+ case {runtime, mode} do
+ {nil, _} -> base
+ {_, :replace} -> runtime
+ {_, :merge} -> SchedulerPolicy.merge_policies(runtime, base)
+ end
+ end
+
+ @doc """
+ Executes the workflow until no more runnables remain.
+
+ Iteratively calls `react/2` until all reachable nodes have been executed.
+ This is the recommended way to fully evaluate a workflow pipeline.
+
+ ## Basic Usage
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(
+ ...> steps: [
+ ...> {Runic.step(fn x -> x + 1 end, name: :add_one),
+ ...> [Runic.step(fn x -> x * 2 end, name: :double)]}
+ ...> ]
+ ...> )
+ iex> results = workflow |> Workflow.react_until_satisfied(5) |> Workflow.raw_productions()
+ iex> Enum.sort(results)
+ [6, 12]
+
+ ## Options
+
+ - `:async` - When `true`, executes runnables in parallel. Default: `false`
+ - `:max_concurrency` - Maximum parallel tasks when `async: true`
+ - `:timeout` - Timeout for each task when `async: true`
+ - `:deadline_ms` - Wall-clock deadline for the entire workflow execution in milliseconds.
+ The policy driver will fail runnables with `{:deadline_exceeded, remaining_ms}` if the
+ deadline is reached. Converted to an absolute `deadline_at` monotonic time internally.
+ - `:checkpoint` - A 1-arity function called after each react cycle with the updated workflow.
+ Useful for persisting intermediate state in long-running durable workflows.
+ - `:run_context` - A map of external values keyed by component name, made available
+ to components that use `context/1` expressions. Supports a `:_global` key for
+ values available to all components. See `put_run_context/2`.
+
+ ## Warning
+
+ Workflows that don't terminate (e.g., hooks that continuously add steps) will
+ cause infinite loops. For non-terminating workflows, use `react/2` in a
+ controlled loop with exit conditions.
+
+ ## Best For
+
+ - IEx/REPL experimentation
+ - Scripts and notebooks
+ - Testing workflows
+ - Simple batch processing
+
+ For production use with complex scheduling needs, consider `prepare_for_dispatch/1`
+ with a custom scheduler process.
+ """
+ @spec react_until_satisfied(t(), Fact.t() | term(), keyword()) :: t()
+ def react_until_satisfied(workflow, fact_or_value \\ nil, opts \\ [])
+
+ def react_until_satisfied(%__MODULE__{} = workflow, nil, opts) do
+ opts = maybe_convert_deadline(opts)
+ workflow = maybe_apply_run_context(workflow, opts)
+ do_react_until_satisfied(workflow, is_runnable?(workflow), opts)
+ end
+
+ def react_until_satisfied(%__MODULE__{} = wrk, %Fact{ancestry: nil} = fact, opts) do
+ opts = maybe_convert_deadline(opts)
+ wrk = maybe_apply_run_context(wrk, opts)
+
+ wrk
+ |> react(fact, opts)
+ |> react_until_satisfied(nil, opts)
+ end
+
+ def react_until_satisfied(%__MODULE__{} = wrk, raw_fact, opts) do
+ react_until_satisfied(wrk, Fact.new(value: raw_fact), opts)
+ end
+
+ defp maybe_convert_deadline(opts) do
+ case {Keyword.get(opts, :deadline_ms), Keyword.get(opts, :deadline_at)} do
+ {nil, _} ->
+ opts
+
+ {_deadline_ms, deadline_at} when not is_nil(deadline_at) ->
+ # Already converted
+ opts
+
+ {deadline_ms, nil} ->
+ deadline_at = System.monotonic_time(:millisecond) + deadline_ms
+
+ opts
+ |> Keyword.put(:deadline_at, deadline_at)
+ end
+ end
+
+ defp do_react_until_satisfied(%__MODULE__{} = workflow, true = _is_runnable?, opts) do
+ checkpoint = Keyword.get(opts, :checkpoint)
+
+ workflow
+ |> react(opts)
+ |> then(fn wrk ->
+ if is_function(checkpoint, 1), do: checkpoint.(wrk)
+ do_react_until_satisfied(wrk, is_runnable?(wrk), opts)
+ end)
+ end
+
+ defp do_react_until_satisfied(%__MODULE__{} = workflow, false = _is_runnable?, _opts),
+ do: workflow
+
+ @doc """
+ Removes all `%Fact{}` vertices and generation integers from the workflow graph.
+
+ This clears the workflow's accumulated memory while preserving its structure
+ (steps, rules, conditions, and flow edges). Useful for long-running workflows
+ to free memory between processing batches.
+
+ ## Example
+
+ require Runic
+ alias Runic.Workflow
+
+ workflow = Runic.workflow(steps: [Runic.step(fn x -> x + 1 end)])
+ workflow = Workflow.react(workflow, 5)
+
+ # Facts exist after reaction
+ refute Enum.empty?(Workflow.facts(workflow))
+
+ # Purge clears them
+ workflow = Workflow.purge_memory(workflow)
+ assert Enum.empty?(Workflow.facts(workflow))
+ """
+ def purge_memory(%__MODULE__{} = wrk) do
+ %__MODULE__{
+ wrk
+ | graph:
+ Graph.Reducers.Bfs.reduce(wrk.graph, wrk.graph, fn
+ %Fact{} = fact, g ->
+ {:next, Graph.delete_vertex(g, fact)}
+
+ generation, g when is_integer(generation) ->
+ {:next, Graph.delete_vertex(g, generation)}
+
+ _node, g ->
+ {:next, g}
+ end)
+ }
+ end
+
+ @doc """
+ For a new set of inputs, `plan/2` prepares the workflow agenda for the next set of reactions by
+ matching through left-hand-side conditions in the workflow network.
+
+ For an inference engine's match -> select -> execute phase, this is the match phase.
+
+ Runic Workflow evaluation is forward chaining meaning from the root of the graph it starts
+ by evaluating the direct children of the root node. If the workflow has any sort of
+ conditions (from rules, etc) these conditions are prioritized in the agenda for the next cycle.
+
+ Plan will always match through a single level of nodes and identify the next runnable activations
+ available.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end, name: :double)])
+ iex> workflow = Workflow.plan(workflow, 5)
+ iex> Workflow.is_runnable?(workflow)
+ true
+ iex> workflow |> Workflow.react() |> Workflow.raw_productions()
+ [10]
+ """
+ def plan(%__MODULE__{} = wrk, %Fact{} = fact) do
+ invoke(wrk, root(), fact)
+ end
+
+ def plan(%__MODULE__{} = wrk, raw_fact) do
+ plan(wrk, Fact.new(value: raw_fact))
+ end
+
+ @doc """
+ `plan/1` will, for all next left hand side / match phase runnables activate and prepare next match runnables.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> rule = Runic.rule(fn x when x > 0 -> :positive end, name: :pos)
+ iex> workflow = Runic.workflow(rules: [rule])
+ iex> workflow = Workflow.plan(workflow, 5)
+ iex> workflow = Workflow.plan(workflow)
+ iex> Workflow.is_runnable?(workflow)
+ true
+ """
+ def plan(%__MODULE__{} = wrk) do
+ wrk
+ |> maybe_prepare_next_generation_from_state_accumulations()
+ |> next_match_runnables()
+ |> Enum.reduce(wrk, fn {node, fact}, wrk ->
+ invoke(wrk, node, fact)
+ end)
+ end
+
+ @doc """
+ Eagerly plans through all produced facts in the workflow that haven't yet activated
+ subsequent runnables.
+
+ This is useful for after a workflow has already been ran and satisfied without runnables
+ and you want to continue preparing reactions in the workflow from output facts.
+
+ Finds facts via `:produced` edges that don't have pending `:runnable` or `:matchable` edges.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [
+ ...> {Runic.step(fn x -> x + 1 end, name: :add),
+ ...> [Runic.step(fn x -> x * 2 end, name: :double)]}
+ ...> ])
+ iex> workflow = Workflow.react_until_satisfied(workflow, 5)
+ iex> Workflow.is_runnable?(workflow)
+ false
+ iex> workflow = Workflow.plan_eagerly(workflow)
+ iex> Workflow.is_runnable?(workflow)
+ true
+ """
+ def plan_eagerly(%__MODULE__{graph: graph} = workflow) do
+ # Find all produced facts that don't have pending activations
+ # Also exclude facts that have :ran edges - those have already been processed
+ new_productions =
+ for edge <- Graph.edges(graph, by: [:produced, :state_produced, :reduced]),
+ fact = edge.v2,
+ is_struct(fact, Fact),
+ Enum.empty?(Graph.out_edges(graph, fact, by: [:runnable, :matchable, :ran])) do
+ fact
+ end
+ |> Enum.uniq()
+
+ Enum.reduce(new_productions, workflow, fn output_fact, wrk ->
+ plan(wrk, output_fact)
+ end)
+ |> activate_through_possible_matches()
+ end
+
+ @doc """
+ Invokes all left hand side / match-phase runnables in the workflow for a given input fact until all are satisfied.
+
+ Upon calling plan_eagerly/2, the workflow will only have right hand side runnables left to execute that react or react_until_satisfied can execute.
+
+ ## Examples
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> rule = Runic.rule(fn x when x > 0 -> :positive end, name: :pos)
+ iex> workflow = Runic.workflow(rules: [rule])
+ iex> workflow = Workflow.plan_eagerly(workflow, 5)
+ iex> Workflow.is_runnable?(workflow)
+ true
+ iex> workflow |> Workflow.react() |> Workflow.raw_productions()
+ [:positive]
+ """
+ def plan_eagerly(%__MODULE__{} = workflow, %Fact{ancestry: nil} = input_fact) do
+ workflow
+ |> plan(input_fact)
+ |> activate_through_possible_matches()
+ end
+
+ def plan_eagerly(%__MODULE__{} = workflow, %Fact{ancestry: {_, _}} = produced_fact) do
+ workflow
+ |> plan(produced_fact)
+ |> activate_through_possible_matches()
+ end
+
+ def plan_eagerly(%__MODULE__{} = wrk, raw_fact) do
+ plan_eagerly(wrk, Fact.new(value: raw_fact))
+ end
+
+ defp activate_through_possible_matches(wrk) do
+ activate_through_possible_matches(
+ wrk,
+ next_match_runnables(wrk),
+ any_match_phase_runnables?(wrk)
+ )
+ end
+
+ defp activate_through_possible_matches(
+ wrk,
+ next_match_runnables,
+ _any_match_phase_runnables? = true
+ ) do
+ Enum.reduce(next_match_runnables, wrk, fn {node, fact}, wrk ->
+ wrk
+ |> invoke(node, fact)
+ |> activate_through_possible_matches()
+ end)
+ end
+
+ defp activate_through_possible_matches(
+ wrk,
+ _match_runnables,
+ _any_match_phase_runnables? = false
+ ) do
+ wrk
+ end
+
+ @doc """
+ Executes the Invokable protocol for a runnable step and fact using the three-phase model.
+
+ This is a lower level API than as with the react or plan functions intended for process based
+ scheduling and execution of workflows.
+
+ The three-phase execution model:
+ 1. **Prepare** - Extract minimal context from workflow, build a `%Runnable{}`
+ 2. **Execute** - Run the node's work function in isolation
+ 3. **Apply** - Reduce results back into the workflow
+
+ See `invoke_with_events/2` for a version that returns events produced by the invokation that can be
+ persisted incrementally as the workflow is executed for durable execution of long running workflows.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+ alias Runic.Workflow.Fact
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ workflow = Workflow.new() |> Workflow.add(step)
+ fact = Fact.new(value: 5)
+
+ workflow = Workflow.invoke(workflow, Workflow.root(), fact)
+ Workflow.is_runnable?(workflow)
+ # => true
+ """
+ def invoke(%__MODULE__{} = wrk, step, fact) do
+ case Invokable.prepare(step, wrk, fact) do
+ {:ok, runnable} ->
+ executed = Invokable.execute(step, runnable)
+ apply_runnable(wrk, executed)
+
+ {:skip, reducer_fn} ->
+ reducer_fn.(wrk)
+
+ {:defer, reducer_fn} ->
+ reducer_fn.(wrk)
+ end
+ end
+
+ @doc """
+ Executes the Invokable protocol for runnable.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+ alias Runic.Workflow.Invokable
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ workflow = Workflow.new() |> Workflow.add(step)
+ workflow = Workflow.plan_eagerly(workflow, 5)
+
+ [runnable | _] = Workflow.prepared_runnables(workflow)
+ executed = Workflow.execute_runnable(runnable)
+ executed.status
+ # => :completed
+ """
+ def execute_runnable(%Runnable{} = runnable) do
+ Invokable.execute(runnable.node, runnable)
+ end
+
+ @doc false
+ @deprecated "Use causal_depth/2 or ancestry_depth/2 instead"
+ def causal_generation(%__MODULE__{} = workflow, %Fact{} = fact) do
+ Private.causal_generation(workflow, fact)
+ end
+
+ @doc """
+ Executes the Invokable protocol for a runnable step and fact and returns all newly caused events produced by the invokation.
+
+ This API is intended to enable durable execution of long running workflows by returning events that can be persisted elsewhere
+ so the workflow state can be rebuilt with `from_log/1`.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+ alias Runic.Workflow.Fact
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ workflow = Workflow.new() |> Workflow.add(step)
+ fact = Fact.new(value: 5)
+
+ {workflow, events} = Workflow.invoke_with_events(workflow, Workflow.root(), fact)
+ is_list(events)
+ # => true
+ """
+ def invoke_with_events(%__MODULE__{} = wrk, step, fact) do
+ wrk = invoke(wrk, step, fact)
+ new_events = events_produced_since(wrk, fact)
+
+ {wrk, new_events}
+ end
+
+ @doc """
+ Returns all %ReactionOccurred{} events caused since the given fact.
+
+ Uses ancestry-based causal ordering. Returns events with depth greater than
+ the reference fact's depth, scoped to the same causal root.
+
+ ## Examples
+
+ require Runic
+ alias Runic.Workflow
+ alias Runic.Workflow.Fact
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ workflow = Workflow.new() |> Workflow.add(step)
+ fact = Fact.new(value: 5)
+ workflow = Workflow.react(workflow, fact)
+ events = Workflow.events_produced_since(workflow, fact)
+ length(events) > 0
+ # => true
+ """
+ def events_produced_since(
+ %__MODULE__{graph: graph} = wrk,
+ %Fact{} = fact
+ ) do
+ ref_depth = ancestry_depth(wrk, fact)
+ ref_root = root_ancestor_hash(wrk, fact)
+
+ reaction_labels = [
+ :produced,
+ :state_produced,
+ :reduced,
+ :satisfied,
+ :state_initiated,
+ :fan_out,
+ :joined
+ ]
+
+ Graph.edges(graph,
+ by: reaction_labels,
+ where: fn edge -> edge.weight > ref_depth end
+ )
+ |> Enum.filter(fn edge ->
+ # Only include facts from the same causal chain
+ case edge.v2 do
+ %Fact{} = produced_fact ->
+ root_ancestor_hash(wrk, produced_fact) == ref_root
+
+ _ ->
+ true
+ end
+ end)
+ |> Enum.map(fn edge ->
+ %ReactionOccurred{
+ from: edge.v1,
+ to: edge.v2,
+ reaction: edge.label,
+ weight: edge.weight,
+ properties: edge.properties
+ }
+ end)
+ end
+
+ defp any_match_phase_runnables?(%__MODULE__{graph: graph}) do
+ not Enum.empty?(Graph.edges(graph, by: :matchable))
+ end
+
+ defp next_match_runnables(%__MODULE__{graph: graph}) do
+ for %{v1: fact, v2: step} <- Graph.edges(graph, by: :matchable) do
+ {step, fact}
+ end
+ end
+
+ @doc """
+ Returns `true` if the workflow has pending work (runnable or matchable nodes).
+
+ Use this in scheduler loops to determine when to stop processing.
+
+ ## Example
+
+ iex> require Runic
+ iex> alias Runic.Workflow
+ iex> workflow = Runic.workflow(steps: [Runic.step(fn x -> x * 2 end)])
+ iex> Workflow.is_runnable?(workflow)
+ false
+ iex> workflow = Workflow.plan_eagerly(workflow, 5)
+ iex> Workflow.is_runnable?(workflow)
+ true
+ iex> workflow = Workflow.react(workflow)
+ iex> Workflow.is_runnable?(workflow)
+ false
+ """
+ @spec is_runnable?(Runic.Workflow.t()) :: boolean()
+ def is_runnable?(%__MODULE__{graph: graph}) do
+ not Enum.empty?(Graph.edges(graph, by: [:runnable, :matchable]))
+ end
+
+ @doc """
+ Returns a list of `{node, fact}` pairs ready for activation in the next cycle.
+
+ All runnables returned are independent and can be executed in parallel.
+ This is a low-level API for custom schedulers. For most use cases, prefer
+ `prepare_for_dispatch/1` which returns fully prepared `%Runnable{}` structs.
+
+ ## Example
+
+ runnables = Workflow.next_runnables(workflow)
+ # => [{%Step{name: :add}, %Fact{value: 5}}, ...]
+ """
+ def next_runnables(%__MODULE__{graph: graph}) do
+ for %Graph.Edge{} = edge <- Graph.edges(graph, by: [:runnable, :matchable]) do
+ {edge.v2, edge.v1}
+ end
+ end
+
+ def next_runnables(workflow, fact_or_raw), do: Private.next_runnables(workflow, fact_or_raw)
+
+ @doc false
+ def log_fact(workflow, fact), do: Private.log_fact(workflow, fact)
+
+ defp maybe_prepare_next_generation_from_state_accumulations(workflow), do: workflow
+
+ @doc false
+ @deprecated "Generation counters removed; use ancestry_depth/2 for causal ordering"
+ def prepare_next_generation(%__MODULE__{} = workflow, %Fact{} = fact),
+ do: Private.prepare_next_generation(workflow, fact)
+
+ def prepare_next_generation(%__MODULE__{} = workflow, [%Fact{} | _] = facts),
+ do: Private.prepare_next_generation(workflow, facts)
+
+ @doc false
+ def draw_connection(workflow, node_1, node_2, connection, opts \\ []),
+ do: Private.draw_connection(workflow, node_1, node_2, connection, opts)
+
+ @doc false
+ def mark_runnable_as_ran(workflow, step, fact),
+ do: Private.mark_runnable_as_ran(workflow, step, fact)
+
+ @doc false
+ def prepare_next_runnables(workflow, node, fact),
+ do: Private.prepare_next_runnables(workflow, node, fact)
+
+ @doc """
+ Computes the causal depth of a fact by walking its ancestry chain.
+
+ Replaces generation counter for causal ordering. A fact with no ancestry
+ (root input) has depth 0. Each causal step adds 1 to the depth.
+
+ ## Examples
+
+ iex> ancestry_depth(workflow, root_fact)
+ 0
+
+ iex> ancestry_depth(workflow, fact_after_two_steps)
+ 2
+ """
+ @spec ancestry_depth(t(), Fact.t() | FactRef.t()) :: non_neg_integer()
+ def ancestry_depth(%__MODULE__{}, %Fact{ancestry: nil}), do: 0
+ def ancestry_depth(%__MODULE__{}, %FactRef{ancestry: nil}), do: 0
+
+ def ancestry_depth(%__MODULE__{graph: graph} = workflow, %Fact{
+ ancestry: {_producer_hash, parent_fact_hash}
+ }) do
+ case Map.get(graph.vertices, parent_fact_hash) do
+ %Fact{} = parent -> 1 + ancestry_depth(workflow, parent)
+ %FactRef{} = parent -> 1 + ancestry_depth(workflow, parent)
+ nil -> 1
+ end
+ end
+
+ def ancestry_depth(%__MODULE__{graph: graph} = workflow, %FactRef{
+ ancestry: {_producer_hash, parent_fact_hash}
+ }) do
+ case Map.get(graph.vertices, parent_fact_hash) do
+ %Fact{} = parent -> 1 + ancestry_depth(workflow, parent)
+ %FactRef{} = parent -> 1 + ancestry_depth(workflow, parent)
+ nil -> 1
+ end
+ end
+
+ @doc """
+ Returns the causal depth of a fact by walking its ancestry chain.
+
+ Alias for `ancestry_depth/2`. For facts without ancestry (root inputs), returns 0.
+
+ ## Examples
+
+ iex> causal_depth(workflow, root_fact)
+ 0
+
+ iex> causal_depth(workflow, produced_fact)
+ 3 # produced after 3 causal steps
+ """
+ @spec causal_depth(t(), Fact.t() | FactRef.t()) :: non_neg_integer()
+ def causal_depth(%__MODULE__{} = workflow, fact)
+ when is_struct(fact, Fact) or is_struct(fact, FactRef) do
+ ancestry_depth(workflow, fact)
+ end
+
+ @doc """
+ Finds the root ancestor fact hash for a given fact.
+
+ Walks the ancestry chain until it finds a fact with `ancestry: nil` (root input).
+ Returns the hash of that root fact, or the fact's own hash if it is a root.
+
+ ## Examples
+
+ iex> root_ancestor_hash(workflow, root_fact)
+ 123456 # root_fact.hash
+
+ iex> root_ancestor_hash(workflow, deeply_nested_fact)
+ 123456 # hash of the original root input
+ """
+ @spec root_ancestor_hash(t(), Fact.t()) :: integer() | nil
+ def root_ancestor_hash(%__MODULE__{}, %Fact{ancestry: nil, hash: hash}), do: hash
+
+ def root_ancestor_hash(%__MODULE__{graph: graph} = workflow, %Fact{
+ ancestry: {_producer_hash, parent_fact_hash}
+ }) do
+ case Map.get(graph.vertices, parent_fact_hash) do
+ %Fact{} = parent_fact ->
+ root_ancestor_hash(workflow, parent_fact)
+
+ nil ->
+ nil
+ end
+ end
+
+ @doc """
+ Gets the hooks for a given node hash.
+
+ Returns a tuple of {before_hooks, after_hooks} for use in CausalContext.
+ """
+ @spec get_hooks(t(), integer()) :: {list(), list()}
+ def get_hooks(workflow, node_hash), do: Private.get_hooks(workflow, node_hash)
+
+ # =============================================================================
+ # Three-Phase Execution API (Phase 4 & 5)
+ # =============================================================================
+
+ @doc """
+ Returns a list of prepared `%Runnable{}` structs ready for execution.
+
+ This is the three-phase version of `next_runnables/1`. Each runnable contains
+ everything needed to execute independently of the workflow.
+
+ ## Three-Phase Execution Model
+
+ 1. **Prepare** - This function calls `Invokable.prepare/3` for each pending node
+ 2. **Execute** - Call `Invokable.execute/2` on each runnable (can be parallelized)
+ 3. **Apply** - Call `Workflow.apply_runnable(workflow, runnable)` to fold events back
+
+ ## Returns
+
+ A list of `%Runnable{}` structs with status `:pending`, ready for `execute/2`.
+ Nodes that return `{:skip, _}` or `{:defer, _}` from prepare are handled immediately
+ and not included in the returned list.
+
+ ## Example
+
+ runnables = Workflow.prepared_runnables(workflow)
+ executed = Enum.map(runnables, &Invokable.execute(&1.node, &1))
+ workflow = Enum.reduce(executed, workflow, &Workflow.apply_runnable(&2, &1))
+ """
+ @spec prepared_runnables(t()) :: [Runnable.t()]
+ def prepared_runnables(%__MODULE__{graph: graph} = workflow) do
+ for %Graph.Edge{} = edge <- Graph.edges(graph, by: [:runnable, :matchable]),
+ node = edge.v2,
+ fact = edge.v1,
+ runnable <- prepare_node(workflow, node, fact) do
+ runnable
+ end
+ end
+
+ defp prepare_node(workflow, node, fact) do
+ case Invokable.prepare(node, workflow, fact) do
+ {:ok, runnable} -> [runnable]
+ {:skip, _reducer_fn} -> []
+ {:defer, _reducer_fn} -> []
+ end
+ end
+
+ @doc """
+ Prepares all available runnables for external dispatch.
+
+ Returns `{workflow, [%Runnable{}]}` where the workflow may have been updated
+ by skip/defer reducers, and the runnables list contains nodes ready for execution.
+
+ This is designed for external schedulers that want to dispatch execution
+ to worker pools or distributed systems.
+
+ ## Example
+
+ {workflow, runnables} = Workflow.prepare_for_dispatch(workflow)
+
+ # Dispatch to worker pool (can be parallel)
+ executed = Task.async_stream(runnables, fn r ->
+ Invokable.execute(r.node, r)
+ end, timeout: :infinity)
+
+ # Apply results back
+ workflow = Enum.reduce(executed, workflow, fn {:ok, r}, w ->
+ Workflow.apply_runnable(w, r)
+ end)
+ """
+ @spec prepare_for_dispatch(t()) :: {t(), [Runnable.t()]}
+ def prepare_for_dispatch(%__MODULE__{graph: graph} = workflow) do
+ Graph.edges(graph, by: [:runnable, :matchable])
+ |> Enum.reduce({workflow, []}, fn %Graph.Edge{v2: node, v1: fact}, {wrk, runnables} ->
+ case Invokable.prepare(node, wrk, fact) do
+ {:ok, runnable} ->
+ {wrk, [runnable | runnables]}
+
+ {:skip, reducer_fn} ->
+ {reducer_fn.(wrk), runnables}
+
+ {:defer, reducer_fn} ->
+ {reducer_fn.(wrk), runnables}
+ end
+ end)
+ |> then(fn {wrk, runnables} -> {wrk, Enum.reverse(runnables)} end)
+ end
+
+ @doc """
+ Applies a completed runnable back to the workflow.
+
+ Called by schedulers after receiving execution results.
+
+ Events from the runnable are folded via `apply_event/2`, hook apply_fns
+ are run, and downstream nodes are activated.
+
+ ## Parameters
+
+ - `workflow` - The current workflow state
+ - `runnable` - A runnable with status `:completed`, `:failed`, `:skipped`, or `:pending`
+
+ ## Returns
+
+ Updated workflow with the runnable's effects applied.
+
+ ## Example
+
+ executed = Invokable.execute(runnable.node, runnable)
+ workflow = Workflow.apply_runnable(workflow, executed)
+ """
+ @spec apply_runnable(t(), Runnable.t()) :: t()
+
+ def apply_runnable(
+ %__MODULE__{} = workflow,
+ %Runnable{status: :completed, events: events} = runnable
+ )
+ when is_list(events) and events != [] do
+ # 1. Fold core events
+ wf = Enum.reduce(events, workflow, fn event, wf -> apply_event(wf, event) end)
+
+ # 2. Run hook apply_fns if present (collected during execute, not serializable)
+ wf = apply_hook_fns(wf, runnable.hook_apply_fns || [])
+
+ # 3. Coordination finalization (Join completion check, etc.)
+ # Returns {wf, derived_events} — derived events are already folded into wf
+ {wf, derived_events} = maybe_finalize_coordination(wf, runnable)
+
+ # 4. Emit downstream activations (skipped when coordination produced derived events,
+ # since coordination already handled downstream activation)
+ # Returns {wf, activation_events} so activation edges are captured in the event stream
+ {wf, activation_events} =
+ if derived_events == [] do
+ emit_downstream_activations(wf, runnable)
+ else
+ {wf, []}
+ end
+
+ # 5. Buffer all events as uncommitted (only when emit_events is enabled)
+ if wf.emit_events do
+ all_new =
+ Enum.reverse(activation_events) ++ Enum.reverse(derived_events) ++ Enum.reverse(events)
+
+ %{wf | uncommitted_events: all_new ++ wf.uncommitted_events}
+ else
+ wf
+ end
+ end
+
+ # Skipped runnable: fold events (marks activation as consumed),
+ # then skip all downstream nodes to prevent stalled workflows.
+ def apply_runnable(
+ %__MODULE__{} = workflow,
+ %Runnable{status: :skipped, events: events, node: node} = _runnable
+ )
+ when is_list(events) and events != [] do
+ wf = Enum.reduce(events, workflow, fn event, wf -> apply_event(wf, event) end)
+ wf = skip_downstream_subgraph(wf, node)
+
+ if wf.emit_events do
+ %{wf | uncommitted_events: Enum.reverse(events) ++ wf.uncommitted_events}
+ else
+ wf
+ end
+ end
+
+ def apply_runnable(%__MODULE__{} = workflow, %Runnable{status: :failed} = runnable) do
+ handle_failed_runnable(workflow, runnable)
+ end
+
+ def apply_runnable(%__MODULE__{} = workflow, %Runnable{status: :pending}) do
+ workflow
+ end
+
+ @doc """
+ Enables event emission on the workflow.
+
+ When `emit_events` is `true`, `apply_runnable/2` buffers events into
+ `uncommitted_events` for consumption by durable execution stores.
+ """
+ @spec enable_event_emission(t()) :: t()
+ def enable_event_emission(%__MODULE__{} = wf), do: %{wf | emit_events: true}
+
+ @doc """
+ Disables event emission on the workflow.
+
+ When `emit_events` is `false` (the default), `apply_runnable/2` skips
+ the `uncommitted_events` buffer entirely, avoiding allocation overhead
+ for in-memory scripting use cases.
+ """
+ @spec disable_event_emission(t()) :: t()
+ def disable_event_emission(%__MODULE__{} = wf), do: %{wf | emit_events: false}
+
+ # Emit downstream activations: dispatches to Activator protocol if implemented,
+ # otherwise uses the default activation logic (find :flow successors, draw activation edges).
+ # Returns {wf, activation_events} so all graph mutations are captured in the event stream.
+ defp emit_downstream_activations(%__MODULE__{} = wf, %Runnable{node: node} = runnable) do
+ if Runic.Workflow.Activator.impl_for(node) do
+ Runic.Workflow.Activator.activate_downstream(node, wf, runnable)
+ else
+ default_emit_downstream(wf, runnable)
+ end
+ end
+
+ # Default: single Fact result — activate :flow successors via RunnableActivated events
+ defp default_emit_downstream(%__MODULE__{} = wf, %Runnable{result: result, node: node})
+ when is_struct(result, Fact) do
+ next = next_steps(wf, node)
+
+ activation_events =
+ Enum.map(next, fn step ->
+ %RunnableActivated{
+ fact_hash: result.hash,
+ node_hash: step.hash,
+ activation_kind: Private.connection_for_activatable(step)
+ }
+ end)
+
+ wf = Enum.reduce(activation_events, wf, fn event, w -> apply_event(w, event) end)
+ {wf, activation_events}
+ end
+
+ defp default_emit_downstream(%__MODULE__{} = wf, _runnable), do: {wf, []}
+
+ # Coordination finalization: dispatches to Coordinator protocol if implemented,
+ # otherwise returns {wf, []} (no coordination needed).
+ defp maybe_finalize_coordination(%__MODULE__{} = wf, %Runnable{node: node} = runnable) do
+ if Runic.Workflow.Coordinator.impl_for(node) do
+ Runic.Workflow.Coordinator.finalize(node, wf, runnable)
+ else
+ {wf, []}
+ end
+ end
+
+ defp handle_failed_runnable(%__MODULE__{} = workflow, %Runnable{
+ node: node,
+ input_fact: fact,
+ error: error
+ }) do
+ Logger.warning("Runnable failed for node #{inspect(node)} with error: #{inspect(error)}")
+
+ workflow
+ |> mark_runnable_as_ran(node, fact)
+ |> skip_downstream_subgraph(node)
+ end
+
+ @doc """
+ Marks all nodes transitively downstream of `failed_node` as unreachable.
+
+ Walks the structural `:flow` edges from `failed_node` to find all transitive
+ dependents, then relabels any pending `:runnable` or `:joined` edges pointing
+ to those nodes as `:upstream_failed`. This prevents the workflow from getting
+ stuck waiting for work that can never complete due to a missing upstream fact.
+ """
+ @spec skip_downstream_subgraph(t(), struct()) :: t()
+ def skip_downstream_subgraph(%__MODULE__{graph: graph} = workflow, failed_node) do
+ downstream_nodes = reachable_via_flow(graph, failed_node) -- [failed_node]
+
+ graph =
+ Enum.reduce(downstream_nodes, graph, fn node, g ->
+ g
+ |> Graph.in_edges(node)
+ |> Enum.filter(&(&1.label in [:runnable, :joined]))
+ |> Enum.reduce(g, fn edge, g_acc ->
+ case Graph.update_labelled_edge(g_acc, edge.v1, edge.v2, edge.label,
+ label: :upstream_failed
+ ) do
+ %Graph{} = updated -> updated
+ {:error, :no_such_edge} -> g_acc
+ end
+ end)
+ end)
+
+ %{workflow | graph: graph}
+ end
+
+ defp reachable_via_flow(graph, start_node) do
+ do_reachable_via_flow(graph, [start_node], MapSet.new(), [])
+ end
+
+ defp do_reachable_via_flow(_graph, [], _visited, acc), do: acc
+
+ defp do_reachable_via_flow(graph, [node | rest], visited, acc) do
+ node_id = graph.vertex_identifier.(node)
+
+ if MapSet.member?(visited, node_id) do
+ do_reachable_via_flow(graph, rest, visited, acc)
+ else
+ visited = MapSet.put(visited, node_id)
+ children = for e <- Graph.out_edges(graph, node, by: :flow), do: e.v2
+ do_reachable_via_flow(graph, children ++ rest, visited, [node | acc])
+ end
+ end
+
+ # =============================================================================
+ # Serialization API
+ # =============================================================================
+
+ @doc """
+ Serializes the workflow to Mermaid flowchart format.
+
+ Returns a string that can be rendered by Mermaid.js.
+
+ ## Options
+
+ - `:direction` - Flow direction: `:TB` (default), `:LR`, `:BT`, `:RL`
+ - `:include_memory` - Include causal reaction edges (default: `false`)
+ - `:title` - Optional title comment
+
+ ## Examples
+
+ iex> workflow |> Workflow.to_mermaid()
+ "flowchart TB\\n ..."
+
+ iex> workflow |> Workflow.to_mermaid(direction: :LR, include_memory: true)
+ "flowchart LR\\n ..."
+ """
+ @spec to_mermaid(t(), Keyword.t()) :: String.t()
+ def to_mermaid(%__MODULE__{} = workflow, opts \\ []) do
+ Runic.Workflow.Serializers.Mermaid.serialize(workflow, opts)
+ end
+
+ @doc """
+ Serializes causal reactions as a Mermaid sequence diagram.
+
+ Shows how facts flow through steps and produce new facts over time.
+ Best used after workflow execution to visualize the causal chain.
+
+ ## Example
+
+ iex> workflow |> Workflow.plan_eagerly(input) |> Workflow.react() |> Workflow.to_mermaid_sequence()
+ "sequenceDiagram\\n ..."
+ """
+ @spec to_mermaid_sequence(t(), Keyword.t()) :: String.t()
+ def to_mermaid_sequence(%__MODULE__{} = workflow, opts \\ []) do
+ Runic.Workflow.Serializers.Mermaid.serialize_causal(workflow, opts)
+ end
+
+ @doc """
+ Serializes the workflow to DOT (Graphviz) format.
+
+ Returns a string that can be rendered with Graphviz tools.
+
+ ## Example
+
+ iex> dot = Workflow.to_dot(workflow)
+ iex> File.write!("workflow.dot", dot)
+ """
+ @spec to_dot(t(), Keyword.t()) :: String.t()
+ def to_dot(%__MODULE__{} = workflow, opts \\ []) do
+ Runic.Workflow.Serializers.DOT.serialize(workflow, opts)
+ end
+
+ @doc """
+ Serializes the workflow to Cytoscape.js element JSON format.
+
+ Returns a list of node and edge elements compatible with Cytoscape.js
+ and Kino.Cytoscape in Livebook.
+
+ ## Example
+
+ iex> elements = Workflow.to_cytoscape(workflow)
+ iex> Kino.Cytoscape.new(elements)
+ """
+ @spec to_cytoscape(t(), Keyword.t()) :: list(map())
+ def to_cytoscape(%__MODULE__{} = workflow, opts \\ []) do
+ Runic.Workflow.Serializers.Cytoscape.serialize(workflow, opts)
+ end
+
+ @doc """
+ Serializes the workflow to an edgelist format.
+
+ Returns a list of `{from, to, label}` tuples by default.
+
+ ## Options
+
+ - `:format` - `:tuples` (default) or `:string`
+ - `:include_memory` - Include causal edges (default: `false`)
+
+ ## Examples
+
+ iex> Workflow.to_edgelist(workflow)
+ [{:root, :step1, :flow}, {:step1, :step2, :flow}]
+
+ iex> Workflow.to_edgelist(workflow, format: :string)
+ "root -> step1 [flow]\\nstep1 -> step2 [flow]"
+ """
+ @spec to_edgelist(t(), Keyword.t()) :: list(tuple()) | String.t()
+ def to_edgelist(%__MODULE__{} = workflow, opts \\ []) do
+ Runic.Workflow.Serializers.Edgelist.serialize(workflow, opts)
+ end
+
+ # =============================================================================
+ # Meta Expression Support
+ # =============================================================================
+
+ @doc """
+ Prepares meta context for a node by traversing its `:meta_ref` edges.
+
+ Each `:meta_ref` edge has a `getter_fn` in its properties that extracts
+ the needed value from the workflow. This function executes all getter functions
+ and builds a map of context_key => value pairs.
+
+ ## Example
+
+ # For a Condition with state_of(:cart_accumulator) in its where clause
+ meta_context = prepare_meta_context(workflow, condition)
+ # => %{cart_accumulator_state: %{total: 150, items: [...]}}
+ """
+ @spec prepare_meta_context(t(), struct()) :: map()
+ def prepare_meta_context(workflow, node), do: Private.prepare_meta_context(workflow, node)
+
+ @doc """
+ Returns the list of components that a node depends on via `:meta_ref` edges.
+
+ This is useful for understanding what state a rule or step will read during
+ execution, and for validation/visualization.
+
+ ## Example
+
+ deps = meta_dependencies(workflow, my_rule_condition)
+ # => [%Accumulator{name: :cart_state, ...}]
+ """
+ @spec meta_dependencies(t(), struct()) :: list(struct())
+ def meta_dependencies(%__MODULE__{graph: graph} = _workflow, node) do
+ node_vertex =
+ case node do
+ %{hash: hash} -> hash
+ _ -> node
+ end
+
+ graph
+ |> Graph.out_edges(node_vertex, by: :meta_ref)
+ |> Enum.map(fn edge ->
+ case edge.v2 do
+ %{hash: hash} ->
+ Map.get(graph.vertices, hash, edge.v2)
+
+ hash when is_integer(hash) ->
+ Map.get(graph.vertices, hash)
+
+ _ ->
+ edge.v2
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ @doc """
+ Returns the list of nodes that depend on a component via `:meta_ref` edges.
+
+ This is the inverse of `meta_dependencies/2` - it shows what nodes will
+ read this component's state.
+
+ ## Example
+
+ dependents = meta_dependents(workflow, cart_accumulator)
+ # => [%Condition{...}, %Step{...}]
+ """
+ @spec meta_dependents(t(), struct()) :: list(struct())
+ def meta_dependents(%__MODULE__{graph: graph} = _workflow, node) do
+ node_vertex =
+ case node do
+ %{hash: hash} -> hash
+ _ -> node
+ end
+
+ graph
+ |> Graph.in_edges(node_vertex, by: :meta_ref)
+ |> Enum.map(fn edge ->
+ case edge.v1 do
+ %{hash: hash} ->
+ Map.get(graph.vertices, hash, edge.v1)
+
+ hash when is_integer(hash) ->
+ Map.get(graph.vertices, hash)
+
+ _ ->
+ edge.v1
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ @doc """
+ Builds a getter function for a meta reference based on its kind.
+
+ The getter function has signature `(workflow, target) -> value` and is
+ stored in the `:meta_ref` edge properties for use during the prepare phase.
+
+ ## Supported Kinds
+
+ - `:state_of` - Returns the last known state of an Accumulator/StateMachine
+ - `:step_ran?` - Returns boolean indicating if step has run
+ - `:fact_count` - Returns count of facts produced by a component
+ - `:latest_value` - Returns the most recent value produced
+ - `:latest_fact` - Returns the most recent fact produced
+ - `:all_values` - Returns all values produced as a list
+ - `:all_facts` - Returns all facts produced as a list
+ """
+ @spec build_getter_fn(map()) :: (t(), term() -> term())
+ def build_getter_fn(meta_ref), do: Private.build_getter_fn(meta_ref)
+
+ @doc """
+ Creates a `:meta_ref` edge from a node to its meta expression target.
+
+ This is called during `Component.connect/3` when a node has meta references.
+ The edge stores the getter function and context key for use during prepare.
+
+ ## Example
+
+ workflow = draw_meta_ref_edge(
+ workflow,
+ condition.hash,
+ accumulator.hash,
+ %{kind: :state_of, field_path: [:total], context_key: :cart_total}
+ )
+ """
+ @spec draw_meta_ref_edge(t(), term(), term(), map()) :: t()
+ def draw_meta_ref_edge(workflow, from, to, meta_ref),
+ do: Private.draw_meta_ref_edge(workflow, from, to, meta_ref)
+
+ @doc false
+ def latest_state_fact(workflow, accumulator),
+ do: Private.latest_state_fact(workflow, accumulator)
+end
diff --git a/vendor/runic/lib/workflow/accumulator.ex b/vendor/runic/lib/workflow/accumulator.ex
new file mode 100644
index 0000000..3a9c0fb
--- /dev/null
+++ b/vendor/runic/lib/workflow/accumulator.ex
@@ -0,0 +1,65 @@
+defmodule Runic.Workflow.Accumulator do
+ @moduledoc """
+ Accumulator components aggregate values over time using a reducer function.
+
+ ## Options
+
+ - `:mergeable` - When `true`, indicates this accumulator's reducer has CRDT-like
+ properties (commutative, idempotent, associative) and is safe for parallel merge
+ without ordering guarantees. Defaults to `false`.
+
+ ## Example
+
+ # A counter is mergeable (addition is commutative and associative)
+ accumulator(
+ init: fn -> 0 end,
+ reducer: fn value, acc -> acc + value end,
+ mergeable: true
+ )
+
+ # A list accumulator is NOT mergeable (order matters)
+ accumulator(
+ init: fn -> [] end,
+ reducer: fn value, acc -> [value | acc] end,
+ mergeable: false
+ )
+
+ ## Runtime Context
+
+ Accumulators can reference external runtime values via `context/1` in their
+ reducer function. When detected, the reducer is rewritten to arity-3
+ `(value, acc, meta_ctx)` and values are resolved from the workflow's `run_context`.
+
+ Runic.accumulator(0, fn x, state -> state + x * context(:factor) end,
+ name: :scaled
+ )
+
+ Use `context/2` to provide defaults:
+
+ Runic.accumulator(0, fn x, state -> state + x * context(:factor, default: 1) end,
+ name: :scaled
+ )
+ """
+
+ @type t :: %__MODULE__{}
+
+ defstruct [
+ :reducer,
+ :init,
+ :hash,
+ :reduce_hash,
+ :name,
+ :closure,
+ :inputs,
+ :outputs,
+ mergeable: false,
+ meta_refs: []
+ ]
+
+ @doc """
+ Returns whether this accumulator has meta references (e.g., `context/1`)
+ that need to be resolved during the prepare phase.
+ """
+ @spec has_meta_refs?(t()) :: boolean()
+ def has_meta_refs?(%__MODULE__{meta_refs: meta_refs}), do: meta_refs != []
+end
diff --git a/vendor/runic/lib/workflow/activator.ex b/vendor/runic/lib/workflow/activator.ex
new file mode 100644
index 0000000..18627ab
--- /dev/null
+++ b/vendor/runic/lib/workflow/activator.ex
@@ -0,0 +1,49 @@
+defprotocol Runic.Workflow.Activator do
+ @moduledoc """
+ Optional protocol for nodes with custom downstream activation patterns.
+
+ After coordination finalization (if any), `apply_runnable/2` needs to
+ activate downstream nodes. Most node types use the default pattern:
+ find `:flow` successors and draw activation edges from the result fact.
+
+ Some node types have non-standard activation patterns:
+
+ - **FanOut** emits multiple facts, each activating all downstream nodes
+ - **Condition/Conjunction** activate via
+ `prepare_next_runnables` after a satisfied match
+
+ Nodes that implement this protocol override the default activation logic.
+ Nodes that do **not** implement it get the standard single-fact activation.
+
+ ## Return Value
+
+ Implementations must return `{workflow, activation_events}` where
+ `activation_events` is a list of `RunnableActivated` events that were
+ folded into the workflow. This allows `apply_runnable/2` to capture
+ activation edges in the event stream for replay correctness.
+
+ ## Example
+
+ defimpl Runic.Workflow.Activator, for: MyApp.CustomBroadcastNode do
+ def activate_downstream(node, workflow, runnable) do
+ # Custom multi-target activation logic
+ Runic.Workflow.Private.activate_downstream_with_events(workflow, node, fact)
+ end
+ end
+ """
+
+ @doc """
+ Activates downstream nodes after a runnable completes.
+
+ Receives the node, the current workflow, and the completed runnable.
+ Returns `{workflow, activation_events}` — the updated workflow with
+ activation edges drawn and the list of `RunnableActivated` events produced.
+ """
+ @spec activate_downstream(
+ node :: struct(),
+ workflow :: Runic.Workflow.t(),
+ runnable :: Runic.Workflow.Runnable.t()
+ ) ::
+ {Runic.Workflow.t(), [Runic.Workflow.Events.RunnableActivated.t()]}
+ def activate_downstream(node, workflow, runnable)
+end
diff --git a/vendor/runic/lib/workflow/aggregate.ex b/vendor/runic/lib/workflow/aggregate.ex
new file mode 100644
index 0000000..a1f6be6
--- /dev/null
+++ b/vendor/runic/lib/workflow/aggregate.ex
@@ -0,0 +1,120 @@
+defmodule Runic.Workflow.Aggregate do
+ @moduledoc """
+ A CQRS/Event Sourcing aggregate component that validates commands, emits domain events, and folds events into state.
+
+ An Aggregate separates write operations (commands) from state projection
+ (event handlers). Commands are validated against the current state via
+ optional guards, produce domain events on success, and those events are
+ folded back into the aggregate's state by event handlers. This mirrors
+ the Aggregate pattern from Domain-Driven Design.
+
+ Note that the "events" here are domain-level facts flowing through the
+ workflow graph — they are distinct from Runic's internal workflow events
+ used for replay and event sourcing at the engine layer.
+
+ ## How It Works
+
+ At compile time an Aggregate is lowered into standard Runic primitives:
+
+ - An **Accumulator** that holds the aggregate's state. Its reducer is built
+ from the declared `event` handlers — each event pattern is matched and
+ folded into the current state.
+ - One **Rule** per command handler. Each rule uses `state_of()` meta-references
+ to access the current state, applies the optional `where` guard as a
+ condition, and produces a domain event via the `emit` function as its
+ reaction. The emitted event then feeds back into the accumulator's reducer.
+
+ Each command rule is named `:"_"`.
+
+ ## DSL Syntax
+
+ Aggregates are created with the `Runic.aggregate/2` macro using a block DSL:
+
+ Runic.aggregate name: :name do
+ state initial_value
+
+ command :command_name do
+ where fn state -> boolean end # optional guard
+ emit fn state -> event_value end # event producer
+ end
+
+ event pattern, state do
+ new_state_expression
+ end
+ end
+
+ ### Directives
+
+ - `state value` — declares the initial aggregate state (required).
+ - `command :name do ... end` — declares a command handler with:
+ - `where fn state -> bool end` — optional guard that must return true
+ for the command to execute. Receives the current state.
+ - `emit fn state -> event end` — produces a domain event from the
+ current state (required).
+ - `event pattern, state do ... end` — declares an event handler that
+ pattern-matches on the event value and folds it into the current state.
+
+ ## Examples
+
+ require Runic
+
+ # Counter aggregate with guarded decrement
+ agg = Runic.aggregate name: :counter do
+ state 0
+
+ command :increment do
+ emit fn _state -> {:incremented, 1} end
+ end
+
+ command :decrement do
+ where fn state -> state > 0 end
+ emit fn _state -> {:decremented, 1} end
+ end
+
+ event {:incremented, n}, state do
+ state + n
+ end
+
+ event {:decremented, n}, state do
+ state - n
+ end
+ end
+
+ # Add to workflow and process commands
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(agg)
+ wrk = Workflow.react(wrk, :increment)
+ # State is now 1, event {:incremented, 1} was produced
+
+ ## Sub-Component Access
+
+ After adding an Aggregate to a workflow, its internal primitives can be
+ retrieved via `Workflow.get_component/2` using a `{name, kind}` tuple:
+
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(agg)
+
+ # Get the underlying accumulator (holds aggregate state)
+ [accumulator] = Workflow.get_component(wrk, {:counter, :accumulator})
+
+ # Get all command handler rules
+ handlers = Workflow.get_component(wrk, {:counter, :command_handler})
+ """
+
+ defstruct [
+ :name,
+ :initial_state,
+ :command_handlers,
+ :event_handlers,
+ :accumulator,
+ :command_rules,
+ :workflow,
+ :source,
+ :hash,
+ :bindings,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/causal_context.ex b/vendor/runic/lib/workflow/causal_context.ex
new file mode 100644
index 0000000..7f1a521
--- /dev/null
+++ b/vendor/runic/lib/workflow/causal_context.ex
@@ -0,0 +1,219 @@
+defmodule Runic.Workflow.CausalContext do
+ @moduledoc """
+ Minimal immutable context for executing a runnable without the full workflow.
+
+ Built during the prepare phase and consumed during execute phase.
+ Contains only what's needed for the specific node type being invoked.
+
+ ## Design Goals
+
+ 1. **Minimal footprint** - Only include data needed for execution
+ 2. **Immutable** - Safe to pass across process boundaries
+ 3. **Self-contained** - No workflow reference, all needed state captured
+ 4. **Content-addressed** - Uses causal ancestry rather than generation counters
+
+ ## Node-Specific Context Fields
+
+ Different node types populate different context fields:
+
+ - **Step**: `fan_out_context` for mapped pipeline tracking
+ - **Condition/Conjunction**: `satisfied_conditions` for gate logic
+ - **Accumulator**: `last_known_state` for stateful operations
+ - **Join**: `join_context` with satisfaction tracking
+ - **FanOut**: `fan_out_context` with reduce tracking
+ - **FanIn**: `fan_in_context` with readiness and sister values
+ - **All nodes**: `meta_context` for graph-resolved meta expression values, `run_context` for external runtime values from `context/1` expressions
+ """
+
+ alias Runic.Workflow.Fact
+
+ @type t :: %__MODULE__{
+ node_hash: integer() | nil,
+ input_fact: Fact.t() | nil,
+ ancestry_depth: non_neg_integer(),
+ hooks: {list(), list()},
+ last_known_state: term() | nil,
+ is_state_initialized: boolean(),
+ satisfied_conditions: MapSet.t() | nil,
+ join_context: map() | nil,
+ fan_out_context: map() | nil,
+ fan_in_context: map() | nil,
+ mergeable: boolean(),
+ meta_context: map(),
+ run_context: map()
+ }
+
+ defstruct [
+ :node_hash,
+ :input_fact,
+ ancestry_depth: 0,
+ hooks: {[], []},
+ last_known_state: nil,
+ is_state_initialized: false,
+ satisfied_conditions: nil,
+ join_context: nil,
+ fan_out_context: nil,
+ fan_in_context: nil,
+ mergeable: false,
+ meta_context: %{},
+ run_context: %{}
+ ]
+
+ @doc """
+ Creates a new CausalContext with the given attributes.
+ """
+ @spec new(keyword()) :: t()
+ def new(attrs \\ []) do
+ struct!(__MODULE__, attrs)
+ end
+
+ @doc """
+ Builds a basic context with node hash, input fact, and ancestry depth.
+ """
+ @spec basic(integer(), Fact.t(), non_neg_integer()) :: t()
+ def basic(node_hash, input_fact, ancestry_depth) do
+ %__MODULE__{
+ node_hash: node_hash,
+ input_fact: input_fact,
+ ancestry_depth: ancestry_depth
+ }
+ end
+
+ @doc """
+ Adds hooks to the context.
+ """
+ @spec with_hooks(t(), {list(), list()}) :: t()
+ def with_hooks(%__MODULE__{} = ctx, {before_hooks, after_hooks}) do
+ %{ctx | hooks: {before_hooks, after_hooks}}
+ end
+
+ @doc """
+ Adds state context for stateful nodes.
+ """
+ @spec with_state(t(), term(), boolean()) :: t()
+ def with_state(%__MODULE__{} = ctx, last_known_state, is_initialized \\ true) do
+ %{ctx | last_known_state: last_known_state, is_state_initialized: is_initialized}
+ end
+
+ @doc """
+ Adds fan_out context for mapped pipeline tracking.
+ """
+ @spec with_fan_out_context(t(), map()) :: t()
+ def with_fan_out_context(%__MODULE__{} = ctx, fan_out_context) do
+ %{ctx | fan_out_context: fan_out_context}
+ end
+
+ @doc """
+ Adds fan_in context for reduction coordination.
+ """
+ @spec with_fan_in_context(t(), map()) :: t()
+ def with_fan_in_context(%__MODULE__{} = ctx, fan_in_context) do
+ %{ctx | fan_in_context: fan_in_context}
+ end
+
+ @doc """
+ Adds join context for join coordination.
+ """
+ @spec with_join_context(t(), map()) :: t()
+ def with_join_context(%__MODULE__{} = ctx, join_context) do
+ %{ctx | join_context: join_context}
+ end
+
+ @doc """
+ Adds satisfied conditions for conjunction gates.
+ """
+ @spec with_satisfied_conditions(t(), MapSet.t()) :: t()
+ def with_satisfied_conditions(%__MODULE__{} = ctx, satisfied_conditions) do
+ %{ctx | satisfied_conditions: satisfied_conditions}
+ end
+
+ @doc """
+ Returns the before hooks from the context.
+ """
+ @spec before_hooks(t()) :: list()
+ def before_hooks(%__MODULE__{hooks: {before, _after}}), do: before
+
+ @doc """
+ Returns the after hooks from the context.
+ """
+ @spec after_hooks(t()) :: list()
+ def after_hooks(%__MODULE__{hooks: {_before, after_hooks}}), do: after_hooks
+
+ @doc """
+ Sets the mergeable flag on the context.
+
+ Components with `mergeable: true` have CRDT-like properties
+ (commutative, idempotent, associative) and are safe for parallel
+ merge without ordering guarantees.
+ """
+ @spec with_mergeable(t(), boolean()) :: t()
+ def with_mergeable(%__MODULE__{} = ctx, mergeable) when is_boolean(mergeable) do
+ %{ctx | mergeable: mergeable}
+ end
+
+ @doc """
+ Returns whether this context's node is mergeable (parallel-safe).
+ """
+ @spec mergeable?(t()) :: boolean()
+ def mergeable?(%__MODULE__{mergeable: mergeable}), do: mergeable
+
+ @doc """
+ Adds meta context for nodes with meta expression dependencies.
+
+ Meta context contains values prepared from `:meta_ref` edges during the
+ prepare phase. These values are then available during execution without
+ requiring workflow access.
+
+ ## Example
+
+ context = CausalContext.new(...)
+ |> CausalContext.with_meta_context(%{cart_state: %{total: 150, items: []}})
+ """
+ @spec with_meta_context(t(), map()) :: t()
+ def with_meta_context(%__MODULE__{} = ctx, meta_context) when is_map(meta_context) do
+ %{ctx | meta_context: meta_context}
+ end
+
+ @doc """
+ Returns the meta context map from the context.
+ """
+ @spec meta_context(t()) :: map()
+ def meta_context(%__MODULE__{meta_context: meta_context}), do: meta_context
+
+ @doc """
+ Returns whether this context has any meta context populated.
+ """
+ @spec has_meta_context?(t()) :: boolean()
+ def has_meta_context?(%__MODULE__{meta_context: meta_context}),
+ do: meta_context != %{}
+
+ @doc """
+ Adds run context for external runtime value injection.
+
+ Run context contains runtime-scoped values (secrets, tenant IDs, database
+ connections) resolved for a specific component during the prepare phase.
+ Available during execution without requiring workflow access.
+
+ ## Example
+
+ context = CausalContext.new()
+ |> CausalContext.with_run_context(%{api_key: "sk-...", model: "gpt-4"})
+ """
+ @spec with_run_context(t(), map()) :: t()
+ def with_run_context(%__MODULE__{} = ctx, run_context) when is_map(run_context) do
+ %{ctx | run_context: run_context}
+ end
+
+ @doc """
+ Returns the run context map from the context.
+ """
+ @spec run_context(t()) :: map()
+ def run_context(%__MODULE__{run_context: run_context}), do: run_context
+
+ @doc """
+ Returns whether this context has any run context populated.
+ """
+ @spec has_run_context?(t()) :: boolean()
+ def has_run_context?(%__MODULE__{run_context: run_context}),
+ do: run_context != %{}
+end
diff --git a/vendor/runic/lib/workflow/compilation_utils.ex b/vendor/runic/lib/workflow/compilation_utils.ex
new file mode 100644
index 0000000..295d97f
--- /dev/null
+++ b/vendor/runic/lib/workflow/compilation_utils.ex
@@ -0,0 +1,249 @@
+defmodule Runic.Workflow.CompilationUtils do
+ @moduledoc """
+ Macro utilities for compiling expressions into workflow graphs or components.
+ """
+ require Runic
+ alias Runic.Workflow
+ alias Runic.Workflow.Components
+ alias Runic.Workflow.Join
+
+ @doc """
+ Used for testing and debugging purposes, this macro will compile a workflow graph
+ from a pipeline expression and return the graph as a quoted expression.
+
+ Expects a tree of lists which may contain a component or a tuple {component, list(components | tuples)}.
+
+ ## Examples
+
+ workflow_of_pipeline([
+ {Runic.step(fn _ -> 1..4 end),
+ [
+ Runic.map([
+ Runic.step(fn num -> num * 2 end),
+ Runic.step(fn num -> num + 1 end),
+ Runic.step(fn num -> num + 4 end)
+ ])
+ ]}
+ ])
+ """
+ defmacro workflow_of_pipeline(expression, name \\ nil) do
+ workflow = workflow_graph_of_pipeline_tree_expression(expression, name)
+
+ quote generated: true do
+ unquote(workflow)
+ end
+ end
+
+ @doc """
+ Accepts a tree of lists and two item tuples representing a pipeline expression
+ and builds a workflow graph DAG from the expression - expanding components which may
+ have further nested pipelines and registering any components as necessary.
+ """
+ def workflow_graph_of_pipeline_tree_expression(expression, name \\ nil)
+
+ def workflow_graph_of_pipeline_tree_expression(expression, name) do
+ workflow_graph_of_pipeline_tree_expression(
+ quote generated: true do
+ require Runic
+ alias Runic.Workflow
+
+ Runic.workflow(name: unquote(name))
+ end,
+ expression,
+ name
+ )
+ end
+
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ {:fn, _, _} = expression,
+ _name
+ ) do
+ step =
+ quote do
+ Runic.step(unquote(expression))
+ end
+
+ quote do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(step), log: false)
+ end
+ end
+
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ {:., _, [_, _, _component]} = expression,
+ _name
+ ) do
+ quote do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(expression), log: false)
+ end
+ end
+
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ {{:., _, [_, _component]}, _, _} = expression,
+ _name
+ ) do
+ quote do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(expression), log: false)
+ end
+ end
+
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ {_component, _, _} = expression,
+ _name
+ ) do
+ quote do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(expression), log: false)
+ end
+ end
+
+ # with n parents, add a join and add join to each parent
+ # then add children to the join
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ {[_ | _] = parents, children} = pipeline_expression,
+ name
+ ) do
+ parent_steps_with_hashes =
+ Enum.map(parents, fn step_ast ->
+ {Components.fact_hash(step_ast), pipeline_step(step_ast)}
+ end)
+
+ parent_hashes = Enum.map(parent_steps_with_hashes, &elem(&1, 0))
+
+ join_hash = Components.fact_hash(Enum.map(parent_steps_with_hashes, &elem(&1, 0)))
+
+ join =
+ quote do
+ %Join{
+ hash: unquote(join_hash),
+ joins: unquote(parent_hashes)
+ }
+ end
+
+ dependent_pipeline_workflow =
+ workflow_graph_of_pipeline_tree_expression(
+ children,
+ to_string(name) <> "_#{Components.fact_hash(pipeline_expression)}"
+ )
+
+ wrk_acc =
+ Enum.reduce(parent_steps_with_hashes, wrk_acc, fn {_, parent_step}, acc ->
+ quote do
+ unquote(acc)
+ |> Workflow.add(unquote(parent_step), to: unquote(join))
+ end
+ end)
+
+ quote do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(dependent_pipeline_workflow), to: unquote(join_hash))
+ end
+ end
+
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ {parent_component, children} = pipeline_expression,
+ name
+ ) do
+ dependent_workflow =
+ workflow_graph_of_pipeline_tree_expression(
+ children,
+ to_string(name) <> "_#{Components.fact_hash(pipeline_expression)}"
+ )
+
+ quote do
+ unquote(wrk_acc)
+ |> Workflow.add(unquote(parent_component))
+ |> Workflow.add(unquote(dependent_workflow), to: unquote(parent_component))
+ end
+ end
+
+ def workflow_graph_of_pipeline_tree_expression(
+ wrk_acc,
+ [_ | _] = expression,
+ name
+ ) do
+ # Traverse the expression tree and build the workflow graph
+ # Handle nested components and composite components recursively
+ Enum.reduce(expression, wrk_acc, fn item, acc ->
+ dependent_workflow =
+ workflow_graph_of_pipeline_tree_expression(
+ item,
+ to_string(name) <> "_#{Components.fact_hash(item)}"
+ )
+
+ quote do
+ unquote(acc)
+ |> Workflow.add(unquote(dependent_workflow))
+ end
+ end)
+ end
+
+ # defp split_map_children(children) when is_list(children) do
+ # Enum.split_with(children, fn
+ # {:map, _, _} -> true
+ # {:., _, [_, _, :map]} -> true
+ # {{:., _, [_, :map]}, _, _} -> true
+ # _otherwise -> false
+ # end)
+ # end
+
+ def pipeline_step({:&, _, _} = expression) do
+ step_ast_hash = Components.fact_hash(expression)
+
+ quote do
+ Runic.step(work: unquote(expression), hash: unquote(step_ast_hash))
+ end
+ end
+
+ def pipeline_step({:fn, _, _} = expression) do
+ step_ast_hash = Components.fact_hash(expression)
+
+ quote do
+ Runic.step(work: unquote(expression), hash: unquote(step_ast_hash))
+ end
+ end
+
+ def pipeline_step({{:., _, [_, :step]}, _, [expression | [rest]]}) do
+ step_ast_hash = Components.fact_hash(expression)
+
+ name = rest[:name]
+
+ quote do
+ Runic.step(work: unquote(expression), hash: unquote(step_ast_hash), name: unquote(name))
+ end
+ end
+
+ def pipeline_step({{:., _, [_, :step]}, _, [expression]}) do
+ step_ast_hash = Components.fact_hash(expression)
+
+ quote do
+ Runic.step(work: unquote(expression), hash: unquote(step_ast_hash))
+ end
+ end
+
+ def pipeline_step({:step, _, [expression | [rest]]}) do
+ step_ast_hash = Components.fact_hash(expression)
+
+ name = rest[:name]
+
+ quote do
+ Runic.step(work: unquote(expression), hash: unquote(step_ast_hash), name: unquote(name))
+ end
+ end
+
+ def pipeline_step({:step, _, [expression]}) do
+ step_ast_hash = Components.fact_hash(expression)
+
+ quote do
+ Runic.step(work: unquote(expression), hash: unquote(step_ast_hash))
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/component.ex b/vendor/runic/lib/workflow/component.ex
new file mode 100644
index 0000000..d95ace3
--- /dev/null
+++ b/vendor/runic/lib/workflow/component.ex
@@ -0,0 +1,1906 @@
+defprotocol Runic.Component do
+ @moduledoc """
+ Protocol defining how Runic components compose together and connect within workflows.
+
+ The `Component` protocol supports extension of modeling new component types that can be
+ added and connected with other components in Runic workflows. It provides introspection
+ capabilities for components (sub-components, inputs, outputs) and connection semantics
+ for workflow composition.
+
+ ## Protocol Functions
+
+ | Function | Purpose |
+ |----------|---------|
+ | `connectable?/2` | Check if a component can be connected to another |
+ | `connect/3` | Connect this component to a parent in a workflow |
+ | `source/1` | Returns the source AST for building/serializing the component |
+ | `hash/1` | Returns the content-addressable hash of the component |
+ | `inputs/1` | Returns the nimble_options schema for component inputs |
+ | `outputs/1` | Returns the nimble_options schema for component outputs |
+
+ ## Built-in Implementations
+
+ | Component Type | Description |
+ |----------------|-------------|
+ | `Runic.Workflow.Step` | Single transformation function |
+ | `Runic.Workflow.Rule` | Conditional logic with condition and reaction |
+ | `Runic.Workflow.Map` | Fan-out transformation over enumerables |
+ | `Runic.Workflow.Reduce` | Fan-in aggregation |
+ | `Runic.Workflow.Accumulator` | Stateful reducer across invocations |
+ | `Runic.Workflow.StateMachine` | Stateful reducer with reactive conditions |
+ | `Runic.Workflow` | Workflows themselves are components |
+ | `Tuple` | Pipeline syntax `{parent, [children]}` |
+
+ ## Type Compatibility
+
+ The `Component` protocol includes type compatibility checking via an internal
+ `TypeCompatibility` helper module. This enables schema-based
+ validation when connecting components:
+
+ # Type compatibility checks
+ TypeCompatibility.types_compatible?(:any, :integer) # => true
+ TypeCompatibility.types_compatible?(:string, :integer) # => false
+
+ # Port compatibility for connecting components
+ producer_outputs = [out: [type: {:list, :integer}]]
+ consumer_inputs = [in: [type: {:list, :any}]]
+ TypeCompatibility.ports_compatible?(producer_outputs, consumer_inputs) # => {:ok, :inferred}
+
+ ## Usage
+
+ require Runic
+
+ step = Runic.step(fn x -> x * 2 end, name: :double)
+ rule = Runic.rule(fn x when x > 10 -> :large end, name: :classify)
+
+ # Introspection
+ Runic.Component.hash(step) # => content-addressable hash
+ Runic.Component.source(step) # => AST representation
+
+ # Compatibility checking
+ Runic.Component.connectable?(step, rule) # => true
+
+ # Connection (typically done via Workflow.add/3)
+ workflow = Runic.Workflow.new()
+ |> Runic.Workflow.add(step)
+ |> Runic.Workflow.add(rule, to: :double)
+
+ ## Implementing Custom Component
+
+ defmodule MyApp.CustomComponent do
+ defstruct [:hash, :name, :config]
+ end
+
+ defimpl Runic.Component, for: MyApp.CustomComponent do
+ alias Runic.Workflow
+
+ def connectable?(_component, _other), do: true
+
+ def connect(component, to, workflow) do
+ workflow
+ |> Workflow.add_step(to, some_internal_step(component))
+ |> Workflow.register_component(component)
+ end
+
+ def source(component) do
+ quote do
+ MyApp.CustomComponent.new(name: unquote(component.name))
+ end
+ end
+
+ def hash(component), do: component.hash
+
+ def inputs(_component), do: [in: [type: :any, doc: "Input value"]]
+
+ def outputs(_component), do: [out: [type: :any, doc: "Output value"]]
+ end
+
+ See the [Protocols Guide](protocols.html) for more details and examples.
+ """
+
+ # @doc """
+ # Get a sub component of a component by name.
+ # """
+ # def get_component(component, sub_component_name)
+
+ # @doc """
+ # Get the sub component from the workflow by name.
+ # """
+ # def get_component(component, workflow, sub_component_name)
+
+ # @doc """
+ # List all connectable sub-components of a component.
+ # """
+ # def components(component)
+
+ # @doc """
+ # List compatible sub-components with the other component.
+ # """
+ # def connectables(component, other_component)
+
+ @doc """
+ Check if a component can be connected to another component.
+ """
+ def connectable?(component, other_component)
+
+ def connect(component, to, workflow)
+
+ @doc """
+ Returns the source AST for building a component.
+ """
+ def source(component)
+
+ def hash(component)
+
+ @doc """
+ Returns port contract for component inputs.
+ Each entry is a named port with options like :type, :doc, :cardinality, :required.
+ """
+ def inputs(component)
+
+ @doc """
+ Returns port contract for component outputs.
+ Each entry is a named port with options like :type, :doc, :cardinality.
+ """
+ def outputs(component)
+
+ # def remove(component, workflow)
+end
+
+# Helper for computing dataflow paths (flow edges only)
+defmodule Runic.Component.FlowPath do
+ @moduledoc false
+
+ @doc """
+ Returns the shortest path between two nodes following only :flow edges.
+ This is used to track all steps in the map-reduce dataflow path.
+ """
+ def flow_path(graph, from, to) do
+ graph
+ |> Graph.edges(by: :flow)
+ |> Enum.reduce(Graph.new(type: :directed), fn edge, g ->
+ Graph.add_edge(g, edge.v1, edge.v2)
+ end)
+ |> Graph.get_shortest_path(from, to) || []
+ end
+end
+
+# Type compatibility helper functions for Component protocol
+defmodule Runic.Component.TypeCompatibility do
+ @moduledoc false
+
+ def types_compatible?(producer_type, consumer_type) do
+ case {producer_type, consumer_type} do
+ # Any type is compatible with anything
+ {:any, _} ->
+ true
+
+ {_, :any} ->
+ true
+
+ # Exact type match
+ {same, same} ->
+ true
+
+ # Lists are compatible if their element types are compatible
+ {{:list, producer_elem}, {:list, consumer_elem}} ->
+ types_compatible?(producer_elem, consumer_elem)
+
+ # One_of types - check if there's overlap or compatibility
+ {{:one_of, producer_opts}, {:one_of, consumer_opts}} ->
+ # If any option in producer matches any option in consumer
+ Enum.any?(producer_opts, fn p_opt ->
+ Enum.any?(consumer_opts, fn c_opt -> types_compatible?(p_opt, c_opt) end)
+ end)
+
+ # One_of producer can match a specific consumer type
+ {{:one_of, producer_opts}, consumer_type} ->
+ Enum.any?(producer_opts, fn opt -> types_compatible?(opt, consumer_type) end)
+
+ # Specific producer type can match one_of consumer
+ {producer_type, {:one_of, consumer_opts}} ->
+ Enum.any?(consumer_opts, fn opt -> types_compatible?(producer_type, opt) end)
+
+ # Default to false for unhandled cases
+ _ ->
+ false
+ end
+ end
+
+ def ports_compatible?(producer_outputs, consumer_inputs) do
+ case {length(producer_outputs), length(consumer_inputs)} do
+ # Single port on each side — infer connection regardless of names
+ {1, 1} ->
+ [{_p_name, p_schema}] = producer_outputs
+ [{_c_name, c_schema}] = consumer_inputs
+ p_type = Keyword.get(p_schema, :type, :any)
+ c_type = Keyword.get(c_schema, :type, :any)
+
+ if types_compatible?(p_type, c_type) do
+ {:ok, :inferred}
+ else
+ {:error, [{:type_mismatch, p_type, c_type}]}
+ end
+
+ # Single consumer port — infer from any compatible producer output
+ {_, 1} ->
+ [{_c_name, c_schema}] = consumer_inputs
+ c_type = Keyword.get(c_schema, :type, :any)
+
+ if Enum.any?(producer_outputs, fn {_p_name, p_schema} ->
+ types_compatible?(Keyword.get(p_schema, :type, :any), c_type)
+ end) do
+ {:ok, :inferred}
+ else
+ producer_types =
+ Enum.map(producer_outputs, fn {name, schema} ->
+ {name, Keyword.get(schema, :type, :any)}
+ end)
+
+ {:error, [{:type_mismatch, producer_types, c_type}]}
+ end
+
+ # Multi-port — match by name
+ _ ->
+ errors =
+ consumer_inputs
+ |> Enum.filter(fn {_name, schema} ->
+ Keyword.get(schema, :required, true)
+ end)
+ |> Enum.reject(fn {c_name, c_schema} ->
+ case Keyword.fetch(producer_outputs, c_name) do
+ {:ok, p_schema} ->
+ types_compatible?(
+ Keyword.get(p_schema, :type, :any),
+ Keyword.get(c_schema, :type, :any)
+ )
+
+ :error ->
+ false
+ end
+ end)
+ |> Enum.map(fn {c_name, c_schema} ->
+ {:unmatched_port, c_name, Keyword.get(c_schema, :type, :any)}
+ end)
+
+ if Enum.empty?(errors), do: {:ok, :matched}, else: {:error, errors}
+ end
+ end
+
+ def schemas_compatible?(producer_outputs, consumer_inputs) do
+ case ports_compatible?(producer_outputs, consumer_inputs) do
+ {:ok, _} -> true
+ {:error, _} -> false
+ end
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Map do
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+ alias Runic.Workflow.Step
+ # alias Runic.Workflow.Join
+
+ def connect(
+ %Runic.Workflow.Map{
+ pipeline: pipeline_workflow
+ } = map,
+ to,
+ workflow
+ ) do
+ fan_out_step =
+ pipeline_workflow.graph
+ |> Graph.out_neighbors(Workflow.root())
+ |> List.first()
+
+ wrk =
+ workflow
+ |> Workflow.add_step(to, fan_out_step)
+ |> Workflow.register_component(map)
+ |> Workflow.draw_connection(map, fan_out_step, :component_of, properties: %{kind: :fan_out})
+
+ wrk =
+ pipeline_workflow.graph
+ |> Graph.edges()
+ |> Enum.reduce(wrk, fn
+ %{v1: %Root{}, v2: _fan_out}, wrk ->
+ wrk
+
+ %{v1: v1, v2: %Step{} = step, label: :flow}, wrk ->
+ wrk =
+ wrk
+ |> Workflow.add_step(v1, step)
+ |> Workflow.register_component(step)
+ |> Workflow.draw_connection(step, step, :component_of, properties: %{kind: :step})
+
+ if is_leaf?(wrk.graph, step) do
+ Workflow.draw_connection(wrk, map, step, :component_of, properties: %{kind: :leaf})
+ else
+ wrk
+ end
+
+ # %{v1: %Runic.Workflow.FanOut{} = fan_out, v2: %Runic.Workflow.Map{} = nested_map} = edge, wrk ->
+
+ # Workflow.add(wrk, nested_map, to: fan_out.hash)
+
+ %{v1: _v1, v2: %Runic.Workflow.Map{} = nested_map} = edge, wrk ->
+ wrk = Map.put(wrk, :graph, Graph.add_edge(wrk.graph, edge))
+
+ # Get the nested map's fan_out step
+ nested_fan_out_step =
+ nested_map.pipeline.graph
+ |> Graph.out_neighbors(Workflow.root())
+ |> List.first()
+
+ wrk
+ |> Workflow.register_component(nested_map)
+ |> Workflow.draw_connection(nested_map, nested_fan_out_step, :component_of,
+ properties: %{kind: :fan_out}
+ )
+
+ %{v1: _, v2: _} = edge, wrk ->
+ # for non-components such as joins, just add the edge
+ %Workflow{wrk | graph: Graph.add_edge(wrk.graph, edge)}
+ end)
+
+ %Workflow{
+ wrk
+ | mapped: %{
+ workflow.mapped
+ | mapped_paths:
+ MapSet.union(workflow.mapped.mapped_paths, map.pipeline.mapped.mapped_paths),
+ mapped_path_fan_outs:
+ Map.merge(
+ Map.get(workflow.mapped, :mapped_path_fan_outs, %{}),
+ Map.get(map.pipeline.mapped, :mapped_path_fan_outs, %{}),
+ fn _node_hash, fan_outs1, fan_outs2 ->
+ MapSet.union(fan_outs1, fan_outs2)
+ end
+ )
+ },
+ components: Map.merge(wrk.components, map.pipeline.components),
+ build_log: wrk.build_log ++ map.pipeline.build_log
+ }
+ end
+
+ defp is_leaf?(g, v), do: g |> Graph.out_edges(v, by: :flow) |> Enum.count() == 0
+
+ def get_component(%Runic.Workflow.Map{components: components}, sub_component_name) do
+ Map.get(components, sub_component_name)
+ end
+
+ def get_component(
+ %Runic.Workflow.Map{} = map,
+ %Workflow{} = workflow,
+ kind
+ ) do
+ case Graph.out_edges(workflow.graph, Map.get(workflow.graph.vertices, map.hash),
+ by: :component_of,
+ where: fn edge ->
+ edge.properties[:kind] == kind
+ end
+ )
+ |> List.first() do
+ %{v2: component} -> component
+ nil -> nil
+ end
+ end
+
+ def connectables(%Runic.Workflow.Map{} = map, other_component) do
+ # Filter components based on compatibility
+ map
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def components(%Runic.Workflow.Map{components: components}) do
+ Keyword.new(components)
+ end
+
+ def connectable?(component, other_component) do
+ # Use schema-based compatibility checking
+ producer_outputs = outputs(component)
+ consumer_inputs = Runic.Component.inputs(other_component)
+
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(%Runic.Workflow.Map{closure: %Runic.Closure{} = closure}) do
+ closure.source
+ end
+
+ def source(%Runic.Workflow.Map{closure: nil}) do
+ nil
+ end
+
+ def hash(map) do
+ map.hash
+ end
+
+ def inputs(%Runic.Workflow.Map{inputs: nil}) do
+ [
+ items: [
+ type: :any,
+ cardinality: :many,
+ doc: "Collection to be processed by the map pipeline"
+ ]
+ ]
+ end
+
+ def inputs(%Runic.Workflow.Map{inputs: user_inputs}) do
+ user_inputs
+ end
+
+ def outputs(%Runic.Workflow.Map{outputs: nil}) do
+ [
+ out: [
+ type: :any,
+ cardinality: :many,
+ doc: "Processed items from the map operation"
+ ]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Map{outputs: user_outputs}) do
+ user_outputs
+ end
+
+ # def remove(%Runic.Workflow.Map{pipeline: map_wrk} = map, workflow) do
+ # # for vertices in the map workflow, remove them but only if they're not also involved in separate components
+
+ # map_wrk.graph
+ # |> Graph.vertices()
+ # |> Enum.reduce(workflow, fn vertex, wrk ->
+ # case Graph.out_edges(wrk.graph, vertex) do
+
+ # end
+ # end)
+ # end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Reduce do
+ alias Runic.Workflow
+ alias Runic.Component.FlowPath
+
+ def connect(reduce, %Runic.Workflow.Map{} = map, workflow) do
+ map_leaf = Workflow.get_component!(workflow, {map.name, :leaf}) |> List.first()
+
+ if is_nil(map_leaf) do
+ raise ArgumentError,
+ "Cannot connect reduce to map #{map.name} because it has no leaf component. Ensure the map has a leaf component defined."
+ end
+
+ map_fan_out = Workflow.get_component!(workflow, {map.name, :fan_out}) |> List.first()
+
+ wrk =
+ workflow
+ |> Workflow.draw_connection(map_leaf, reduce.fan_in, :flow)
+ |> Workflow.register_component(reduce)
+ |> Workflow.draw_connection(reduce, reduce.fan_in, :component_of,
+ properties: %{kind: :fan_in}
+ )
+ |> Workflow.draw_connection(map_fan_out, reduce.fan_in, :fan_in)
+
+ # Use flow-only path to ensure all steps in the dataflow are tracked
+ path_to_fan_out = FlowPath.flow_path(wrk.graph, map_fan_out, reduce.fan_in)
+
+ mapped_paths =
+ Enum.reduce(path_to_fan_out, wrk.mapped.mapped_paths, fn node, mapset ->
+ MapSet.put(mapset, node.hash)
+ end)
+
+ mapped_path_fan_outs =
+ Enum.reduce(path_to_fan_out, Map.get(wrk.mapped, :mapped_path_fan_outs, %{}), fn node, acc ->
+ Map.update(acc, node.hash, MapSet.new([map_fan_out.hash]), fn fan_outs ->
+ MapSet.put(fan_outs, map_fan_out.hash)
+ end)
+ end)
+
+ %Workflow{
+ wrk
+ | mapped:
+ wrk.mapped
+ |> Map.put(:mapped_paths, mapped_paths)
+ |> Map.put(:mapped_path_fan_outs, mapped_path_fan_outs)
+ }
+ end
+
+ def connect(%{fan_in: %{map: mapped}} = reduce, %Workflow.Step{} = step, workflow)
+ when not is_nil(mapped) do
+ map_fanout = Workflow.get_component!(workflow, {mapped, :fan_out}) |> List.first()
+
+ wrk =
+ workflow
+ |> Workflow.add_step(step, reduce.fan_in)
+ |> Workflow.register_component(reduce)
+ |> Workflow.draw_connection(map_fanout, reduce.fan_in, :fan_in)
+ |> Workflow.draw_connection(reduce, reduce.fan_in, :component_of,
+ properties: %{kind: :fan_in}
+ )
+
+ # Use flow-only path to ensure all steps in the dataflow are tracked
+ path_to_fan_out = FlowPath.flow_path(wrk.graph, map_fanout, reduce.fan_in)
+
+ mapped_paths =
+ Enum.reduce(path_to_fan_out, wrk.mapped.mapped_paths, fn node, mapset ->
+ MapSet.put(mapset, node.hash)
+ end)
+
+ mapped_path_fan_outs =
+ Enum.reduce(path_to_fan_out, Map.get(wrk.mapped, :mapped_path_fan_outs, %{}), fn node, acc ->
+ Map.update(acc, node.hash, MapSet.new([map_fanout.hash]), fn fan_outs ->
+ MapSet.put(fan_outs, map_fanout.hash)
+ end)
+ end)
+
+ %Workflow{
+ wrk
+ | mapped:
+ wrk.mapped
+ |> Map.put(:mapped_paths, mapped_paths)
+ |> Map.put(:mapped_path_fan_outs, mapped_path_fan_outs)
+ }
+ end
+
+ def connect(reduce, to, workflow) when is_list(to) do
+ # Include the fan_in hash in the join's joins list so the Join knows to wait for it
+ join_hashes = Enum.map(to, & &1.hash) ++ [reduce.fan_in.hash]
+
+ join = Workflow.Join.new(join_hashes)
+
+ workflow
+ |> Workflow.add_step(to, join)
+ |> Workflow.add_step(reduce.fan_in, join)
+ |> Workflow.register_component(reduce)
+ |> Workflow.draw_connection(reduce, reduce.fan_in, :component_of,
+ properties: %{kind: :fan_in}
+ )
+ end
+
+ def connect(reduce, to, workflow) do
+ workflow
+ |> Workflow.add_step(to, reduce.fan_in)
+ |> Workflow.register_component(reduce)
+ |> Workflow.draw_connection(reduce, reduce.fan_in, :component_of,
+ properties: %{kind: :fan_in}
+ )
+ end
+
+ def get_component(%Runic.Workflow.Reduce{fan_in: fan_in}, _kind) do
+ fan_in
+ end
+
+ def get_component(%Runic.Workflow.Reduce{fan_in: fan_in}, _workflow, _kind) do
+ fan_in
+ end
+
+ def components(reduce) do
+ [fan_in: reduce.fan_in]
+ end
+
+ def connectables(reduce, other_component) do
+ # Filter components based on compatibility
+ reduce
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def connectable?(reduce, other_component) do
+ producer_outputs = outputs(reduce)
+ consumer_inputs = Runic.Component.inputs(other_component)
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(%Runic.Workflow.Reduce{closure: %Runic.Closure{} = closure}) do
+ closure.source
+ end
+
+ def source(%Runic.Workflow.Reduce{closure: nil}) do
+ nil
+ end
+
+ def hash(reduce) do
+ reduce.hash
+ end
+
+ def inputs(%Runic.Workflow.Reduce{inputs: nil}) do
+ [
+ items: [
+ type: :any,
+ cardinality: :many,
+ doc: "Collection of values to be reduced"
+ ]
+ ]
+ end
+
+ def inputs(%Runic.Workflow.Reduce{inputs: user_inputs}) do
+ user_inputs
+ end
+
+ def outputs(%Runic.Workflow.Reduce{outputs: nil}) do
+ [
+ result: [
+ type: :any,
+ doc: "Reduced value produced by the reduce operation"
+ ]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Reduce{outputs: user_outputs}) do
+ user_outputs
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Step do
+ alias Runic.Workflow
+ alias Runic.Workflow.Reduce
+
+ def components(step) do
+ [step: step]
+ end
+
+ def connect(step, to, workflow) when is_list(to) do
+ join =
+ to
+ |> Enum.map(fn
+ %Reduce{fan_in: fan_in} -> fan_in.hash
+ other -> other.hash
+ end)
+ |> Workflow.Join.new()
+
+ wrk =
+ Enum.reduce(to, workflow, fn
+ %Reduce{fan_in: fan_in}, wrk -> Workflow.add_step(wrk, fan_in, join)
+ other, wrk -> Workflow.add_step(wrk, other, join)
+ end)
+
+ wrk
+ |> Workflow.add_step(join, step)
+ |> Workflow.draw_connection(step, step, :component_of, properties: %{kind: :step})
+ |> Workflow.register_component(step)
+ end
+
+ def connect(step, %Runic.Workflow.Rule{} = rule, workflow) do
+ reaction = Map.get(workflow.graph.vertices, rule.reaction_hash)
+
+ workflow
+ |> Workflow.add_step(reaction, step)
+ |> Workflow.draw_connection(step, step, :component_of, properties: %{kind: :step})
+ |> Workflow.register_component(step)
+ end
+
+ def connect(step, to, workflow) do
+ workflow
+ |> Workflow.add_step(to, step)
+ |> Workflow.draw_connection(step, step, :component_of, properties: %{kind: :step})
+ |> Workflow.register_component(step)
+ end
+
+ def get_component(step, _kind) do
+ step
+ end
+
+ def connectables(step, other_component) do
+ # Filter components based on compatibility
+ step
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def connectable?(step, other_component) do
+ # Use schema-based compatibility checking
+ producer_outputs = outputs(step)
+ consumer_inputs = Runic.Component.inputs(other_component)
+
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(%Runic.Workflow.Step{closure: %Runic.Closure{} = closure}) do
+ closure.source
+ end
+
+ def source(%Runic.Workflow.Step{closure: nil}) do
+ nil
+ end
+
+ def hash(step) do
+ step.hash
+ end
+
+ def inputs(%Runic.Workflow.Step{inputs: nil}) do
+ [
+ in: [
+ type: :any,
+ doc: "Input value to be processed by the step function"
+ ]
+ ]
+ end
+
+ def inputs(%Runic.Workflow.Step{inputs: user_inputs}) do
+ user_inputs
+ end
+
+ def outputs(%Runic.Workflow.Step{outputs: nil}) do
+ [
+ out: [
+ type: :any,
+ doc: "Output value produced by the step function"
+ ]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Step{outputs: user_outputs}) do
+ user_outputs
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Rule do
+ alias Runic.Workflow
+ alias Runic.Workflow.Step
+ alias Runic.Workflow.Reduce
+ alias Runic.Workflow.Conjunction
+
+ def connect(rule, to, workflow) when to in [nil, %Runic.Workflow.Root{}] do
+ wrk = Workflow.merge(workflow, rule.workflow)
+
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ wrk
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> create_meta_ref_edges_for_node(rule.name, condition)
+ |> create_meta_ref_edges_for_node(rule.name, reaction)
+ |> resolve_condition_refs(rule)
+ end
+
+ def connect(rule, to, workflow) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ workflow
+ |> Workflow.add_step(to, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> create_meta_ref_edges_for_node(rule.name, condition)
+ |> create_meta_ref_edges_for_node(rule.name, reaction)
+ |> resolve_condition_refs(rule)
+ end
+
+ defp create_meta_ref_edges_for_node(workflow, component_name, %{
+ meta_refs: meta_refs,
+ hash: node_hash
+ })
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ # context/1 refs are resolved from run_context at runtime, not from workflow components
+ if meta_ref.kind == :context do
+ wrk
+ else
+ create_meta_ref_edge(wrk, component_name, node_hash, meta_ref)
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges_for_node(workflow, _component_name, _node), do: workflow
+
+ defp create_meta_ref_edge(wrk, component_name, node_hash, meta_ref) do
+ target = meta_ref.target
+
+ target_component =
+ case target do
+ name when is_atom(name) ->
+ Workflow.get_component(wrk, name)
+
+ hash when is_integer(hash) ->
+ Map.get(wrk.graph.vertices, hash)
+
+ {_parent, _subcomponent} = tuple_ref ->
+ case Workflow.get_component(wrk, tuple_ref) do
+ [component | _] -> component
+ [] -> nil
+ component -> component
+ end
+
+ _ ->
+ nil
+ end
+
+ if target_component do
+ Workflow.draw_meta_ref_edge(wrk, node_hash, target_component.hash, meta_ref)
+ else
+ raise Runic.UnresolvedReferenceError,
+ component_name: component_name,
+ reference_kind: meta_ref.kind,
+ target: target
+ end
+ end
+
+ defp resolve_condition_refs(workflow, %{condition_refs: []}), do: workflow
+
+ defp resolve_condition_refs(workflow, %{condition_refs: refs, name: rule_name})
+ when is_list(refs) do
+ Enum.reduce(refs, workflow, fn {ref_name, target_hash}, wrk ->
+ referenced_condition = Workflow.get_component(wrk, ref_name)
+
+ if is_nil(referenced_condition) do
+ raise Runic.UnresolvedReferenceError,
+ component_name: rule_name,
+ reference_kind: :condition,
+ target: ref_name,
+ hint:
+ "Add condition(name: #{inspect(ref_name)}) to the workflow before adding this rule."
+ end
+
+ target_node = Map.get(wrk.graph.vertices, target_hash)
+
+ case target_node do
+ %Conjunction{} = conj ->
+ updated_conj = %Conjunction{
+ conj
+ | condition_hashes: MapSet.put(conj.condition_hashes, referenced_condition.hash),
+ condition_refs: List.delete(conj.condition_refs, ref_name)
+ }
+
+ %Workflow{
+ wrk
+ | graph:
+ wrk.graph
+ |> Graph.replace_vertex(conj, updated_conj)
+ |> Graph.add_edge(referenced_condition, updated_conj, label: :flow)
+ }
+
+ %Step{} ->
+ %Workflow{
+ wrk
+ | graph: Graph.add_edge(wrk.graph, referenced_condition, target_node, label: :flow)
+ }
+
+ _ ->
+ wrk
+ end
+ end)
+ end
+
+ def get_component(rule, :reaction) do
+ Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+ end
+
+ def get_component(rule, :condition) do
+ Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ end
+
+ def components(rule) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ [condition: condition, reaction: reaction]
+ end
+
+ def connectables(rule, other_component) do
+ # Filter components based on compatibility
+ rule
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def connectable?(rule, other_component) do
+ producer_outputs = outputs(rule)
+ consumer_inputs = Runic.Component.inputs(other_component)
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(%Runic.Workflow.Rule{closure: %Runic.Closure{} = closure}) do
+ closure.source
+ end
+
+ def source(%Runic.Workflow.Rule{closure: nil}) do
+ nil
+ end
+
+ def hash(rule) do
+ rule.hash
+ end
+
+ def inputs(%Runic.Workflow.Rule{inputs: nil}) do
+ [
+ in: [
+ type: :any,
+ doc: "Input value to be evaluated by the rule condition"
+ ]
+ ]
+ end
+
+ def inputs(%Runic.Workflow.Rule{inputs: user_inputs}) do
+ user_inputs
+ end
+
+ def outputs(%Runic.Workflow.Rule{outputs: nil}) do
+ [
+ out: [
+ type: :any,
+ doc: "Output value produced by the rule reaction when condition matches"
+ ]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Rule{outputs: user_outputs}) do
+ user_outputs
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Aggregate do
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+
+ def connect(aggregate, %Root{}, workflow) do
+ connect_aggregate(aggregate, workflow, nil)
+ end
+
+ def connect(aggregate, to, workflow) do
+ connect_aggregate(aggregate, workflow, to)
+ end
+
+ defp connect_aggregate(aggregate, workflow, parent) do
+ accumulator = aggregate.accumulator
+ command_rules = aggregate.command_rules || []
+
+ # Accumulator connected from parent/root to initialize state
+ wrk =
+ if parent do
+ Workflow.add_step(workflow, parent, accumulator)
+ else
+ Workflow.add_step(workflow, accumulator)
+ end
+
+ wrk =
+ wrk
+ |> Workflow.register_component(aggregate)
+ |> Workflow.register_component(accumulator)
+ |> Workflow.draw_connection(aggregate, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+
+ Enum.reduce(command_rules, wrk, fn rule, wrk ->
+ connect_command_rule(wrk, aggregate, rule, accumulator, parent)
+ end)
+ end
+
+ defp connect_command_rule(workflow, aggregate, rule, accumulator, parent) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ # Topology: [Parent →] Condition → Reaction → Accumulator
+ # Condition also connected from parent/root for command input
+ wrk =
+ if parent do
+ Workflow.add_step(workflow, parent, condition)
+ else
+ Workflow.add_step(workflow, condition)
+ end
+
+ wrk
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.add_step(reaction, accumulator)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(aggregate, rule, :component_of,
+ properties: %{kind: :command_handler}
+ )
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(
+ workflow,
+ %{meta_refs: meta_refs, hash: node_hash},
+ accumulator
+ )
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ if meta_ref.kind == :context do
+ wrk
+ else
+ Workflow.draw_meta_ref_edge(wrk, node_hash, accumulator.hash, meta_ref)
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(workflow, _node, _accumulator), do: workflow
+
+ def connectable?(aggregate, other_component) do
+ producer_outputs = outputs(aggregate)
+ consumer_inputs = Runic.Component.inputs(other_component)
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(aggregate), do: aggregate.source
+ def hash(aggregate), do: aggregate.hash
+
+ def inputs(%Runic.Workflow.Aggregate{inputs: nil}) do
+ [command: [type: :any, doc: "Commands to be validated and processed by the aggregate"]]
+ end
+
+ def inputs(%Runic.Workflow.Aggregate{inputs: user_inputs}), do: user_inputs
+
+ def outputs(%Runic.Workflow.Aggregate{outputs: nil}) do
+ [
+ state: [type: :any, doc: "Current aggregate state"],
+ events: [type: :any, doc: "Domain events produced by command handlers"]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Aggregate{outputs: user_outputs}), do: user_outputs
+end
+
+defimpl Runic.Component, for: Runic.Workflow.StateMachine do
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+
+ def connect(state_machine, %Root{}, workflow) do
+ accumulator = state_machine.accumulator
+ reactor_rules = state_machine.reactor_rules || []
+
+ wrk =
+ workflow
+ |> Workflow.add_step(accumulator)
+ |> Workflow.register_component(state_machine)
+ |> Workflow.register_component(accumulator)
+ |> Workflow.draw_connection(state_machine, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+
+ Enum.reduce(reactor_rules, wrk, fn rule, wrk ->
+ connect_reactor_rule(wrk, state_machine, rule, accumulator)
+ end)
+ end
+
+ def connect(state_machine, to, workflow) do
+ accumulator = state_machine.accumulator
+ reactor_rules = state_machine.reactor_rules || []
+
+ wrk =
+ workflow
+ |> Workflow.add_step(to, accumulator)
+ |> Workflow.register_component(state_machine)
+ |> Workflow.register_component(accumulator)
+ |> Workflow.draw_connection(state_machine, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+
+ Enum.reduce(reactor_rules, wrk, fn rule, wrk ->
+ connect_reactor_rule(wrk, state_machine, rule, accumulator)
+ end)
+ end
+
+ defp connect_reactor_rule(workflow, state_machine, rule, accumulator) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ workflow
+ |> Workflow.add_step(accumulator, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(state_machine, rule, :component_of, properties: %{kind: :reactor})
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(
+ workflow,
+ %{meta_refs: meta_refs, hash: node_hash},
+ accumulator
+ )
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ if meta_ref.kind == :context do
+ wrk
+ else
+ Workflow.draw_meta_ref_edge(wrk, node_hash, accumulator.hash, meta_ref)
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(workflow, _node, _accumulator), do: workflow
+
+ def get_component(state_machine, :reducer), do: state_machine.accumulator
+ def get_component(state_machine, :accumulator), do: state_machine.accumulator
+
+ def components(state_machine) do
+ reactor_rules = state_machine.reactor_rules || []
+
+ [
+ accumulator: state_machine.accumulator,
+ reactor_rules: reactor_rules
+ ]
+ end
+
+ def connectables(state_machine, other_component) do
+ state_machine
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def connectable?(state_machine, other_component) do
+ producer_outputs = outputs(state_machine)
+ consumer_inputs = Runic.Component.inputs(other_component)
+
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(state_machine) do
+ state_machine.source
+ end
+
+ def hash(state_machine) do
+ state_machine.hash
+ end
+
+ def inputs(%Runic.Workflow.StateMachine{inputs: nil}) do
+ [
+ in: [
+ type: :any,
+ doc: "Input events or data to trigger state transitions"
+ ]
+ ]
+ end
+
+ def inputs(%Runic.Workflow.StateMachine{inputs: user_inputs}) do
+ user_inputs
+ end
+
+ def outputs(%Runic.Workflow.StateMachine{outputs: nil}) do
+ [
+ state: [
+ type: :any,
+ doc: "Current state value maintained by the state machine"
+ ]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.StateMachine{outputs: user_outputs}) do
+ user_outputs
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Saga do
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+
+ def connect(saga, %Root{}, workflow) do
+ connect_saga(saga, workflow, fn wrk, acc -> Workflow.add_step(wrk, acc) end)
+ end
+
+ def connect(saga, to, workflow) do
+ connect_saga(saga, workflow, fn wrk, acc -> Workflow.add_step(wrk, to, acc) end)
+ end
+
+ defp connect_saga(saga, workflow, add_accumulator_fn) do
+ accumulator = saga.accumulator
+ forward_rules = saga.forward_rules || []
+ compensation_rules = saga.compensation_rules || []
+
+ wrk =
+ workflow
+ |> add_accumulator_fn.(accumulator)
+ |> Workflow.register_component(saga)
+ |> Workflow.register_component(accumulator)
+ |> Workflow.draw_connection(saga, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+
+ wrk =
+ Enum.reduce(forward_rules, wrk, fn rule, wrk ->
+ connect_saga_rule(wrk, saga, rule, accumulator, :transaction)
+ end)
+
+ Enum.reduce(compensation_rules, wrk, fn rule, wrk ->
+ connect_saga_rule(wrk, saga, rule, accumulator, :compensation)
+ end)
+ end
+
+ defp connect_saga_rule(workflow, saga, rule, accumulator, kind) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ workflow
+ |> Workflow.add_step(accumulator, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(saga, rule, :component_of, properties: %{kind: kind})
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(
+ workflow,
+ %{meta_refs: meta_refs, hash: node_hash},
+ accumulator
+ )
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ if meta_ref.kind == :context do
+ wrk
+ else
+ Workflow.draw_meta_ref_edge(wrk, node_hash, accumulator.hash, meta_ref)
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(workflow, _node, _accumulator), do: workflow
+
+ def connectable?(saga, other_component) do
+ producer_outputs = outputs(saga)
+ consumer_inputs = Runic.Component.inputs(other_component)
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(saga), do: saga.source
+
+ def hash(saga), do: saga.hash
+
+ def inputs(%Runic.Workflow.Saga{inputs: nil}) do
+ [in: [type: :any, doc: "Input that triggers the saga execution"]]
+ end
+
+ def inputs(%Runic.Workflow.Saga{inputs: user_inputs}), do: user_inputs
+
+ def outputs(%Runic.Workflow.Saga{outputs: nil}) do
+ [
+ state: [type: :any, doc: "Current saga state (status, results, etc.)"],
+ result: [type: :any, doc: "Final saga result (completed or aborted)"]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Saga{outputs: user_outputs}), do: user_outputs
+end
+
+defimpl Runic.Component, for: Runic.Workflow.FSM do
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+
+ def connect(fsm, %Root{}, workflow) do
+ connect_fsm(fsm, workflow, fn wrk, acc -> Workflow.add_step(wrk, acc) end)
+ end
+
+ def connect(fsm, to, workflow) do
+ connect_fsm(fsm, workflow, fn wrk, acc -> Workflow.add_step(wrk, to, acc) end)
+ end
+
+ defp connect_fsm(fsm, workflow, add_accumulator_fn) do
+ accumulator = fsm.accumulator
+ transition_rules = fsm.transition_rules || []
+ entry_rules = fsm.entry_rules || []
+
+ wrk =
+ workflow
+ |> add_accumulator_fn.(accumulator)
+ |> Workflow.register_component(fsm)
+ |> Workflow.register_component(accumulator)
+ |> Workflow.draw_connection(fsm, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+
+ # Transition rule conditions receive events from root (not from accumulator),
+ # but use meta_ref edges to read the accumulator's state via state_of().
+ # Their reactions produce {event, target} tuples that feed into the accumulator.
+ wrk =
+ Enum.reduce(transition_rules, wrk, fn rule, wrk ->
+ connect_transition_rule(wrk, fsm, rule, accumulator)
+ end)
+
+ # Entry rules observe the accumulator's state (downstream of accumulator)
+ Enum.reduce(entry_rules, wrk, fn rule, wrk ->
+ connect_entry_rule(wrk, fsm, rule, accumulator)
+ end)
+ end
+
+ defp connect_transition_rule(workflow, fsm, rule, accumulator) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ # Conditions receive events from root (same level as accumulator),
+ # and reactions feed into the accumulator
+ workflow
+ |> Workflow.add_step(condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.add_step(reaction, accumulator)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(fsm, rule, :component_of, properties: %{kind: :transition})
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp connect_entry_rule(workflow, fsm, rule, accumulator) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ # Entry rules observe accumulator output (fire when state matches)
+ workflow
+ |> Workflow.add_step(accumulator, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(fsm, rule, :component_of, properties: %{kind: :entry_action})
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(
+ workflow,
+ %{meta_refs: meta_refs, hash: node_hash},
+ accumulator
+ )
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ if meta_ref.kind == :context do
+ wrk
+ else
+ Workflow.draw_meta_ref_edge(wrk, node_hash, accumulator.hash, meta_ref)
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(workflow, _node, _accumulator), do: workflow
+
+ def get_component(fsm, :accumulator), do: fsm.accumulator
+
+ def components(fsm) do
+ transition_rules = fsm.transition_rules || []
+ entry_rules = fsm.entry_rules || []
+
+ [
+ accumulator: fsm.accumulator,
+ transition_rules: transition_rules,
+ entry_rules: entry_rules
+ ]
+ end
+
+ def connectables(fsm, other_component) do
+ fsm
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def connectable?(fsm, other_component) do
+ producer_outputs = outputs(fsm)
+ consumer_inputs = Runic.Component.inputs(other_component)
+
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(fsm), do: fsm.source
+
+ def hash(fsm), do: fsm.hash
+
+ def inputs(%Runic.Workflow.FSM{inputs: nil}) do
+ [event: [type: :any, doc: "Events that trigger FSM state transitions"]]
+ end
+
+ def inputs(%Runic.Workflow.FSM{inputs: user_inputs}), do: user_inputs
+
+ def outputs(%Runic.Workflow.FSM{outputs: nil}) do
+ [
+ state: [type: :any, doc: "Current state atom of the FSM"],
+ transition: [type: :any, doc: "Transition output facts"]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.FSM{outputs: user_outputs}), do: user_outputs
+end
+
+defimpl Runic.Component, for: Runic.Workflow.ProcessManager do
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+
+ def connect(pm, %Root{}, workflow) do
+ connect_pm(pm, workflow, nil)
+ end
+
+ def connect(pm, to, workflow) do
+ connect_pm(pm, workflow, to)
+ end
+
+ defp connect_pm(pm, workflow, parent) do
+ accumulator = pm.accumulator
+ event_rules = pm.event_rules || []
+ completion_rule = pm.completion_rule
+
+ wrk =
+ if parent do
+ Workflow.add_step(workflow, parent, accumulator)
+ else
+ Workflow.add_step(workflow, accumulator)
+ end
+
+ wrk =
+ wrk
+ |> Workflow.register_component(pm)
+ |> Workflow.register_component(accumulator)
+ |> Workflow.draw_connection(pm, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+
+ # Wire event handler rules: conditions receive events from root/parent,
+ # reactions produce command facts
+ wrk =
+ Enum.reduce(event_rules, wrk, fn rule, wrk ->
+ connect_event_rule(wrk, pm, rule, accumulator, parent)
+ end)
+
+ # Wire completion rule if present
+ if completion_rule do
+ connect_completion_rule(wrk, pm, completion_rule, accumulator)
+ else
+ wrk
+ end
+ end
+
+ defp connect_event_rule(workflow, pm, rule, accumulator, parent) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ # Conditions receive events from root/parent (same level as accumulator)
+ wrk =
+ if parent do
+ Workflow.add_step(workflow, parent, condition)
+ else
+ Workflow.add_step(workflow, condition)
+ end
+
+ wrk
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(pm, rule, :component_of, properties: %{kind: :event_handler})
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp connect_completion_rule(workflow, pm, rule, accumulator) do
+ condition = Map.get(rule.workflow.graph.vertices, rule.condition_hash)
+ reaction = Map.get(rule.workflow.graph.vertices, rule.reaction_hash)
+
+ # Completion rule observes accumulator output
+ workflow
+ |> Workflow.add_step(accumulator, condition)
+ |> Workflow.add_step(condition, reaction)
+ |> Workflow.register_component(rule)
+ |> Workflow.draw_connection(rule, reaction, :component_of, properties: %{kind: :reaction})
+ |> Workflow.draw_connection(rule, condition, :component_of, properties: %{kind: :condition})
+ |> Workflow.draw_connection(pm, rule, :component_of, properties: %{kind: :completion})
+ |> create_meta_ref_edges_for_accumulator(condition, accumulator)
+ |> create_meta_ref_edges_for_accumulator(reaction, accumulator)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(
+ workflow,
+ %{meta_refs: meta_refs, hash: node_hash},
+ accumulator
+ )
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ if meta_ref.kind == :context do
+ wrk
+ else
+ Workflow.draw_meta_ref_edge(wrk, node_hash, accumulator.hash, meta_ref)
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges_for_accumulator(workflow, _node, _accumulator), do: workflow
+
+ def connectable?(pm, other_component) do
+ producer_outputs = outputs(pm)
+ consumer_inputs = Runic.Component.inputs(other_component)
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(pm), do: pm.source
+ def hash(pm), do: pm.hash
+
+ def inputs(%Runic.Workflow.ProcessManager{inputs: nil}) do
+ [event: [type: :any, doc: "Domain events that drive the process manager"]]
+ end
+
+ def inputs(%Runic.Workflow.ProcessManager{inputs: user_inputs}), do: user_inputs
+
+ def outputs(%Runic.Workflow.ProcessManager{outputs: nil}) do
+ [
+ state: [type: :any, doc: "Current coordination state"],
+ commands: [type: :any, doc: "Commands emitted by event handlers"]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.ProcessManager{outputs: user_outputs}), do: user_outputs
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Accumulator do
+ alias Runic.Workflow
+
+ def components(accumulator) do
+ [accumulator: accumulator]
+ end
+
+ def connect(accumulator, to, workflow) when is_list(to) do
+ join =
+ to
+ |> Enum.map(& &1.hash)
+ |> Workflow.Join.new()
+
+ workflow
+ |> Workflow.add_step(to, join)
+ |> Workflow.add_step(join, accumulator)
+ |> Workflow.draw_connection(accumulator, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+ end
+
+ def connect(accumulator, to, workflow) do
+ workflow
+ |> Workflow.add_step(to, accumulator)
+ |> Workflow.draw_connection(accumulator, accumulator, :component_of,
+ properties: %{kind: :accumulator}
+ )
+ |> Workflow.register_component(accumulator)
+ |> create_meta_ref_edges(accumulator)
+ end
+
+ defp create_meta_ref_edges(workflow, %{meta_refs: meta_refs, hash: node_hash, name: name})
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ if meta_ref.kind == :context do
+ wrk
+ else
+ target = meta_ref.target
+
+ target_component =
+ case target do
+ name when is_atom(name) -> Workflow.get_component(wrk, name)
+ hash when is_integer(hash) -> Map.get(wrk.graph.vertices, hash)
+ _ -> nil
+ end
+
+ if target_component do
+ Workflow.draw_meta_ref_edge(wrk, node_hash, target_component.hash, meta_ref)
+ else
+ raise Runic.UnresolvedReferenceError,
+ component_name: name,
+ reference_kind: meta_ref.kind,
+ target: target
+ end
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges(workflow, _accumulator), do: workflow
+
+ def get_component(accumulator, _kind) do
+ accumulator
+ end
+
+ def connectables(accumulator, other_component) do
+ # Filter components based on compatibility
+ accumulator
+ |> components()
+ |> Enum.filter(fn {_name, component} ->
+ connectable?(component, other_component)
+ end)
+ end
+
+ def connectable?(accumulator, other_component) do
+ # Use schema-based compatibility checking
+ producer_outputs = outputs(accumulator)
+ consumer_inputs = Runic.Component.inputs(other_component)
+
+ Runic.Component.TypeCompatibility.schemas_compatible?(producer_outputs, consumer_inputs)
+ end
+
+ def source(%Runic.Workflow.Accumulator{closure: %Runic.Closure{} = closure}) do
+ closure.source
+ end
+
+ def source(%Runic.Workflow.Accumulator{closure: nil}) do
+ nil
+ end
+
+ def hash(accumulator) do
+ accumulator.hash
+ end
+
+ def inputs(%Runic.Workflow.Accumulator{inputs: nil}) do
+ [
+ in: [
+ type: :any,
+ doc: "Input value to be accumulated with the current state"
+ ]
+ ]
+ end
+
+ def inputs(%Runic.Workflow.Accumulator{inputs: user_inputs}) do
+ user_inputs
+ end
+
+ def outputs(%Runic.Workflow.Accumulator{outputs: nil}) do
+ [
+ state: [
+ type: :any,
+ doc: "Accumulated value after applying the reducer function"
+ ]
+ ]
+ end
+
+ def outputs(%Runic.Workflow.Accumulator{outputs: user_outputs}) do
+ user_outputs
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow.Condition do
+ alias Runic.Workflow
+
+ def connect(condition, to, workflow) when is_list(to) do
+ join =
+ to
+ |> Enum.map(& &1.hash)
+ |> Workflow.Join.new()
+
+ wrk =
+ Enum.reduce(to, workflow, fn parent, wrk ->
+ Workflow.add_step(wrk, parent, join)
+ end)
+
+ wrk
+ |> Workflow.add_step(join, condition)
+ |> Workflow.draw_connection(condition, condition, :component_of,
+ properties: %{kind: :condition}
+ )
+ |> Workflow.register_component(condition)
+ |> create_meta_ref_edges(condition)
+ end
+
+ def connect(condition, to, workflow) do
+ workflow
+ |> Workflow.add_step(to, condition)
+ |> Workflow.draw_connection(condition, condition, :component_of,
+ properties: %{kind: :condition}
+ )
+ |> Workflow.register_component(condition)
+ |> create_meta_ref_edges(condition)
+ end
+
+ defp create_meta_ref_edges(workflow, %{meta_refs: meta_refs, hash: node_hash, name: name})
+ when is_list(meta_refs) and meta_refs != [] do
+ Enum.reduce(meta_refs, workflow, fn meta_ref, wrk ->
+ target = meta_ref.target
+
+ target_component =
+ case target do
+ name when is_atom(name) ->
+ Workflow.get_component(wrk, name)
+
+ hash when is_integer(hash) ->
+ Map.get(wrk.graph.vertices, hash)
+
+ {_parent, _subcomponent} = tuple_ref ->
+ case Workflow.get_component(wrk, tuple_ref) do
+ [component | _] -> component
+ [] -> nil
+ component -> component
+ end
+
+ _ ->
+ nil
+ end
+
+ if target_component do
+ Workflow.draw_meta_ref_edge(wrk, node_hash, target_component.hash, meta_ref)
+ else
+ raise Runic.UnresolvedReferenceError,
+ component_name: name,
+ reference_kind: meta_ref.kind,
+ target: target
+ end
+ end)
+ end
+
+ defp create_meta_ref_edges(workflow, _condition), do: workflow
+
+ def connectable?(_condition, _other_component), do: true
+
+ def source(%Runic.Workflow.Condition{closure: %Runic.Closure{} = closure}) do
+ closure.source
+ end
+
+ def source(%Runic.Workflow.Condition{closure: nil}) do
+ nil
+ end
+
+ def hash(condition), do: condition.hash
+
+ def inputs(_condition) do
+ [
+ in: [
+ type: :any,
+ doc: "Input value to evaluate"
+ ]
+ ]
+ end
+
+ def outputs(_condition) do
+ [
+ out: [
+ type: :any,
+ doc: "Passthrough value when satisfied"
+ ]
+ ]
+ end
+end
+
+defimpl Runic.Component, for: Runic.Workflow do
+ alias Runic.Workflow
+ alias Runic.Workflow.Fact
+
+ def connect(%Workflow{} = child_workflow, parent_component, workflow) do
+ new_graph =
+ child_workflow.graph
+ |> Graph.edges()
+ |> Enum.reduce(workflow.graph, fn
+ %{v1: %Runic.Workflow.Root{}, v2: _} = edge, g ->
+ new_edge = %Graph.Edge{edge | v1: parent_component}
+ Graph.add_edge(g, new_edge)
+
+ # handle reaction memory edges to override with latest history
+ %{v1: %Fact{} = fact_v1, v2: v2, label: label} = edge, g
+ when label in [:matchable, :runnable, :ran] ->
+ out_edge_labels_of_into_mem_for_edge =
+ g
+ |> Graph.out_edges(fact_v1)
+ |> Enum.filter(&(&1.v2 == v2))
+ |> MapSet.new(& &1.label)
+
+ cond do
+ label in [:matchable, :runnable] and
+ MapSet.member?(out_edge_labels_of_into_mem_for_edge, :ran) ->
+ g
+
+ label == :ran and
+ MapSet.member?(out_edge_labels_of_into_mem_for_edge, :runnable) ->
+ Graph.update_labelled_edge(g, fact_v1, v2, :runnable, label: :ran)
+
+ true ->
+ Graph.add_edge(g, edge)
+ end
+
+ edge, g ->
+ Graph.add_edge(g, edge)
+ end)
+
+ # Merge mapped data: mapped_paths as MapSet union, other keys (tracking data) merged
+ merged_mapped =
+ Map.merge(workflow.mapped, child_workflow.mapped, fn
+ :mapped_paths, v1, v2 -> MapSet.union(v1, v2)
+ :mapped_path_fan_outs, v1, v2 ->
+ Map.merge(v1, v2, fn _node_hash, fan_outs1, fan_outs2 ->
+ MapSet.union(fan_outs1, fan_outs2)
+ end)
+ # For tracking keys like {generation, hash} -> list or map, merge appropriately
+ _key, v1, v2 when is_list(v1) and is_list(v2) -> Enum.uniq(v1 ++ v2)
+ _key, v1, v2 when is_map(v1) and is_map(v2) -> Map.merge(v1, v2)
+ _key, _v1, v2 -> v2
+ end)
+
+ %Workflow{
+ workflow
+ | graph: new_graph,
+ mapped: merged_mapped,
+ run_context:
+ Map.merge(workflow.run_context, child_workflow.run_context, fn
+ _key, v1, v2 when is_map(v1) and is_map(v2) -> Map.merge(v1, v2)
+ _key, _v1, v2 -> v2
+ end),
+ before_hooks: Map.merge(workflow.before_hooks, child_workflow.before_hooks),
+ after_hooks: Map.merge(workflow.after_hooks, child_workflow.after_hooks),
+ components: Map.merge(workflow.components, child_workflow.components),
+ inputs: Map.merge(workflow.inputs, child_workflow.inputs),
+ build_log: workflow.build_log ++ child_workflow.build_log
+ }
+ end
+
+ def get_component(%Workflow{} = workflow, sub_component_name) do
+ Workflow.get_component(workflow, sub_component_name)
+ end
+
+ def get_component(%Workflow{} = workflow, %Workflow{}, sub_component_name) do
+ get_component(workflow, sub_component_name)
+ end
+
+ def components(%Workflow{components: components}) do
+ Enum.map(components, fn {name, _hash} -> {name, name} end)
+ end
+
+ def connectables(%Workflow{} = workflow, _other_component) do
+ components(workflow)
+ end
+
+ def connectable?(%Workflow{output_ports: nil}, _other_component), do: true
+
+ def connectable?(%Workflow{} = wf, other_component) do
+ producer_outputs = outputs(wf)
+ consumer_inputs = Runic.Component.inputs(other_component)
+
+ case Runic.Component.TypeCompatibility.ports_compatible?(producer_outputs, consumer_inputs) do
+ {:ok, _} -> true
+ {:error, _} -> false
+ end
+ end
+
+ def source(%Workflow{} = workflow) do
+ quote do
+ %Runic.Workflow{
+ name: unquote(workflow.name),
+ components: unquote(Macro.escape(workflow.components))
+ }
+ end
+ end
+
+ def hash(%Workflow{hash: hash}) when not is_nil(hash), do: hash
+
+ def hash(%Workflow{} = workflow) do
+ Runic.Workflow.Components.fact_hash(workflow)
+ end
+
+ def inputs(%Workflow{input_ports: nil}), do: []
+ def inputs(%Workflow{input_ports: ports}), do: ports
+
+ def outputs(%Workflow{output_ports: nil}), do: []
+ def outputs(%Workflow{output_ports: ports}), do: ports
+end
+
+defimpl Runic.Component, for: Tuple do
+ def connect({parent, children} = _pipeline, parent_component_in_workflow, workflow)
+ when is_list(children) do
+ # Connect the parent component to each child component
+ wrk = Runic.Workflow.add_step(workflow, parent_component_in_workflow, parent)
+
+ Enum.reduce(children, wrk, fn child, acc ->
+ Runic.Component.connect(child, parent, acc)
+ end)
+ end
+
+ def get_component(_tuple, _sub_component_name), do: nil
+ def get_component(_tuple, _workflow, _sub_component_name), do: nil
+ def components(_tuple), do: []
+ def connectables(_tuple, _other_component), do: []
+ def connectable?(_tuple, _other_component), do: false
+ def source(tuple), do: Macro.escape(tuple)
+ def inputs(_tuple), do: []
+ def outputs(_tuple), do: []
+
+ def hash(pipeline) do
+ Runic.Workflow.Components.fact_hash(pipeline)
+ end
+end
diff --git a/vendor/runic/lib/workflow/component_added.ex b/vendor/runic/lib/workflow/component_added.ex
new file mode 100644
index 0000000..c441460
--- /dev/null
+++ b/vendor/runic/lib/workflow/component_added.ex
@@ -0,0 +1,39 @@
+defmodule Runic.Workflow.ComponentAdded do
+ # To be used as a serializable event log for rebuilding workflows from logs
+
+ alias Runic.Closure
+
+ @derive {Inspect, only: [:name, :closure]}
+
+ # New format uses :closure field (fully serializable)
+ # Old format uses :source + :bindings with __caller_context__ (deprecated)
+ @type t :: %__MODULE__{
+ name: String.t() | atom(),
+ closure: Closure.t() | nil,
+ source: term() | nil,
+ bindings: map(),
+ to: term(),
+ hash: term()
+ }
+
+ defstruct [
+ :name,
+ :closure,
+ # Deprecated fields (kept for backward compatibility)
+ :source,
+ :bindings,
+ :to,
+ :hash
+ ]
+
+ # defimpl JSON.Encoder, for: __MODULE__ do
+ # def encode(%Runic.Workflow.ComponentAdded{} = event, _encoder) do
+ # %{
+ # "source" => event.source |> :erlang.term_to_binary() |> Base.encode64(),
+ # "to" => event.to,
+ # "bindings" => event.bindings
+ # }
+ # |> JSON.encode!()
+ # end
+ # end
+end
diff --git a/vendor/runic/lib/workflow/component_removed.ex b/vendor/runic/lib/workflow/component_removed.ex
new file mode 100644
index 0000000..0bf1586
--- /dev/null
+++ b/vendor/runic/lib/workflow/component_removed.ex
@@ -0,0 +1,11 @@
+defmodule Runic.Workflow.ComponentRemoved do
+ @moduledoc false
+ # Serializable event representing the removal of a component from a workflow.
+
+ @type t :: %__MODULE__{
+ name: atom() | String.t(),
+ hash: integer() | nil
+ }
+
+ defstruct [:name, :hash]
+end
diff --git a/vendor/runic/lib/workflow/components.ex b/vendor/runic/lib/workflow/components.ex
new file mode 100644
index 0000000..e2b154f
--- /dev/null
+++ b/vendor/runic/lib/workflow/components.ex
@@ -0,0 +1,108 @@
+defmodule Runic.Workflow.Components do
+ # common functions across workflow components
+ @doc false
+ @max_phash 4_294_967_296
+
+ def fact_hash(value), do: :erlang.phash2(value, @max_phash)
+
+ def vertex_id_of(%Runic.Workflow.FactRef{hash: hash}), do: hash
+ def vertex_id_of(%{hash: hash}), do: hash
+ def vertex_id_of(hash) when is_integer(hash), do: hash
+ def vertex_id_of(anything_otherwise), do: fact_hash(anything_otherwise)
+
+ def memory_vertex_id(%{hash: hash}), do: hash
+ def memory_vertex_id(hash) when is_integer(hash), do: hash
+ def memory_vertex_id(anything_otherwise), do: fact_hash(anything_otherwise)
+
+ def work_hash({m, f}),
+ do: work_hash({m, f, 1})
+
+ def work_hash({m, f, a}),
+ do: fact_hash(:erlang.term_to_binary(Function.capture(m, f, a)))
+
+ def work_hash(work) when is_function(work),
+ do: fact_hash(:erlang.term_to_binary(work))
+
+ def arity_of({:fn, _, [{:->, _, [[{:when, _when_meta, lhs}] | _rhs]}]}) do
+ Enum.count(lhs, fn
+ {_, _, child_ast} when is_list(child_ast) -> false
+ {_, _, _} -> true
+ end)
+ end
+
+ def arity_of({:fn, _, [{:->, _, [lhs, _rhs]}]}), do: Enum.count(lhs)
+
+ def arity_of(fun) when is_function(fun), do: Function.info(fun, :arity) |> elem(1)
+
+ def arity_of([
+ {:->, _meta, [[{:when, _when_meta, lhs_expression}] | _rhs]} | _
+ ]) do
+ lhs_expression
+ |> Enum.reject(&(not match?({_arg_name, _meta, nil}, &1)))
+ |> length()
+ end
+
+ def arity_of([{:->, _meta, [lhs | _rhs]} | _]), do: arity_of(lhs)
+
+ def arity_of(args) when is_list(args), do: length(args)
+
+ def arity_of(%{work: work}), do: arity_of(work)
+
+ def arity_of(_term), do: 1
+
+ # def is_of_arity?(arity) do
+ # fn
+ # args when is_list(args) ->
+ # if(arity == 1, do: true, else: length(args) == arity)
+
+ # args ->
+ # arity_of(args) == arity
+ # end
+ # end
+
+ def run({m, f}, fact_value) when is_list(fact_value), do: run({m, f}, fact_value, 1)
+
+ def run(work, fact_value) when is_function(work), do: run(work, fact_value, arity_of(work))
+
+ def run({m, f}, [] = fact_value, a) do
+ work = Function.capture(m, f, a)
+ run(work, fact_value, arity_of(work))
+ end
+
+ def run(work, _fact_value, 0) when is_function(work), do: apply(work, [])
+
+ def run(work, fact_value, 1) when is_function(work) and is_list(fact_value),
+ do: apply(work, [fact_value])
+
+ def run(work, fact_value, _arity) when is_function(work) and is_list(fact_value),
+ do: apply(work, fact_value)
+
+ def run(work, fact_value, _arity) when is_function(work), do: apply(work, [fact_value])
+
+ @doc """
+ Validates enumerable protocol implementation of values as a NimbleOptions custom type.
+ """
+ def enumerable_type(values, _args) do
+ case Enumerable.impl_for(values) do
+ nil ->
+ {:error, "#{inspect(values)} is not Enumerable"}
+
+ _impl ->
+ {:ok, values}
+ end
+ end
+
+ def component_impls do
+ case Runic.Component.__protocol__(:impls) do
+ :not_consolidated -> []
+ {:consolidated, impls} -> impls
+ end
+ end
+
+ def invokable_impls do
+ case Runic.Workflow.Invokable.__protocol__(:impls) do
+ :not_consolidated -> []
+ {:consolidated, impls} -> impls
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/condition.ex b/vendor/runic/lib/workflow/condition.ex
new file mode 100644
index 0000000..71344e2
--- /dev/null
+++ b/vendor/runic/lib/workflow/condition.ex
@@ -0,0 +1,176 @@
+defmodule Runic.Workflow.Condition do
+ @moduledoc """
+ Condition nodes are predicate checks that gate flow in a workflow.
+
+ A Condition receives a fact and evaluates to true/false. If true, downstream
+ nodes become runnable; if false, the fact is consumed without propagation.
+
+ ## Meta Expression Support
+
+ Conditions can reference workflow state through meta expressions like `state_of(:component)`.
+ When a Condition has meta references, the `:meta_refs` field is populated during
+ macro compilation, and `:meta_ref` edges are drawn during `Component.connect/3`.
+
+ During the prepare phase, these edges are traversed to populate `meta_context` in
+ the `CausalContext`, making the referenced state available during execution.
+
+ ## Runtime Context
+
+ Conditions can also reference external runtime values via `context/1` expressions,
+ commonly used in rule `where` clauses:
+
+ Runic.rule name: :gated do
+ given(val: v)
+ where(v > context(:threshold))
+ then(fn %{val: v} -> {:ok, v} end)
+ end
+
+ The condition's work function is rewritten to arity-2 when `context/1` is detected,
+ and values are resolved from the workflow's `run_context` during the prepare phase.
+ """
+
+ alias Runic.Workflow.Components
+ alias Runic.Closure
+
+ @type meta_ref :: %{
+ kind: atom(),
+ target: atom() | integer() | {atom(), atom()},
+ field_path: list(atom()),
+ context_key: atom()
+ }
+
+ @type t :: %__MODULE__{
+ name: String.t() | atom() | nil,
+ hash: integer() | nil,
+ work: function(),
+ work_hash: integer() | nil,
+ closure: Closure.t() | nil,
+ arity: non_neg_integer(),
+ meta_refs: list(meta_ref())
+ }
+
+ defstruct [:name, :hash, :work, :work_hash, :closure, :arity, meta_refs: []]
+
+ require Logger
+
+ def new(work) when is_function(work) do
+ new(work, Function.info(work, :arity) |> elem(1))
+ end
+
+ def new(opts) do
+ struct!(__MODULE__, opts)
+ end
+
+ def new(work, arity) when is_function(work) do
+ %__MODULE__{
+ work: work,
+ hash: Components.work_hash(work),
+ arity: arity
+ }
+ end
+
+ def check(%__MODULE__{work: work, arity: arity}, %Runic.Workflow.Fact{} = fact) do
+ try_to_run_work(work, fact.value, arity)
+ end
+
+ def check(%__MODULE__{work: work, arity: arity}, value) do
+ try_to_run_work(work, value, arity)
+ end
+
+ def try_to_run_work(work, fact_value, arity) do
+ try do
+ run_work(work, fact_value, arity)
+ rescue
+ FunctionClauseError -> false
+ BadArityError -> false
+ catch
+ true ->
+ true
+
+ any ->
+ Logger.error(
+ "something other than FunctionClauseError happened in Condition invoke/3 -> try_to_run_work/3: \n\n #{inspect(any)}"
+ )
+
+ false
+ end
+ end
+
+ defp run_work(work, fact_value, 1) when is_list(fact_value) do
+ apply(work, [fact_value])
+ end
+
+ defp run_work(work, fact_value, arity) when arity > 1 and is_list(fact_value) do
+ Components.run(work, fact_value)
+ end
+
+ defp run_work(_work, _fact_value, arity) when arity > 1 do
+ false
+ end
+
+ defp run_work(work, fact_value, _arity) do
+ Components.run(work, fact_value)
+ end
+
+ @doc """
+ Returns whether this condition has meta references that need to be resolved
+ during the prepare phase.
+ """
+ @spec has_meta_refs?(t()) :: boolean()
+ def has_meta_refs?(%__MODULE__{meta_refs: meta_refs}), do: meta_refs != []
+
+ @doc """
+ Checks a condition with meta context available.
+
+ When a condition has meta references, its work function is arity 2,
+ receiving `(input, meta_context)`.
+ """
+ @spec check_with_meta_context(t(), term(), map()) :: boolean()
+ def check_with_meta_context(
+ %__MODULE__{work: work, arity: 2} = _condition,
+ %Runic.Workflow.Fact{} = fact,
+ meta_context
+ )
+ when is_map(meta_context) do
+ try do
+ work.(fact.value, meta_context)
+ rescue
+ FunctionClauseError -> false
+ BadArityError -> false
+ catch
+ _ -> false
+ end
+ end
+
+ def check_with_meta_context(%__MODULE__{work: work, arity: 2} = _condition, value, meta_context)
+ when is_map(meta_context) do
+ try do
+ work.(value, meta_context)
+ rescue
+ FunctionClauseError -> false
+ BadArityError -> false
+ catch
+ _ -> false
+ end
+ end
+
+ def check_with_meta_context(%__MODULE__{} = condition, value, _meta_context) do
+ check(condition, value)
+ end
+end
+
+defimpl Runic.Workflow.Activator, for: Runic.Workflow.Condition do
+ alias Runic.Workflow
+ alias Runic.Workflow.Runnable
+ alias Runic.Workflow.Private
+
+ def activate_downstream(%Runic.Workflow.Condition{} = node, %Workflow{} = wf, %Runnable{
+ result: true,
+ input_fact: fact
+ }) do
+ Private.activate_downstream_with_events(wf, node, fact)
+ end
+
+ def activate_downstream(%Runic.Workflow.Condition{}, %Workflow{} = wf, %Runnable{}),
+ do: {wf, []}
+end
diff --git a/vendor/runic/lib/workflow/condition_ref.ex b/vendor/runic/lib/workflow/condition_ref.ex
new file mode 100644
index 0000000..59b2949
--- /dev/null
+++ b/vendor/runic/lib/workflow/condition_ref.ex
@@ -0,0 +1,17 @@
+defmodule Runic.Workflow.ConditionRef do
+ @moduledoc """
+ A lightweight compile-time placeholder for referencing named conditions in rule `where` clauses.
+
+ `ConditionRef` is not a graph vertex — it exists only during macro expansion to represent
+ `condition(:name)` inside a `where` clause before the condition is resolved at connect-time.
+
+ At connect-time (when a rule with condition refs is added to a workflow via `Component.connect/3`),
+ each ref is resolved to an existing named condition component in the workflow.
+ """
+
+ @type t :: %__MODULE__{
+ name: atom()
+ }
+
+ defstruct [:name]
+end
diff --git a/vendor/runic/lib/workflow/conjunction.ex b/vendor/runic/lib/workflow/conjunction.ex
new file mode 100644
index 0000000..03d6ab6
--- /dev/null
+++ b/vendor/runic/lib/workflow/conjunction.ex
@@ -0,0 +1,67 @@
+defmodule Runic.Workflow.Conjunction do
+ @moduledoc """
+ Logical AND gate that requires all referenced conditions to be satisfied.
+
+ A Conjunction holds `condition_hashes` (a MapSet of resolved condition hashes)
+ and optionally `condition_refs` (a list of named condition references that are
+ resolved at connect-time when the rule is added to a workflow).
+ """
+
+ alias Runic.Workflow.Components
+
+ defstruct [:hash, :condition_hashes, condition_refs: []]
+
+ @doc """
+ Creates a conjunction from a list of condition structs.
+
+ This is the original constructor used by existing rule compilation.
+ """
+ def new(conditions) when is_list(conditions) do
+ condition_hashes = conditions |> MapSet.new(& &1.hash)
+
+ %__MODULE__{
+ hash: condition_hashes |> Components.fact_hash(),
+ condition_hashes: condition_hashes
+ }
+ end
+
+ @doc """
+ Creates a conjunction from inline condition hashes and named condition refs.
+
+ The hash is computed from a sorted list of `{:hash, int} | {:ref, atom}` tuples,
+ making it stable at compile-time even when refs are not yet resolved.
+
+ At connect-time, `condition_hashes` is populated with resolved hashes.
+ """
+ def new(inline_hashes, condition_refs)
+ when is_list(inline_hashes) and is_list(condition_refs) do
+ condition_hashes = MapSet.new(inline_hashes)
+ sorted_refs = Enum.sort(condition_refs)
+
+ hash_basis =
+ (Enum.sort(inline_hashes) |> Enum.map(&{:hash, &1})) ++
+ Enum.map(sorted_refs, &{:ref, &1})
+
+ %__MODULE__{
+ hash: Components.fact_hash(hash_basis),
+ condition_hashes: condition_hashes,
+ condition_refs: sorted_refs
+ }
+ end
+end
+
+defimpl Runic.Workflow.Activator, for: Runic.Workflow.Conjunction do
+ alias Runic.Workflow
+ alias Runic.Workflow.Runnable
+ alias Runic.Workflow.Private
+
+ def activate_downstream(%Runic.Workflow.Conjunction{} = node, %Workflow{} = wf, %Runnable{
+ result: true,
+ input_fact: fact
+ }) do
+ Private.activate_downstream_with_events(wf, node, fact)
+ end
+
+ def activate_downstream(%Runic.Workflow.Conjunction{}, %Workflow{} = wf, %Runnable{}),
+ do: {wf, []}
+end
diff --git a/vendor/runic/lib/workflow/coordinator.ex b/vendor/runic/lib/workflow/coordinator.ex
new file mode 100644
index 0000000..da2f1c2
--- /dev/null
+++ b/vendor/runic/lib/workflow/coordinator.ex
@@ -0,0 +1,53 @@
+defprotocol Runic.Workflow.Coordinator do
+ @moduledoc """
+ Optional protocol for nodes that need post-fold coordination.
+
+ After `apply_event/2` has folded a runnable's events into the workflow,
+ some node types need to inspect the updated graph to determine if a
+ coordination step is complete (e.g. all Join branches satisfied, all
+ FanIn items collected).
+
+ Nodes that implement this protocol will have `finalize/3` called during
+ `apply_runnable/2`. The callback receives the node, the current workflow
+ (with events already folded), and the original runnable. It returns
+ `{workflow, derived_events}` where `derived_events` have been folded
+ into the returned workflow.
+
+ Nodes that do **not** implement this protocol are assumed to need no
+ coordination — `apply_runnable/2` skips the coordination step for them.
+
+ ## Built-in Implementations
+
+ - `Runic.Workflow.Join` — rechecks branch satisfaction after folding
+ `JoinFactReceived` events, emits `JoinCompleted` + `JoinEdgeRelabeled`
+ events when all branches are satisfied.
+
+ - `Runic.Workflow.FanIn` — rechecks whether all fan-out items have been
+ processed, emits `FanInCompleted` + sister `ActivationConsumed` events
+ when ready.
+
+ ## Example
+
+ defimpl Runic.Workflow.Coordinator, for: MyApp.CustomCoordinationNode do
+ def finalize(node, workflow, runnable) do
+ # Check coordination condition, emit derived events if complete
+ {workflow, []}
+ end
+ end
+ """
+
+ @doc """
+ Post-fold coordination finalization.
+
+ Called after all events from `execute/2` have been folded into the workflow.
+ Returns `{workflow, derived_events}` where `derived_events` have already
+ been folded into the returned workflow.
+ """
+ @spec finalize(
+ node :: struct(),
+ workflow :: Runic.Workflow.t(),
+ runnable :: Runic.Workflow.Runnable.t()
+ ) ::
+ {Runic.Workflow.t(), [struct()]}
+ def finalize(node, workflow, runnable)
+end
diff --git a/vendor/runic/lib/workflow/event_applicator.ex b/vendor/runic/lib/workflow/event_applicator.ex
new file mode 100644
index 0000000..bf46e3d
--- /dev/null
+++ b/vendor/runic/lib/workflow/event_applicator.ex
@@ -0,0 +1,39 @@
+defprotocol Runic.Workflow.EventApplicator do
+ @moduledoc """
+ Protocol for applying custom events to a workflow.
+
+ Built-in events (e.g. `FactProduced`, `ActivationConsumed`, `JoinCompleted`) are
+ handled by pattern-matched clauses in `Workflow.apply_event/2` and do **not** need
+ to implement this protocol — they are dispatched before the protocol fallback.
+
+ External libraries that define custom `Invokable` node types can introduce their
+ own event structs and implement `EventApplicator` so that `apply_event/2` knows
+ how to fold them into the workflow.
+
+ ## Example
+
+ defmodule MyApp.Events.CustomStepCompleted do
+ defstruct [:node_hash, :result_value]
+ end
+
+ defimpl Runic.Workflow.EventApplicator, for: MyApp.Events.CustomStepCompleted do
+ def apply(event, workflow) do
+ # Use Workflow public API to fold the event into the workflow
+ fact = Runic.Workflow.Fact.new(value: event.result_value, ancestry: {event.node_hash, nil})
+ Runic.Workflow.log_fact(workflow, fact)
+ end
+ end
+
+ ## Serialization
+
+ Custom events serialize naturally via ETF (`:erlang.term_to_binary/1`). For
+ cross-language interop, implement serialization at the Store adapter level
+ using the event struct fields directly.
+ """
+
+ @doc """
+ Applies this event to the workflow, returning the updated workflow.
+ """
+ @spec apply(event :: struct(), workflow :: Runic.Workflow.t()) :: Runic.Workflow.t()
+ def apply(event, workflow)
+end
diff --git a/vendor/runic/lib/workflow/events.ex b/vendor/runic/lib/workflow/events.ex
new file mode 100644
index 0000000..080dfde
--- /dev/null
+++ b/vendor/runic/lib/workflow/events.ex
@@ -0,0 +1,46 @@
+defmodule Runic.Workflow.Events do
+ @moduledoc """
+ Barrel module for all workflow event types.
+
+ Events are the primary mutation interface in the event-sourced workflow model.
+ `Invokable.execute/2` produces events, and `Workflow.apply_event/2` folds them
+ into the workflow graph as a pure function.
+ """
+
+ alias Runic.Workflow.Events.FactProduced
+ alias Runic.Workflow.Events.ActivationConsumed
+ alias Runic.Workflow.Events.RunnableActivated
+ alias Runic.Workflow.Events.ConditionSatisfied
+ alias Runic.Workflow.Events.MapReduceTracked
+ alias Runic.Workflow.Events.StateInitiated
+ alias Runic.Workflow.Events.JoinFactReceived
+ alias Runic.Workflow.Events.JoinCompleted
+ alias Runic.Workflow.Events.JoinEdgeRelabeled
+ alias Runic.Workflow.Events.FanOutFactEmitted
+ alias Runic.Workflow.Events.FanInCompleted
+
+ @type event ::
+ FactProduced.t()
+ | ActivationConsumed.t()
+ | RunnableActivated.t()
+ | ConditionSatisfied.t()
+ | MapReduceTracked.t()
+ | StateInitiated.t()
+ | JoinFactReceived.t()
+ | JoinCompleted.t()
+ | JoinEdgeRelabeled.t()
+ | FanOutFactEmitted.t()
+ | FanInCompleted.t()
+
+ defdelegate fact_produced(), to: FactProduced, as: :__struct__
+ defdelegate activation_consumed(), to: ActivationConsumed, as: :__struct__
+ defdelegate runnable_activated(), to: RunnableActivated, as: :__struct__
+ defdelegate condition_satisfied(), to: ConditionSatisfied, as: :__struct__
+ defdelegate map_reduce_tracked(), to: MapReduceTracked, as: :__struct__
+ defdelegate state_initiated(), to: StateInitiated, as: :__struct__
+ defdelegate join_fact_received(), to: JoinFactReceived, as: :__struct__
+ defdelegate join_completed(), to: JoinCompleted, as: :__struct__
+ defdelegate join_edge_relabeled(), to: JoinEdgeRelabeled, as: :__struct__
+ defdelegate fan_out_fact_emitted(), to: FanOutFactEmitted, as: :__struct__
+ defdelegate fan_in_completed(), to: FanInCompleted, as: :__struct__
+end
diff --git a/vendor/runic/lib/workflow/events/activation_consumed.ex b/vendor/runic/lib/workflow/events/activation_consumed.ex
new file mode 100644
index 0000000..411b382
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/activation_consumed.ex
@@ -0,0 +1,15 @@
+defmodule Runic.Workflow.Events.ActivationConsumed do
+ @moduledoc """
+ Event emitted when a node consumes its activation edge (marks it as :ran).
+
+ `from_label` is the edge label being consumed: `:runnable` or `:matchable`.
+ """
+
+ @type t :: %__MODULE__{
+ fact_hash: term(),
+ node_hash: term(),
+ from_label: :runnable | :matchable
+ }
+
+ defstruct [:fact_hash, :node_hash, :from_label]
+end
diff --git a/vendor/runic/lib/workflow/events/condition_satisfied.ex b/vendor/runic/lib/workflow/events/condition_satisfied.ex
new file mode 100644
index 0000000..8a0c484
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/condition_satisfied.ex
@@ -0,0 +1,13 @@
+defmodule Runic.Workflow.Events.ConditionSatisfied do
+ @moduledoc """
+ Event emitted when a condition (or conjunction) is satisfied by a fact.
+ """
+
+ @type t :: %__MODULE__{
+ fact_hash: term(),
+ condition_hash: term(),
+ weight: non_neg_integer()
+ }
+
+ defstruct [:fact_hash, :condition_hash, :weight]
+end
diff --git a/vendor/runic/lib/workflow/events/fact_produced.ex b/vendor/runic/lib/workflow/events/fact_produced.ex
new file mode 100644
index 0000000..bb214aa
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/fact_produced.ex
@@ -0,0 +1,22 @@
+defmodule Runic.Workflow.Events.FactProduced do
+ @moduledoc """
+ Event emitted when a fact is produced during workflow execution.
+
+ The `producer_label` indicates what kind of production edge should be drawn:
+ `:produced`, `:state_produced`, `:state_initiated`, `:reduced`, `:fan_out`, `:joined`, or `:input`.
+
+ At runtime, `value` is always present. For journal persistence, the Store adapter
+ may extract values to a content-addressed fact store keyed by hash.
+ """
+
+ @type t :: %__MODULE__{
+ hash: term(),
+ value: term(),
+ ancestry: {term(), term()} | nil,
+ producer_label: atom(),
+ weight: non_neg_integer(),
+ meta: map()
+ }
+
+ defstruct [:hash, :value, :ancestry, :producer_label, :weight, meta: %{}]
+end
diff --git a/vendor/runic/lib/workflow/events/fan_in_completed.ex b/vendor/runic/lib/workflow/events/fan_in_completed.ex
new file mode 100644
index 0000000..1ea97b9
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/fan_in_completed.ex
@@ -0,0 +1,32 @@
+defmodule Runic.Workflow.Events.FanInCompleted do
+ @moduledoc """
+ Event derived during `maybe_finalize_coordination/2` when a FanIn node
+ determines that all expected fan-out items have been processed.
+
+ This event is produced during the apply phase (not execute), similar to
+ `JoinCompleted`. It logs the reduced result fact, draws the `:reduced`
+ edge, marks completion in mapped state, and cleans up tracking keys.
+ """
+
+ @type t :: %__MODULE__{
+ fan_in_hash: term(),
+ source_fact_hash: term(),
+ result_fact_hash: term(),
+ result_value: term(),
+ result_ancestry: {term(), term()} | nil,
+ expected_key: {term(), term()},
+ seen_key: {term(), term()},
+ weight: non_neg_integer()
+ }
+
+ defstruct [
+ :fan_in_hash,
+ :source_fact_hash,
+ :result_fact_hash,
+ :result_value,
+ :result_ancestry,
+ :expected_key,
+ :seen_key,
+ :weight
+ ]
+end
diff --git a/vendor/runic/lib/workflow/events/fan_out_fact_emitted.ex b/vendor/runic/lib/workflow/events/fan_out_fact_emitted.ex
new file mode 100644
index 0000000..59d0e92
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/fan_out_fact_emitted.ex
@@ -0,0 +1,31 @@
+defmodule Runic.Workflow.Events.FanOutFactEmitted do
+ @moduledoc """
+ Event emitted when a FanOut node splits an enumerable input into individual facts.
+
+ One event is produced per item in the enumerable. During `apply_event/2`, the emitted
+ fact is logged, a `:fan_out` edge is drawn, and the `mapped` tracking state is updated
+ for downstream FanIn coordination.
+ """
+
+ @type t :: %__MODULE__{
+ fan_out_hash: term(),
+ source_fact_hash: term(),
+ emitted_fact_hash: term(),
+ emitted_value: term(),
+ emitted_ancestry: {term(), term()} | nil,
+ item_index: non_neg_integer() | nil,
+ items_total: non_neg_integer() | nil,
+ weight: non_neg_integer()
+ }
+
+ defstruct [
+ :fan_out_hash,
+ :source_fact_hash,
+ :emitted_fact_hash,
+ :emitted_value,
+ :emitted_ancestry,
+ :item_index,
+ :items_total,
+ :weight
+ ]
+end
diff --git a/vendor/runic/lib/workflow/events/join_completed.ex b/vendor/runic/lib/workflow/events/join_completed.ex
new file mode 100644
index 0000000..1f0530a
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/join_completed.ex
@@ -0,0 +1,22 @@
+defmodule Runic.Workflow.Events.JoinCompleted do
+ @moduledoc """
+ Derived event emitted when a Join node has all required branches satisfied.
+
+ Produced during `apply_runnable/2`'s coordination finalization step,
+ NOT during `execute/2`. Contains the result fact (collected join values)
+ which gets logged into the graph and connected with a `:produced` edge.
+
+ During replay via `apply_event/2`, this event is folded directly without
+ re-deriving — the completion check only runs during live execution.
+ """
+
+ @type t :: %__MODULE__{
+ join_hash: term(),
+ result_fact_hash: term(),
+ result_value: term(),
+ result_ancestry: {term(), term()} | nil,
+ weight: non_neg_integer()
+ }
+
+ defstruct [:join_hash, :result_fact_hash, :result_value, :result_ancestry, :weight]
+end
diff --git a/vendor/runic/lib/workflow/events/join_edge_relabeled.ex b/vendor/runic/lib/workflow/events/join_edge_relabeled.ex
new file mode 100644
index 0000000..0acd806
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/join_edge_relabeled.ex
@@ -0,0 +1,18 @@
+defmodule Runic.Workflow.Events.JoinEdgeRelabeled do
+ @moduledoc """
+ Derived event emitted when a join edge is relabeled upon join completion.
+
+ Captures the relabeling of `:joined` → `:join_satisfied` edges that occurs
+ when a Join node completes. Produced during `apply_runnable/2`'s
+ coordination finalization, alongside `JoinCompleted`.
+ """
+
+ @type t :: %__MODULE__{
+ fact_hash: term(),
+ join_hash: term(),
+ from_label: atom(),
+ to_label: atom()
+ }
+
+ defstruct [:fact_hash, :join_hash, :from_label, :to_label]
+end
diff --git a/vendor/runic/lib/workflow/events/join_fact_received.ex b/vendor/runic/lib/workflow/events/join_fact_received.ex
new file mode 100644
index 0000000..6f392c9
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/join_fact_received.ex
@@ -0,0 +1,18 @@
+defmodule Runic.Workflow.Events.JoinFactReceived do
+ @moduledoc """
+ Event emitted when a fact arrives at a Join node from a parent branch.
+
+ Drawing a `:joined` edge from the fact to the join node.
+ The join may or may not complete after this event — completion is
+ checked separately in `apply_runnable/2` via `maybe_finalize_coordination/2`.
+ """
+
+ @type t :: %__MODULE__{
+ fact_hash: term(),
+ join_hash: term(),
+ parent_hash: term(),
+ weight: non_neg_integer()
+ }
+
+ defstruct [:fact_hash, :join_hash, :parent_hash, :weight]
+end
diff --git a/vendor/runic/lib/workflow/events/map_reduce_tracked.ex b/vendor/runic/lib/workflow/events/map_reduce_tracked.ex
new file mode 100644
index 0000000..287b10a
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/map_reduce_tracked.ex
@@ -0,0 +1,16 @@
+defmodule Runic.Workflow.Events.MapReduceTracked do
+ @moduledoc """
+ Event emitted when a step in a fan-out pipeline tracks its result
+ for downstream fan-in coordination.
+ """
+
+ @type t :: %__MODULE__{
+ source_fact_hash: term(),
+ fan_out_hash: term(),
+ fan_out_fact_hash: term(),
+ step_hash: term(),
+ result_fact_hash: term()
+ }
+
+ defstruct [:source_fact_hash, :fan_out_hash, :fan_out_fact_hash, :step_hash, :result_fact_hash]
+end
diff --git a/vendor/runic/lib/workflow/events/runnable_activated.ex b/vendor/runic/lib/workflow/events/runnable_activated.ex
new file mode 100644
index 0000000..6a9ad53
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/runnable_activated.ex
@@ -0,0 +1,15 @@
+defmodule Runic.Workflow.Events.RunnableActivated do
+ @moduledoc """
+ Event emitted when a downstream node is activated by a produced fact.
+
+ `activation_kind` is `:runnable` for execute-type nodes or `:matchable` for match-type nodes.
+ """
+
+ @type t :: %__MODULE__{
+ fact_hash: term(),
+ node_hash: term(),
+ activation_kind: :runnable | :matchable
+ }
+
+ defstruct [:fact_hash, :node_hash, :activation_kind]
+end
diff --git a/vendor/runic/lib/workflow/events/serializer.ex b/vendor/runic/lib/workflow/events/serializer.ex
new file mode 100644
index 0000000..ce3689b
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/serializer.ex
@@ -0,0 +1,81 @@
+defmodule Runic.Workflow.Events.Serializer do
+ @moduledoc """
+ Serialization utilities for workflow events.
+
+ Provides binary (ETF) serialization for event persistence and transport.
+ Events are plain structs and serialize naturally via Erlang's external term format.
+
+ ## Binary Serialization (ETF)
+
+ The primary serialization format uses `:erlang.term_to_binary/1` which handles
+ all Elixir/Erlang terms natively. This is the recommended format for same-system
+ persistence (ETS, Mnesia, file-based stores).
+
+ events = workflow.uncommitted_events
+ binary = Serializer.to_binary(events)
+ {:ok, ^events} = Serializer.from_binary(binary)
+
+ ## Custom Events
+
+ Custom event structs from external `Invokable` implementations serialize and
+ deserialize automatically via ETF — no registration or adapter is needed.
+ The only requirement is that the struct's module atom exists in the VM at
+ deserialization time (which is guaranteed when using `:safe` mode and the
+ application code is loaded).
+
+ Custom events should implement the `Runic.Workflow.EventApplicator` protocol
+ so that `Workflow.apply_event/2` knows how to fold them into the workflow
+ during replay via `Workflow.from_events/1`.
+
+ ## Considerations
+
+ - Binary format is tied to the Erlang VM and struct module names.
+ Schema migrations require versioned deserialization.
+ - For cross-language interop, implement a JSON adapter at the Store level
+ using the event struct fields directly (all fields are JSON-safe primitives
+ except `value` in `FactProduced`, which is arbitrary user data).
+ - For event schema versioning, add a `version` field to custom event structs
+ and handle upcasting during deserialization at the Store adapter level.
+ """
+
+ @doc """
+ Serializes a list of events to binary (Erlang External Term Format).
+ """
+ @spec to_binary([struct()]) :: binary()
+ def to_binary(events) when is_list(events) do
+ :erlang.term_to_binary(events)
+ end
+
+ @doc """
+ Deserializes events from binary.
+
+ Uses `:safe` mode to prevent atom table exhaustion from untrusted input.
+ All event struct atoms must already exist in the VM.
+
+ Returns `{:ok, events}` or `{:error, reason}`.
+ """
+ @spec from_binary(binary()) :: {:ok, [struct()]} | {:error, term()}
+ def from_binary(binary) when is_binary(binary) do
+ {:ok, :erlang.binary_to_term(binary, [:safe])}
+ rescue
+ e -> {:error, e}
+ end
+
+ @doc """
+ Serializes a single event to binary.
+ """
+ @spec event_to_binary(struct()) :: binary()
+ def event_to_binary(event) when is_struct(event) do
+ :erlang.term_to_binary(event)
+ end
+
+ @doc """
+ Deserializes a single event from binary.
+ """
+ @spec event_from_binary(binary()) :: {:ok, struct()} | {:error, term()}
+ def event_from_binary(binary) when is_binary(binary) do
+ {:ok, :erlang.binary_to_term(binary, [:safe])}
+ rescue
+ e -> {:error, e}
+ end
+end
diff --git a/vendor/runic/lib/workflow/events/state_initiated.ex b/vendor/runic/lib/workflow/events/state_initiated.ex
new file mode 100644
index 0000000..dd91b35
--- /dev/null
+++ b/vendor/runic/lib/workflow/events/state_initiated.ex
@@ -0,0 +1,18 @@
+defmodule Runic.Workflow.Events.StateInitiated do
+ @moduledoc """
+ Event emitted when an accumulator initializes its state for the first time.
+
+ Contains the initial fact's hash, value, and ancestry so that
+ `apply_event/2` can log the init fact and draw a `:state_initiated` edge.
+ """
+
+ @type t :: %__MODULE__{
+ accumulator_hash: term(),
+ init_fact_hash: term(),
+ init_value: term(),
+ init_ancestry: {term(), term()} | nil,
+ weight: non_neg_integer()
+ }
+
+ defstruct [:accumulator_hash, :init_fact_hash, :init_value, :init_ancestry, :weight]
+end
diff --git a/vendor/runic/lib/workflow/fact.ex b/vendor/runic/lib/workflow/fact.ex
new file mode 100644
index 0000000..3fda149
--- /dev/null
+++ b/vendor/runic/lib/workflow/fact.ex
@@ -0,0 +1,33 @@
+defmodule Runic.Workflow.Fact do
+ alias Runic.Workflow.Components
+ defstruct [:hash, :value, :ancestry, meta: %{}]
+
+ @type hash() :: integer() | binary()
+
+ @type t() :: %__MODULE__{
+ value: term(),
+ hash: hash(),
+ ancestry: {hash(), hash()},
+ meta: map()
+ }
+
+ def new(params) do
+ struct!(__MODULE__, params)
+ |> maybe_set_hash()
+ end
+
+ defp maybe_set_hash(%__MODULE__{value: value, hash: nil, meta: meta} = fact) do
+ hash_input = {value, fact.ancestry, item_index(meta)}
+ %__MODULE__{fact | hash: Components.fact_hash(hash_input)}
+ end
+
+ defp maybe_set_hash(%__MODULE__{hash: hash} = fact)
+ when not is_nil(hash),
+ do: fact
+
+ defp item_index(meta) when is_map(meta) do
+ Map.get(meta, :item_index) || Map.get(meta, "item_index")
+ end
+
+ defp item_index(_meta), do: nil
+end
diff --git a/vendor/runic/lib/workflow/fact_ref.ex b/vendor/runic/lib/workflow/fact_ref.ex
new file mode 100644
index 0000000..2922dd6
--- /dev/null
+++ b/vendor/runic/lib/workflow/fact_ref.ex
@@ -0,0 +1,16 @@
+defmodule Runic.Workflow.FactRef do
+ @moduledoc """
+ A lightweight reference to a Fact without its value.
+
+ Used during lean replay / hybrid rehydration to reconstruct graph
+ topology without loading all fact values into memory.
+ """
+
+ @type t :: %__MODULE__{
+ hash: Runic.Workflow.Fact.hash(),
+ ancestry: {Runic.Workflow.Fact.hash(), Runic.Workflow.Fact.hash()} | nil,
+ meta: map()
+ }
+
+ defstruct [:hash, :ancestry, meta: %{}]
+end
diff --git a/vendor/runic/lib/workflow/fact_resolver.ex b/vendor/runic/lib/workflow/fact_resolver.ex
new file mode 100644
index 0000000..347cadc
--- /dev/null
+++ b/vendor/runic/lib/workflow/fact_resolver.ex
@@ -0,0 +1,80 @@
+defmodule Runic.Workflow.FactResolver do
+ @moduledoc """
+ Runtime resolver that hydrates `FactRef` structs via a Store adapter.
+
+ Maintains a process-local cache to avoid repeated store round-trips.
+ The cache lives in the Worker process memory and is cleared on Worker stop.
+ """
+
+ alias Runic.Workflow.{Fact, FactRef}
+
+ defstruct [:store, cache: %{}]
+
+ @type t :: %__MODULE__{
+ store: {module(), term()},
+ cache: %{optional(term()) => term()}
+ }
+
+ @doc "Creates a new FactResolver backed by the given store tuple."
+ @spec new({module(), term()}) :: t()
+ def new(store), do: %__MODULE__{store: store, cache: %{}}
+
+ @doc """
+ Resolves a Fact or FactRef to a full Fact with its value.
+
+ - Full `Fact` structs with a value are returned as-is (passthrough).
+ - `FactRef` structs are resolved by checking the cache first, then
+ falling back to the store's `load_fact/2`.
+
+ Returns `{:ok, %Fact{}}` or `{:error, reason}`.
+ """
+ @spec resolve(Fact.t() | FactRef.t(), t()) :: {:ok, Fact.t()} | {:error, term()}
+ def resolve(%Fact{value: v} = fact, _resolver) when not is_nil(v), do: {:ok, fact}
+
+ def resolve(%FactRef{hash: h, ancestry: a, meta: meta}, %__MODULE__{} = resolver) do
+ case Map.get(resolver.cache, h) do
+ nil ->
+ {mod, st} = resolver.store
+
+ case mod.load_fact(h, st) do
+ {:ok, value} -> {:ok, %Fact{hash: h, ancestry: a, value: value, meta: meta}}
+ {:error, _} = err -> err
+ end
+
+ value ->
+ {:ok, %Fact{hash: h, ancestry: a, value: value, meta: meta}}
+ end
+ end
+
+ @doc "Like `resolve/2` but raises on error."
+ @spec resolve!(Fact.t() | FactRef.t(), t()) :: Fact.t()
+ def resolve!(fact_or_ref, resolver) do
+ {:ok, fact} = resolve(fact_or_ref, resolver)
+ fact
+ end
+
+ @doc """
+ Batch-loads a list of fact hashes into the resolver's cache.
+
+ Hashes already present in the cache are skipped. Returns an updated
+ resolver with the newly loaded values in its cache.
+ """
+ @spec preload(t(), [term()]) :: t()
+ def preload(%__MODULE__{} = resolver, fact_hashes) when is_list(fact_hashes) do
+ {mod, st} = resolver.store
+
+ loaded =
+ Enum.reduce(fact_hashes, resolver.cache, fn hash, cache ->
+ if Map.has_key?(cache, hash) do
+ cache
+ else
+ case mod.load_fact(hash, st) do
+ {:ok, value} -> Map.put(cache, hash, value)
+ {:error, _} -> cache
+ end
+ end
+ end)
+
+ %{resolver | cache: loaded}
+ end
+end
diff --git a/vendor/runic/lib/workflow/facts.ex b/vendor/runic/lib/workflow/facts.ex
new file mode 100644
index 0000000..82e7545
--- /dev/null
+++ b/vendor/runic/lib/workflow/facts.ex
@@ -0,0 +1,30 @@
+defmodule Runic.Workflow.Facts do
+ @moduledoc """
+ Unified accessors for `Fact` and `FactRef` structs.
+
+ Avoids pattern-matching on struct types throughout the codebase when
+ only the hash, ancestry, or "has a value?" predicate is needed.
+ """
+
+ alias Runic.Workflow.{Fact, FactRef}
+
+ @doc "Returns the hash of a Fact or FactRef."
+ @spec hash(Fact.t() | FactRef.t()) :: Fact.hash()
+ def hash(%Fact{hash: h}), do: h
+ def hash(%FactRef{hash: h}), do: h
+
+ @doc "Returns the ancestry of a Fact or FactRef."
+ @spec ancestry(Fact.t() | FactRef.t()) :: {Fact.hash(), Fact.hash()} | nil
+ def ancestry(%Fact{ancestry: a}), do: a
+ def ancestry(%FactRef{ancestry: a}), do: a
+
+ @doc "Returns true if the struct carries a concrete value (only true for Facts with non-nil values)."
+ @spec value?(Fact.t() | FactRef.t()) :: boolean()
+ def value?(%Fact{value: v}) when not is_nil(v), do: true
+ def value?(_), do: false
+
+ @doc "Converts a Fact to a FactRef, discarding the value."
+ @spec to_ref(Fact.t()) :: FactRef.t()
+ def to_ref(%Fact{hash: h, ancestry: a, meta: meta}),
+ do: %FactRef{hash: h, ancestry: a, meta: meta}
+end
diff --git a/vendor/runic/lib/workflow/fan_in.ex b/vendor/runic/lib/workflow/fan_in.ex
new file mode 100644
index 0000000..11a20a4
--- /dev/null
+++ b/vendor/runic/lib/workflow/fan_in.ex
@@ -0,0 +1,135 @@
+defmodule Runic.Workflow.FanIn do
+ @moduledoc """
+ FanIn steps are part of a reduce operator that combines multiple facts into a single fact
+ by applying the reducer function to return the accumulator with the parent.
+
+ ## Options
+
+ - `:mergeable` - When `true`, indicates this fan-in's reducer has CRDT-like
+ properties (commutative, idempotent, associative) and is safe for parallel merge
+ without ordering guarantees. Defaults to `false`.
+
+ ## Runtime Context
+
+ FanIn supports `meta_refs` for `context/1` when used as part of a reduce
+ operation. The `has_meta_refs?/1` function indicates whether context
+ references are present.
+ """
+ defstruct [:hash, :init, :reducer, :map, :name, mergeable: false, meta_refs: []]
+
+ @doc """
+ Returns whether this FanIn has meta references (e.g., `context/1`)
+ that need to be resolved during the prepare phase.
+ """
+ def has_meta_refs?(%__MODULE__{meta_refs: meta_refs}), do: meta_refs != []
+end
+
+defimpl Runic.Workflow.Coordinator, for: Runic.Workflow.FanIn do
+ alias Runic.Workflow
+ alias Runic.Workflow.Fact
+ alias Runic.Workflow.Runnable
+ alias Runic.Workflow.Private
+ alias Runic.Workflow.Events.ActivationConsumed
+ alias Runic.Workflow.Events.FanInCompleted
+
+ def finalize(
+ %Runic.Workflow.FanIn{} = fan_in,
+ %Workflow{} = wf,
+ %Runnable{
+ input_fact: fact,
+ context: %{fan_in_context: %{mode: :fan_out_reduce} = fctx} = ctx
+ }
+ ) do
+ completed_key = {:fan_in_completed, fctx.source_fact_hash, fan_in.hash}
+ already_completed = Map.get(wf.mapped, completed_key, false)
+
+ if already_completed do
+ {wf, []}
+ else
+ expected_list = wf.mapped[fctx.expected_key] || []
+ expected_set = MapSet.new(expected_list)
+ seen_map = wf.mapped[fctx.seen_key] || %{}
+ seen_set = MapSet.new(Map.keys(seen_map))
+
+ ready =
+ not Enum.empty?(expected_set) and
+ MapSet.equal?(expected_set, seen_set)
+
+ if ready do
+ expected_in_order = Enum.reverse(expected_list)
+
+ sister_fact_values =
+ for origin <- expected_in_order do
+ sister_hash = seen_map[origin]
+ wf.graph.vertices[sister_hash].value
+ end
+
+ reduced_value = fan_in_reduce(sister_fact_values, fan_in.init.(), fan_in.reducer)
+ reduced_fact = Fact.new(value: reduced_value, ancestry: {fan_in.hash, fact.hash})
+
+ wf = Workflow.run_before_hooks(wf, fan_in, fact)
+
+ sister_consumed_events =
+ for origin <- expected_in_order,
+ sister_hash = seen_map[origin],
+ sister_fact = wf.graph.vertices[sister_hash],
+ sister_fact != nil,
+ sister_fact.hash != fact.hash do
+ %ActivationConsumed{
+ fact_hash: sister_fact.hash,
+ node_hash: fan_in.hash,
+ from_label: :runnable
+ }
+ end
+
+ completion_event = %FanInCompleted{
+ fan_in_hash: fan_in.hash,
+ source_fact_hash: fctx.source_fact_hash,
+ result_fact_hash: reduced_fact.hash,
+ result_value: reduced_fact.value,
+ result_ancestry: reduced_fact.ancestry,
+ expected_key: fctx.expected_key,
+ seen_key: fctx.seen_key,
+ weight: ctx.ancestry_depth + 1
+ }
+
+ derived_events = sister_consumed_events ++ [completion_event]
+
+ wf = Enum.reduce(derived_events, wf, fn event, w -> Workflow.apply_event(w, event) end)
+ wf = Workflow.run_after_hooks(wf, fan_in, reduced_fact)
+
+ # Activate downstream nodes with the reduced fact
+ next = Workflow.next_steps(wf, fan_in)
+
+ wf =
+ Enum.reduce(next, wf, fn step, w ->
+ Workflow.draw_connection(
+ w,
+ reduced_fact,
+ step,
+ Private.connection_for_activatable(step)
+ )
+ end)
+
+ {wf, derived_events}
+ else
+ {wf, []}
+ end
+ end
+ end
+
+ # FanIn without fan_out_reduce context — no coordination needed
+ def finalize(%Runic.Workflow.FanIn{}, %Workflow{} = wf, %Runnable{}) do
+ {wf, []}
+ end
+
+ defp fan_in_reduce(enumerable, acc, reducer) do
+ Enum.reduce_while(enumerable, acc, fn value, acc ->
+ case reducer.(value, acc) do
+ {:cont, new_acc} -> {:cont, new_acc}
+ {:halt, new_acc} -> {:halt, new_acc}
+ new_acc -> {:cont, new_acc}
+ end
+ end)
+ end
+end
diff --git a/vendor/runic/lib/workflow/fan_out.ex b/vendor/runic/lib/workflow/fan_out.ex
new file mode 100644
index 0000000..57051fc
--- /dev/null
+++ b/vendor/runic/lib/workflow/fan_out.ex
@@ -0,0 +1,41 @@
+defmodule Runic.Workflow.FanOut do
+ @moduledoc """
+ FanOut steps are part of a map operator that expands enumerable facts into separate facts.
+
+ FanOut just splits input facts - separate steps as defined in the map expression will do the processing.
+ """
+ defstruct [:hash, :name]
+end
+
+defimpl Runic.Workflow.Activator, for: Runic.Workflow.FanOut do
+ alias Runic.Workflow
+ alias Runic.Workflow.Runnable
+ alias Runic.Workflow.Private
+ alias Runic.Workflow.Events.RunnableActivated
+
+ def activate_downstream(%Runic.Workflow.FanOut{} = fan_out, %Workflow{} = wf, %Runnable{
+ result: emitted_facts
+ })
+ when is_list(emitted_facts) do
+ next = Workflow.next_steps(wf, fan_out)
+
+ {wf, all_events} =
+ Enum.reduce(emitted_facts, {wf, []}, fn fact, {w, events_acc} ->
+ new_events =
+ Enum.map(next, fn step ->
+ %RunnableActivated{
+ fact_hash: fact.hash,
+ node_hash: step.hash,
+ activation_kind: Private.connection_for_activatable(step)
+ }
+ end)
+
+ w = Enum.reduce(new_events, w, fn event, w2 -> Workflow.apply_event(w2, event) end)
+ {w, events_acc ++ new_events}
+ end)
+
+ {wf, all_events}
+ end
+
+ def activate_downstream(%Runic.Workflow.FanOut{}, %Workflow{} = wf, %Runnable{}), do: {wf, []}
+end
diff --git a/vendor/runic/lib/workflow/fsm.ex b/vendor/runic/lib/workflow/fsm.ex
new file mode 100644
index 0000000..28adda1
--- /dev/null
+++ b/vendor/runic/lib/workflow/fsm.ex
@@ -0,0 +1,118 @@
+defmodule Runic.Workflow.FSM do
+ @moduledoc """
+ A finite state machine component with discrete named states and event-driven transitions.
+
+ An FSM models a system that is always in exactly one of a finite set of states.
+ Transitions between states are triggered by named events and may have entry
+ actions that fire when a state is entered.
+
+ ## How It Works
+
+ At compile time an FSM is lowered into standard Runic primitives:
+
+ - An **Accumulator** that holds the current state as an atom value, initialized
+ to the declared `initial_state`.
+ - One **Rule** per transition. Each rule uses `state_of()` meta-references to
+ gate on the current state and matches against the incoming event. The rule's
+ reaction produces the target state atom which feeds back into the accumulator.
+ - One **Rule** per `on_entry` action. Entry rules fire when the accumulator
+ transitions into the associated state.
+
+ Each transition rule is named `:"__on_"`, making
+ individual transitions addressable by name.
+
+ Compile-time validation ensures:
+ - The `initial_state` refers to a declared state
+ - All transition `:to` targets refer to declared states
+ - No duplicate `{state, event}` transition pairs exist
+
+ The content hash is deterministic — identical FSM definitions produce the
+ same hash regardless of compilation order.
+
+ ## DSL Syntax
+
+ FSMs are created with the `Runic.fsm/2` macro using a block DSL:
+
+ Runic.fsm name: :name do
+ initial_state :state_name
+
+ state :state_name do
+ on :event_name, to: :target_state
+ on_entry fn -> side_effect_value end
+ end
+ end
+
+ ### Directives
+
+ - `initial_state :atom` — declares the starting state (required, must match
+ a declared state).
+ - `state :name do ... end` — declares a state with its transitions and
+ optional entry action.
+ - `on :event, to: :target` — declares a transition from the enclosing state
+ to `:target` when `:event` is received.
+ - `on_entry fn -> value end` — declares a side-effect function that fires
+ when this state is entered.
+
+ ## Examples
+
+ require Runic
+
+ # Traffic light FSM
+ fsm = Runic.fsm name: :traffic_light do
+ initial_state :red
+
+ state :red do
+ on :timer, to: :green
+ on :emergency, to: :red
+ on_entry fn -> {:notify, :traffic_stopped} end
+ end
+
+ state :green do
+ on :timer, to: :yellow
+ on :emergency, to: :red
+ end
+
+ state :yellow do
+ on :timer, to: :red
+ on :emergency, to: :red
+ end
+ end
+
+ # Add to workflow and run
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(fsm)
+ wrk = Workflow.react(wrk, :timer)
+ # Current state transitions from :red -> :green
+
+ ## Sub-Component Access
+
+ After adding an FSM to a workflow, its internal primitives can be retrieved
+ via `Workflow.get_component/2` using a `{name, kind}` tuple:
+
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(fsm)
+
+ # Get the underlying accumulator (holds current state atom)
+ [accumulator] = Workflow.get_component(wrk, {:traffic_light, :accumulator})
+
+ # Get all transition rules
+ transitions = Workflow.get_component(wrk, {:traffic_light, :transition})
+ """
+
+ defstruct [
+ :name,
+ :initial_state,
+ :states,
+ :accumulator,
+ :transition_rules,
+ :entry_rules,
+ :workflow,
+ :source,
+ :hash,
+ :bindings,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/hook_event.ex b/vendor/runic/lib/workflow/hook_event.ex
new file mode 100644
index 0000000..f9b688c
--- /dev/null
+++ b/vendor/runic/lib/workflow/hook_event.ex
@@ -0,0 +1,71 @@
+defmodule Runic.Workflow.HookEvent do
+ @moduledoc """
+ Event struct passed to hooks during execution.
+
+ This provides a uniform interface for hooks across all node types,
+ eliminating the need for workflow access during the execute phase.
+
+ ## Fields
+
+ - `:timing` - `:before` or `:after` indicating when the hook is running
+ - `:node` - The node struct being executed (Step, Condition, etc.)
+ - `:node_hash` - Hash of the node for quick identification
+ - `:input_fact` - The input fact triggering this execution
+ - `:result` - For after hooks: the result of execution (Fact, boolean, etc.)
+
+ ## Example
+
+ # New-style hook (arity-2, workflow-free)
+ fn %HookEvent{timing: :before, node: step}, ctx ->
+ Logger.info("Executing step \#{step.name}")
+ :ok
+ end
+
+ # Hook returning an apply_fn for workflow modifications
+ fn %HookEvent{timing: :after, result: fact}, _ctx ->
+ {:apply, fn workflow ->
+ Workflow.add(workflow, some_step, to: fact)
+ end}
+ end
+ """
+
+ @type timing :: :before | :after
+
+ @type t :: %__MODULE__{
+ timing: timing(),
+ node: struct(),
+ node_hash: integer(),
+ input_fact: Runic.Workflow.Fact.t(),
+ result: term() | nil
+ }
+
+ defstruct [:timing, :node, :node_hash, :input_fact, :result]
+
+ @doc """
+ Creates a before-hook event.
+ """
+ @spec before(struct(), Runic.Workflow.Fact.t()) :: t()
+ def before(node, input_fact) do
+ %__MODULE__{
+ timing: :before,
+ node: node,
+ node_hash: node.hash,
+ input_fact: input_fact,
+ result: nil
+ }
+ end
+
+ @doc """
+ Creates an after-hook event with the execution result.
+ """
+ @spec after_exec(struct(), Runic.Workflow.Fact.t(), term()) :: t()
+ def after_exec(node, input_fact, result) do
+ %__MODULE__{
+ timing: :after,
+ node: node,
+ node_hash: node.hash,
+ input_fact: input_fact,
+ result: result
+ }
+ end
+end
diff --git a/vendor/runic/lib/workflow/hook_runner.ex b/vendor/runic/lib/workflow/hook_runner.ex
new file mode 100644
index 0000000..01a7b57
--- /dev/null
+++ b/vendor/runic/lib/workflow/hook_runner.ex
@@ -0,0 +1,107 @@
+defmodule Runic.Workflow.HookRunner do
+ @moduledoc """
+ Runs hooks during the execute phase without requiring workflow access.
+
+ This module provides a safe, parallel-friendly way to execute hooks.
+ Hooks can be either:
+
+ 1. **New-style (arity-2)**: `fn event, context -> :ok | {:apply, fn} | {:error, term} end`
+ - Executed during the execute phase
+ - Can return `:ok` or an `apply_fn` for deferred workflow modifications
+
+ 2. **Legacy (arity-3)**: `fn step, workflow, fact -> workflow end`
+ - Converted to apply_fn for backward compatibility
+ - NOT executed during execute phase (requires workflow)
+
+ ## Return Types
+
+ Hooks can return:
+ - `:ok` - Hook completed successfully, no workflow modifications
+ - `{:apply, apply_fn}` - Hook completed, apply_fn will be called during apply phase
+ - `{:apply, [apply_fn]}` - Multiple apply functions
+ - `{:error, reason}` - Hook failed, will cause runnable to fail
+ """
+
+ alias Runic.Workflow.{HookEvent, CausalContext}
+
+ @type apply_fn :: (Runic.Workflow.t() -> Runic.Workflow.t())
+ @type hook_return :: :ok | {:apply, apply_fn()} | {:apply, [apply_fn()]} | {:error, term()}
+ @type new_hook :: (HookEvent.t(), CausalContext.t() -> hook_return())
+ @type legacy_hook :: (struct(), Runic.Workflow.t(), Runic.Workflow.Fact.t() ->
+ Runic.Workflow.t())
+ @type hook :: new_hook() | legacy_hook()
+
+ @doc """
+ Runs before hooks and collects any apply_fns.
+
+ Returns `{:ok, apply_fns}` or `{:error, reason}`.
+ """
+ @spec run_before(CausalContext.t(), struct(), Runic.Workflow.Fact.t()) ::
+ {:ok, [apply_fn()]} | {:error, term()}
+ def run_before(%CausalContext{} = ctx, node, input_fact) do
+ hooks = CausalContext.before_hooks(ctx)
+ event = HookEvent.before(node, input_fact)
+ run_hooks(hooks, event, ctx, node, input_fact)
+ end
+
+ @doc """
+ Runs after hooks with the execution result and collects any apply_fns.
+
+ Returns `{:ok, apply_fns}` or `{:error, reason}`.
+ """
+ @spec run_after(CausalContext.t(), struct(), Runic.Workflow.Fact.t(), term()) ::
+ {:ok, [apply_fn()]} | {:error, term()}
+ def run_after(%CausalContext{} = ctx, node, input_fact, result) do
+ hooks = CausalContext.after_hooks(ctx)
+ event = HookEvent.after_exec(node, input_fact, result)
+ run_hooks(hooks, event, ctx, node, input_fact)
+ end
+
+ defp run_hooks(hooks, event, ctx, node, input_fact) do
+ Enum.reduce_while(hooks, {:ok, []}, fn hook, {:ok, apply_fns} ->
+ case run_single_hook(hook, event, ctx, node, input_fact) do
+ {:ok, new_apply_fns} ->
+ {:cont, {:ok, apply_fns ++ new_apply_fns}}
+
+ {:error, reason} ->
+ {:halt, {:error, reason}}
+ end
+ end)
+ end
+
+ defp run_single_hook(hook, event, ctx, _node, _input_fact) when is_function(hook, 2) do
+ try do
+ case hook.(event, ctx) do
+ :ok ->
+ {:ok, []}
+
+ {:apply, apply_fn} when is_function(apply_fn, 1) ->
+ {:ok, [apply_fn]}
+
+ {:apply, apply_fns} when is_list(apply_fns) ->
+ {:ok, apply_fns}
+
+ {:error, reason} ->
+ {:error, {:hook_error, reason}}
+
+ other ->
+ {:error, {:invalid_hook_return, other}}
+ end
+ rescue
+ e ->
+ {:error, {:hook_exception, e, __STACKTRACE__}}
+ end
+ end
+
+ defp run_single_hook(hook, _event, _ctx, node, input_fact) when is_function(hook, 3) do
+ apply_fn = fn workflow ->
+ hook.(node, workflow, input_fact)
+ end
+
+ {:ok, [apply_fn]}
+ end
+
+ defp run_single_hook(_hook, _event, _ctx, _node, _input_fact) do
+ {:error, :invalid_hook_arity}
+ end
+end
diff --git a/vendor/runic/lib/workflow/inspect.ex b/vendor/runic/lib/workflow/inspect.ex
new file mode 100644
index 0000000..7f39338
--- /dev/null
+++ b/vendor/runic/lib/workflow/inspect.ex
@@ -0,0 +1,59 @@
+# # defimpl Inspect, for: Runic.Workflow.Step do
+# # import Inspect.Algebra
+
+# # def inspect(step, _opts) do
+# # source =
+# # step.source
+# # |> Macro.to_string()
+# # |> String.replace_trailing(")", ", hash: #{step.hash})")
+
+# # concat([
+# # source
+# # ])
+# # end
+# # end
+
+# defimpl Inspect, for: Runic.Workflow.ComponentAdded do
+# import Inspect.Algebra
+
+# def inspect(event, _opts) do
+# concat([
+# "%Runic.Workflow.ComponentAdded{",
+# "source: ",
+# Macro.to_string(event.source),
+# ", to: ",
+# Macro.to_string(event.to),
+# "}"
+# ])
+# end
+# end
+
+# defimpl Inspect, for: Runic.Workflow.Map do
+# import Inspect.Algebra
+
+# def inspect(map, _opts) do
+# source =
+# map.source
+# |> Macro.to_string()
+# |> String.replace_trailing(")", ", hash: #{map.hash})")
+
+# concat([
+# source
+# ])
+# end
+# end
+
+# defimpl Inspect, for: Runic.Workflow.Reduce do
+# import Inspect.Algebra
+
+# def inspect(reduce, _opts) do
+# source =
+# reduce.source
+# |> Macro.to_string()
+# |> String.replace_trailing(")", ", hash: #{reduce.hash})")
+
+# concat([
+# source
+# ])
+# end
+# end
diff --git a/vendor/runic/lib/workflow/invokable.ex b/vendor/runic/lib/workflow/invokable.ex
new file mode 100644
index 0000000..0fa4b78
--- /dev/null
+++ b/vendor/runic/lib/workflow/invokable.ex
@@ -0,0 +1,1736 @@
+defprotocol Runic.Workflow.Invokable do
+ @moduledoc """
+ Protocol defining how workflow nodes execute within a workflow context.
+
+ The `Invokable` protocol is the runtime heart of Runic. It defines how each node type
+ (Step, Condition, Rule, Accumulator, etc.) executes within the context of a workflow,
+ enabling extension of Runic's runtime with nodes that have different execution properties
+ and evaluation semantics.
+
+ ## Three-Phase Execution Model
+
+ All workflow execution uses a three-phase model that enables parallel execution
+ and external scheduler integration:
+
+ ```
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+ │ PREPARE │ ───► │ EXECUTE │ ───► │ APPLY │
+ │ (Phase 1) │ │ (Phase 2) │ │ (Phase 3) │
+ └─────────────┘ └─────────────┘ └─────────────┘
+ │ │ │
+ ▼ ▼ ▼
+ Extract context Run work fn Reduce results
+ from workflow in isolation into workflow
+ → %Runnable{} (parallelizable) (sequential)
+ ```
+
+ 1. **Prepare** (`prepare/3`) - Extract minimal context from workflow into a `%Runnable{}` struct
+ 2. **Execute** (`execute/2`) - Run the node's work function in isolation (can be parallelized)
+ 3. **Apply** - Events on the Runnable are folded back into the workflow via `apply_event/2`
+
+ This separation enables:
+
+ - **Parallel execution** of independent nodes (Phase 2 has no workflow access)
+ - **External scheduler integration** via `prepare_for_dispatch/1` and `apply_runnable/2`
+ - **Distributed execution** by dispatching Runnables to remote workers (events are serializable)
+ - **Separation of concerns** between pure computation and stateful workflow updates
+
+ ## Protocol Functions
+
+ | Function | Purpose |
+ |----------|---------|
+ | `match_or_execute/1` | Declares whether node is a `:match` (predicate) or `:execute` (produces facts) |
+ | `invoke/3` | High-level API that runs all three phases internally |
+ | `prepare/3` | Phase 1: Extract context from workflow, build a `%Runnable{}` |
+ | `execute/2` | Phase 2: Run the work function using only Runnable context |
+
+ ## Return Types
+
+ - `prepare/3` returns:
+ - `{:ok, %Runnable{}}` - Ready for execution
+ - `{:skip, (Workflow.t() -> Workflow.t())}` - Skip with reducer function
+ - `{:defer, (Workflow.t() -> Workflow.t())}` - Defer with reducer function
+
+ - `execute/2` returns a `%Runnable{}` with:
+ - `status: :completed` - With `result` and `events` populated
+ - `status: :failed` - With `error` populated
+ - `status: :skipped` - With `events` for skip handling
+
+ ## Built-in Implementations
+
+ Runic provides `Invokable` implementations for all core node types:
+
+ | Node Type | Match/Execute | Description |
+ |-----------|---------------|-------------|
+ | `Runic.Workflow.Root` | `:match` | Entry point for facts into the workflow |
+ | `Runic.Workflow.Condition` | `:match` | Boolean predicate check |
+ | `Runic.Workflow.Step` | `:execute` | Transform input fact to output fact |
+ | `Runic.Workflow.Conjunction` | `:match` | Logical AND of multiple conditions |
+ | `Runic.Workflow.Accumulator` | `:execute` | Stateful reducer across invocations |
+ | `Runic.Workflow.Join` | `:execute` | Wait for multiple parent facts before firing |
+ | `Runic.Workflow.FanOut` | `:execute` | Spread enumerable into parallel branches |
+ | `Runic.Workflow.FanIn` | `:execute` | Collect parallel results back together |
+
+ ## External Scheduler Integration
+
+ The three-phase model enables integration with custom schedulers:
+
+ # Phase 1: Prepare runnables for dispatch
+ workflow = Workflow.plan_eagerly(workflow, input)
+ {workflow, runnables} = Workflow.prepare_for_dispatch(workflow)
+
+ # Phase 2: Execute (dispatch to worker pool, external service, etc.)
+ executed = Task.async_stream(runnables, fn runnable ->
+ Runic.Workflow.Invokable.execute(runnable.node, runnable)
+ end, timeout: :infinity)
+
+ # Phase 3: Apply results back to workflow
+ workflow = Enum.reduce(executed, workflow, fn {:ok, runnable}, wrk ->
+ Workflow.apply_runnable(wrk, runnable)
+ end)
+
+ ## Implementing Custom Invokable
+
+ To create a custom node type, implement the protocol:
+
+ defmodule MyApp.CustomNode do
+ defstruct [:hash, :name, :work]
+ end
+
+ defimpl Runic.Workflow.Invokable, for: MyApp.CustomNode do
+ alias Runic.Workflow
+ alias Runic.Workflow.{Fact, Runnable, CausalContext}
+ alias Runic.Workflow.Events.{FactProduced, ActivationConsumed}
+
+ def match_or_execute(_node), do: :execute
+
+ def invoke(%MyApp.CustomNode{} = node, workflow, fact) do
+ result = node.work.(fact.value)
+ result_fact = Fact.new(value: result, ancestry: {node.hash, fact.hash})
+
+ workflow
+ |> Workflow.log_fact(result_fact)
+ |> Workflow.draw_connection(node, result_fact, :produced)
+ |> Workflow.mark_runnable_as_ran(node, fact)
+ |> Workflow.prepare_next_runnables(node, result_fact)
+ end
+
+ def prepare(%MyApp.CustomNode{} = node, workflow, fact) do
+ context = CausalContext.new(
+ node_hash: node.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact)
+ )
+
+ {:ok, Runnable.new(node, fact, context)}
+ end
+
+ def execute(%MyApp.CustomNode{} = node, %Runnable{input_fact: fact, context: ctx} = runnable) do
+ result = node.work.(fact.value)
+ result_fact = Fact.new(value: result, ancestry: {node.hash, fact.hash})
+
+ events = [
+ %FactProduced{
+ hash: result_fact.hash,
+ value: result_fact.value,
+ ancestry: result_fact.ancestry,
+ producer_label: :produced,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: node.hash,
+ from_label: :runnable
+ }
+ ]
+
+ Runnable.complete(runnable, result_fact, events)
+ end
+ end
+
+ See the [Protocols Guide](protocols.html) for more details and examples.
+ """
+
+ alias Runic.Workflow.Runnable
+
+ @doc """
+ Returns whether this node is a match (predicate/gate) or execute (produces facts) node.
+ """
+ @spec match_or_execute(node :: struct()) :: :match | :execute
+ def match_or_execute(node)
+
+ @doc """
+ Legacy invoke function - activates a node in context of a workflow and input fact.
+ Returns a new workflow with the node's effects applied.
+
+ During migration, this may delegate to the three-phase prepare/execute/apply cycle.
+ """
+ @spec invoke(node :: struct(), workflow :: Runic.Workflow.t(), fact :: Runic.Workflow.Fact.t()) ::
+ Runic.Workflow.t()
+ def invoke(node, workflow, fact)
+
+ @doc """
+ Phase 1: Prepare a runnable for execution.
+
+ Extracts minimal context from the workflow needed to execute this node.
+ Returns a `%Runnable{}` struct that can be executed independently of the workflow.
+
+ ## Returns
+
+ - `{:ok, %Runnable{}}` - Node is ready for execution
+ - `{:skip, reducer_fn}` - Node should be skipped, apply reducer to workflow
+ - `{:defer, reducer_fn}` - Node should be deferred, apply reducer to workflow
+ """
+ @spec prepare(
+ node :: struct(),
+ workflow :: Runic.Workflow.t(),
+ fact :: Runic.Workflow.Fact.t()
+ ) ::
+ {:ok, Runnable.t()}
+ | {:skip, (Runic.Workflow.t() -> Runic.Workflow.t())}
+ | {:defer, (Runic.Workflow.t() -> Runic.Workflow.t())}
+ def prepare(node, workflow, fact)
+
+ @doc """
+ Phase 2: Execute a prepared runnable.
+
+ Runs the node's work function using only the context captured in the Runnable.
+ No workflow access is allowed during this phase (enables parallelization).
+
+ Returns the Runnable with:
+ - `status` updated to `:completed`, `:failed`, or `:skipped`
+ - `result` populated with the execution result
+ - `events` populated with event structs to fold into the workflow
+ """
+ @spec execute(node :: struct(), runnable :: Runnable.t()) :: Runnable.t()
+ def execute(node, runnable)
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.Root do
+ alias Runic.Workflow.Root
+ alias Runic.Workflow.Fact
+ alias Runic.Workflow
+ alias Runic.Workflow.{Runnable, CausalContext}
+ alias Runic.Workflow.Events.FactProduced
+
+ def match_or_execute(_root), do: :match
+
+ def invoke(%Root{} = root, workflow, fact) do
+ workflow
+ |> Workflow.log_fact(fact)
+ |> Workflow.prepare_next_runnables(root, fact)
+ end
+
+ def prepare(%Root{} = root, _workflow, %Fact{} = fact) do
+ context =
+ CausalContext.new(
+ node_hash: nil,
+ input_fact: fact,
+ ancestry_depth: 0
+ )
+
+ {:ok, Runnable.new(nil, root, fact, context)}
+ end
+
+ def execute(%Root{} = _root, %Runnable{input_fact: fact} = runnable) do
+ events = [
+ %FactProduced{
+ hash: fact.hash,
+ value: fact.value,
+ ancestry: fact.ancestry,
+ producer_label: :input,
+ weight: 0
+ }
+ ]
+
+ Runnable.complete(runnable, fact, events)
+ end
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.Condition do
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ Condition,
+ Runnable,
+ CausalContext,
+ HookRunner
+ }
+
+ alias Runic.Workflow.Events.{ConditionSatisfied, ActivationConsumed}
+
+ def match_or_execute(_condition), do: :match
+
+ def invoke(
+ %Condition{} = condition,
+ %Workflow{} = workflow,
+ %Fact{} = fact
+ ) do
+ if Condition.check(condition, fact) do
+ workflow
+ |> Workflow.prepare_next_runnables(condition, fact)
+ |> Workflow.draw_connection(fact, condition, :satisfied)
+ |> Workflow.mark_runnable_as_ran(condition, fact)
+ |> Workflow.run_after_hooks(condition, fact)
+ else
+ workflow
+ |> Workflow.mark_runnable_as_ran(condition, fact)
+ |> Workflow.run_after_hooks(condition, fact)
+ end
+ end
+
+ def prepare(%Condition{} = condition, %Workflow{} = workflow, %Fact{} = fact) do
+ meta_context =
+ if Condition.has_meta_refs?(condition) do
+ Workflow.prepare_meta_context(workflow, condition)
+ else
+ %{}
+ end
+
+ run_context = Workflow.get_run_context(workflow, condition.name)
+
+ context =
+ CausalContext.new(
+ node_hash: condition.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ hooks: Workflow.get_hooks(workflow, condition.hash),
+ meta_context: meta_context,
+ run_context: run_context
+ )
+
+ {:ok, Runnable.new(condition, fact, context)}
+ end
+
+ def execute(%Condition{} = condition, %Runnable{input_fact: fact, context: ctx} = runnable) do
+ with {:ok, before_apply_fns} <- HookRunner.run_before(ctx, condition, fact) do
+ effective_context = merge_effective_context(ctx.meta_context, ctx.run_context)
+
+ satisfied =
+ if effective_context != %{} and condition.arity == 2 do
+ Condition.check_with_meta_context(condition, fact, effective_context)
+ else
+ Condition.check(condition, fact)
+ end
+
+ case HookRunner.run_after(ctx, condition, fact, satisfied) do
+ {:ok, after_apply_fns} ->
+ events =
+ if satisfied do
+ [
+ %ConditionSatisfied{
+ fact_hash: fact.hash,
+ condition_hash: condition.hash,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: condition.hash,
+ from_label: :matchable
+ }
+ ]
+ else
+ [
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: condition.hash,
+ from_label: :matchable
+ }
+ ]
+ end
+
+ hook_fns = before_apply_fns ++ after_apply_fns
+ Runnable.complete(runnable, satisfied, events, hook_fns)
+
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ else
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ end
+
+ defp merge_effective_context(meta, run) when map_size(meta) == 0 and map_size(run) == 0, do: %{}
+ defp merge_effective_context(meta, run) when map_size(run) == 0, do: meta
+ defp merge_effective_context(meta, run) when map_size(meta) == 0, do: run
+ defp merge_effective_context(meta, run), do: Map.merge(run, meta)
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.Step do
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ Step,
+ Components,
+ Runnable,
+ CausalContext,
+ HookRunner
+ }
+
+ alias Runic.Workflow.Events.{FactProduced, ActivationConsumed, MapReduceTracked}
+
+ def match_or_execute(_step), do: :execute
+
+ def invoke(
+ %Step{} = step,
+ %Workflow{} = workflow,
+ %Fact{} = fact
+ ) do
+ result = Components.run(step.work, fact.value, Components.arity_of(step.work))
+
+ result_fact = Fact.new(value: result, ancestry: {step.hash, fact.hash}, meta: fact.meta)
+
+ causal_depth = Workflow.ancestry_depth(workflow, fact) + 1
+
+ workflow
+ |> Workflow.log_fact(result_fact)
+ |> Workflow.draw_connection(step, result_fact, :produced, weight: causal_depth)
+ |> Workflow.mark_runnable_as_ran(step, fact)
+ |> Workflow.run_after_hooks(step, result_fact)
+ |> Workflow.prepare_next_runnables(step, result_fact)
+ |> maybe_prepare_map_reduce(step, result_fact)
+ end
+
+ def prepare(%Step{} = step, %Workflow{} = workflow, %Fact{} = fact) do
+ fan_out_context = build_fan_out_context(workflow, step, fact)
+
+ meta_context =
+ if Step.has_meta_refs?(step) do
+ Workflow.prepare_meta_context(workflow, step)
+ else
+ %{}
+ end
+
+ run_context = Workflow.get_run_context(workflow, step.name)
+
+ context =
+ CausalContext.new(
+ node_hash: step.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ hooks: Workflow.get_hooks(workflow, step.hash),
+ fan_out_context: fan_out_context,
+ meta_context: meta_context,
+ run_context: run_context
+ )
+
+ {:ok, Runnable.new(step, fact, context)}
+ end
+
+ def execute(%Step{} = step, %Runnable{input_fact: fact, context: ctx} = runnable) do
+ with {:ok, before_apply_fns} <- HookRunner.run_before(ctx, step, fact) do
+ try do
+ result = run_step_work(step, fact.value, ctx)
+
+ result_fact = Fact.new(value: result, ancestry: {step.hash, fact.hash}, meta: fact.meta)
+
+ case HookRunner.run_after(ctx, step, fact, result_fact) do
+ {:ok, after_apply_fns} ->
+ events = build_events(step, fact, result_fact, ctx)
+ hook_fns = before_apply_fns ++ after_apply_fns
+ Runnable.complete(runnable, result_fact, events, hook_fns)
+
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ rescue
+ e ->
+ Runnable.fail(runnable, e)
+ end
+ else
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ end
+
+ defp run_step_work(step, input, ctx) do
+ effective_context = merge_effective_context(ctx.meta_context, ctx.run_context)
+ arity = Components.arity_of(step.work)
+
+ cond do
+ effective_context != %{} and arity >= 2 ->
+ step.work.(input, effective_context)
+
+ Step.has_meta_refs?(step) and ctx.meta_context != %{} ->
+ Step.run_with_meta_context(step, input, ctx.meta_context)
+
+ true ->
+ Components.run(step.work, input, arity)
+ end
+ end
+
+ defp merge_effective_context(meta, run) when map_size(meta) == 0 and map_size(run) == 0, do: %{}
+ defp merge_effective_context(meta, run) when map_size(run) == 0, do: meta
+ defp merge_effective_context(meta, run) when map_size(meta) == 0, do: run
+ defp merge_effective_context(meta, run), do: Map.merge(run, meta)
+
+ defp build_events(step, input_fact, result_fact, ctx) do
+ events = [
+ %FactProduced{
+ hash: result_fact.hash,
+ value: result_fact.value,
+ ancestry: result_fact.ancestry,
+ producer_label: :produced,
+ weight: ctx.ancestry_depth + 1,
+ meta: result_fact.meta
+ },
+ %ActivationConsumed{
+ fact_hash: input_fact.hash,
+ node_hash: step.hash,
+ from_label: :runnable
+ }
+ ]
+
+ case ctx.fan_out_context do
+ %{tracks: tracks} when is_list(tracks) and tracks != [] ->
+ events ++
+ Enum.map(tracks, fn %{source_fact_hash: sfh, fan_out_hash: foh, fan_out_fact_hash: fofh} ->
+ %MapReduceTracked{
+ source_fact_hash: sfh,
+ fan_out_hash: foh,
+ fan_out_fact_hash: fofh,
+ step_hash: step.hash,
+ result_fact_hash: result_fact.hash
+ }
+ end)
+
+ _ ->
+ events
+ end
+ end
+
+ defp build_fan_out_context(workflow, step, fact) do
+ if is_reduced_in_map?(workflow, step) do
+ case map_reduce_tracks(workflow, step.hash, fact) do
+ [] ->
+ nil
+
+ tracks ->
+ %{tracks: tracks}
+ end
+ else
+ nil
+ end
+ end
+
+ defp is_reduced_in_map?(workflow, step) do
+ workflow.mapped
+ |> Map.get(:mapped_path_fan_outs, %{})
+ |> Map.has_key?(step.hash)
+ end
+
+ defp maybe_prepare_map_reduce(workflow, step, fact) do
+ if is_reduced_in_map?(workflow, step) do
+ map_reduce_tracks(workflow, step.hash, fact)
+ |> Enum.reduce(workflow, fn %{
+ source_fact_hash: source_fact_hash,
+ fan_out_hash: fan_out_hash,
+ fan_out_fact_hash: fan_out_fact_hash
+ },
+ workflow_acc ->
+ seen_key = {fan_out_hash, source_fact_hash, step.hash}
+ seen = Map.get(workflow_acc.mapped, seen_key, %{})
+ seen = Map.put(seen, fan_out_fact_hash, fact.hash)
+
+ mapped =
+ workflow_acc.mapped
+ |> Map.put(seen_key, seen)
+ |> Map.put({:fan_out_for_batch, source_fact_hash}, fan_out_hash)
+
+ Map.put(workflow_acc, :mapped, mapped)
+ end)
+ else
+ workflow
+ end
+ end
+
+ defp map_reduce_tracks(workflow, step_hash, fact) do
+ workflow
+ |> relevant_fan_out_hashes(step_hash)
+ |> Enum.flat_map(fn fan_out_hash ->
+ case find_fan_out_info(workflow, fact, fan_out_hash) do
+ {source_fact_hash, ^fan_out_hash, fan_out_fact_hash} ->
+ [
+ %{
+ source_fact_hash: source_fact_hash,
+ fan_out_hash: fan_out_hash,
+ fan_out_fact_hash: fan_out_fact_hash
+ }
+ ]
+
+ nil ->
+ []
+ end
+ end)
+ end
+
+ defp relevant_fan_out_hashes(workflow, step_hash) do
+ workflow.mapped
+ |> Map.get(:mapped_path_fan_outs, %{})
+ |> Map.get(step_hash, MapSet.new())
+ |> MapSet.to_list()
+ end
+
+ @doc false
+ def fan_out_origin_fact_hash(workflow, %Runic.Workflow.Fact{
+ hash: fact_hash,
+ ancestry: {producer_hash, input_fact_hash}
+ }) do
+ case workflow.graph.vertices[producer_hash] do
+ %Runic.Workflow.FanOut{} ->
+ fact_hash
+
+ _ ->
+ # Walk up the ancestry chain to find the fact that was produced by a FanOut
+ find_fan_out_origin(workflow, input_fact_hash)
+ end
+ end
+
+ def fan_out_origin_fact_hash(_workflow, _fact), do: nil
+
+ defp find_fan_out_info(
+ workflow,
+ %Runic.Workflow.Fact{hash: fact_hash, ancestry: {producer_hash, input_fact_hash}},
+ target_fan_out_hash
+ ) do
+ case workflow.graph.vertices[producer_hash] do
+ %Runic.Workflow.FanOut{} = fan_out when fan_out.hash == target_fan_out_hash ->
+ {input_fact_hash, fan_out.hash, fact_hash}
+
+ _ ->
+ do_find_fan_out_info(workflow, input_fact_hash, target_fan_out_hash)
+ end
+ end
+
+ defp find_fan_out_info(_workflow, _fact, _target_fan_out_hash), do: nil
+
+ defp do_find_fan_out_info(_workflow, nil, _target_fan_out_hash), do: nil
+
+ defp do_find_fan_out_info(workflow, fact_hash, target_fan_out_hash) do
+ fact = workflow.graph.vertices[fact_hash]
+
+ case fact do
+ %Runic.Workflow.Fact{ancestry: {producer_hash, parent_fact_hash}} ->
+ producer = workflow.graph.vertices[producer_hash]
+
+ case producer do
+ %Runic.Workflow.FanOut{} = fan_out when fan_out.hash == target_fan_out_hash ->
+ {parent_fact_hash, fan_out.hash, fact_hash}
+
+ _ ->
+ do_find_fan_out_info(workflow, parent_fact_hash, target_fan_out_hash)
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp find_fan_out_origin(_workflow, nil), do: nil
+
+ defp find_fan_out_origin(workflow, fact_hash) do
+ fact = workflow.graph.vertices[fact_hash]
+
+ case fact do
+ %Runic.Workflow.Fact{ancestry: {producer_hash, parent_fact_hash}} ->
+ producer = workflow.graph.vertices[producer_hash]
+
+ case producer do
+ %Runic.Workflow.FanOut{} ->
+ # This fact was produced by a FanOut - this is the origin we want
+ fact_hash
+
+ _ ->
+ # Keep walking up the ancestry chain
+ find_fan_out_origin(workflow, parent_fact_hash)
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ # def runnable_connection(_step), do: :runnable
+ # def resolved_connection(_step), do: :ran
+ # def causal_connection(_step), do: :produced
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.Conjunction do
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ Conjunction,
+ Runnable,
+ CausalContext
+ }
+
+ alias Runic.Workflow.Events.{ConditionSatisfied, ActivationConsumed}
+
+ def match_or_execute(_conjunction), do: :match
+
+ def invoke(
+ %Conjunction{} = conj,
+ %Workflow{} = workflow,
+ %Fact{} = fact
+ ) do
+ satisfied_conditions = Workflow.satisfied_condition_hashes(workflow, fact)
+
+ causal_depth = Workflow.ancestry_depth(workflow, fact) + 1
+
+ if conj.hash not in satisfied_conditions and
+ Enum.all?(conj.condition_hashes, &(&1 in satisfied_conditions)) do
+ workflow
+ |> Workflow.prepare_next_runnables(conj, fact)
+ |> Workflow.draw_connection(fact, conj, :satisfied, weight: causal_depth)
+ |> Workflow.mark_runnable_as_ran(conj, fact)
+ else
+ Workflow.mark_runnable_as_ran(workflow, conj, fact)
+ end
+ end
+
+ def prepare(%Conjunction{} = conj, %Workflow{} = workflow, %Fact{} = fact) do
+ satisfied_conditions = Workflow.satisfied_condition_hashes(workflow, fact)
+
+ context =
+ CausalContext.new(
+ node_hash: conj.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ satisfied_conditions: MapSet.new(satisfied_conditions)
+ )
+
+ {:ok, Runnable.new(conj, fact, context)}
+ end
+
+ def execute(%Conjunction{} = conj, %Runnable{input_fact: fact, context: ctx} = runnable) do
+ satisfied_conditions = ctx.satisfied_conditions
+
+ all_satisfied =
+ conj.hash not in satisfied_conditions and
+ Enum.all?(conj.condition_hashes, &(&1 in satisfied_conditions))
+
+ events =
+ if all_satisfied do
+ [
+ %ConditionSatisfied{
+ fact_hash: fact.hash,
+ condition_hash: conj.hash,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: conj.hash,
+ from_label: :matchable
+ }
+ ]
+ else
+ [
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: conj.hash,
+ from_label: :matchable
+ }
+ ]
+ end
+
+ Runnable.complete(runnable, all_satisfied, events)
+ end
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.Accumulator do
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ Components,
+ Accumulator,
+ Runnable,
+ CausalContext,
+ HookRunner
+ }
+
+ alias Runic.Workflow.Events.{FactProduced, ActivationConsumed, StateInitiated}
+
+ def match_or_execute(_state_reactor), do: :execute
+
+ def invoke(
+ %Accumulator{} = acc,
+ %Workflow{} = workflow,
+ %Fact{} = fact
+ ) do
+ last_known_state = last_known_state(workflow, acc)
+
+ causal_depth = Workflow.ancestry_depth(workflow, fact) + 1
+
+ unless is_nil(last_known_state) do
+ next_state = apply(acc.reducer, [fact.value, last_known_state.value])
+
+ next_state_produced_fact = Fact.new(value: next_state, ancestry: {acc.hash, fact.hash})
+
+ workflow
+ |> Workflow.log_fact(next_state_produced_fact)
+ |> Workflow.draw_connection(acc, fact, :state_produced, weight: causal_depth)
+ |> Workflow.mark_runnable_as_ran(acc, fact)
+ |> Workflow.run_after_hooks(acc, next_state_produced_fact)
+ |> Workflow.prepare_next_runnables(acc, next_state_produced_fact)
+ else
+ init_fact = init_fact(acc)
+
+ next_state = apply(acc.reducer, [fact.value, init_fact.value])
+
+ next_state_produced_fact = Fact.new(value: next_state, ancestry: {acc.hash, fact.hash})
+
+ workflow
+ |> Workflow.log_fact(init_fact)
+ |> Workflow.draw_connection(acc, init_fact, :state_initiated, weight: causal_depth)
+ |> Workflow.log_fact(next_state_produced_fact)
+ |> Workflow.draw_connection(acc, next_state_produced_fact, :state_produced,
+ weight: causal_depth
+ )
+ |> Workflow.mark_runnable_as_ran(acc, fact)
+ |> Workflow.run_after_hooks(acc, next_state_produced_fact)
+ |> Workflow.prepare_next_runnables(acc, next_state_produced_fact)
+ end
+ end
+
+ def prepare(%Accumulator{} = acc, %Workflow{} = workflow, %Fact{} = fact) do
+ last_state_fact = last_known_state(workflow, acc)
+
+ meta_context =
+ if Accumulator.has_meta_refs?(acc) do
+ Workflow.prepare_meta_context(workflow, acc)
+ else
+ %{}
+ end
+
+ run_context = Workflow.get_run_context(workflow, acc.name)
+
+ context =
+ CausalContext.new(
+ node_hash: acc.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ hooks: Workflow.get_hooks(workflow, acc.hash),
+ last_known_state: if(last_state_fact, do: last_state_fact.value, else: nil),
+ is_state_initialized: not is_nil(last_state_fact),
+ mergeable: acc.mergeable,
+ meta_context: meta_context,
+ run_context: run_context
+ )
+
+ {:ok, Runnable.new(acc, fact, context)}
+ end
+
+ def execute(%Accumulator{} = acc, %Runnable{input_fact: fact, context: ctx} = runnable) do
+ with {:ok, before_apply_fns} <- HookRunner.run_before(ctx, acc, fact) do
+ if ctx.is_state_initialized do
+ next_state = run_accumulator_reducer(acc, fact.value, ctx.last_known_state, ctx)
+ next_state_produced_fact = Fact.new(value: next_state, ancestry: {acc.hash, fact.hash})
+
+ case HookRunner.run_after(ctx, acc, fact, next_state_produced_fact) do
+ {:ok, after_apply_fns} ->
+ events = [
+ %FactProduced{
+ hash: next_state_produced_fact.hash,
+ value: next_state_produced_fact.value,
+ ancestry: next_state_produced_fact.ancestry,
+ producer_label: :state_produced,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: acc.hash,
+ from_label: :runnable
+ }
+ ]
+
+ hook_fns = before_apply_fns ++ after_apply_fns
+ Runnable.complete(runnable, next_state_produced_fact, events, hook_fns)
+
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ else
+ init_fact_val = acc.init.()
+
+ init_fact =
+ Fact.new(
+ value: init_fact_val,
+ ancestry: {acc.hash, Components.fact_hash(init_fact_val)}
+ )
+
+ next_state = run_accumulator_reducer(acc, fact.value, init_fact_val, ctx)
+ next_state_produced_fact = Fact.new(value: next_state, ancestry: {acc.hash, fact.hash})
+
+ case HookRunner.run_after(ctx, acc, fact, next_state_produced_fact) do
+ {:ok, after_apply_fns} ->
+ events = [
+ %StateInitiated{
+ accumulator_hash: acc.hash,
+ init_fact_hash: init_fact.hash,
+ init_value: init_fact.value,
+ init_ancestry: init_fact.ancestry,
+ weight: ctx.ancestry_depth + 1
+ },
+ %FactProduced{
+ hash: next_state_produced_fact.hash,
+ value: next_state_produced_fact.value,
+ ancestry: next_state_produced_fact.ancestry,
+ producer_label: :state_produced,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: acc.hash,
+ from_label: :runnable
+ }
+ ]
+
+ hook_fns = before_apply_fns ++ after_apply_fns
+ Runnable.complete(runnable, next_state_produced_fact, events, hook_fns)
+
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ end
+ else
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ end
+
+ defp last_known_state(workflow, accumulator) do
+ Workflow.latest_state_fact(workflow, accumulator)
+ end
+
+ defp init_fact(%Accumulator{init: init, hash: hash}) do
+ init = init.()
+ Fact.new(value: init, ancestry: {hash, Components.fact_hash(init)})
+ end
+
+ defp run_accumulator_reducer(acc, value, state, ctx) do
+ effective_context = merge_effective_context(ctx.meta_context, ctx.run_context)
+ arity = Function.info(acc.reducer, :arity) |> elem(1)
+
+ if effective_context != %{} and arity == 3 do
+ acc.reducer.(value, state, effective_context)
+ else
+ apply(acc.reducer, [value, state])
+ end
+ end
+
+ defp merge_effective_context(meta, run) when map_size(meta) == 0 and map_size(run) == 0, do: %{}
+ defp merge_effective_context(meta, run) when map_size(run) == 0, do: meta
+ defp merge_effective_context(meta, run) when map_size(meta) == 0, do: run
+ defp merge_effective_context(meta, run), do: Map.merge(run, meta)
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.Join do
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ Join,
+ Runnable,
+ CausalContext
+ }
+
+ alias Runic.Workflow.Events.{JoinFactReceived, ActivationConsumed}
+
+ def match_or_execute(_join), do: :execute
+
+ def invoke(
+ %Join{} = join,
+ %Workflow{} = workflow,
+ %Fact{ancestry: {_parent_hash, _value_hash}} = fact
+ ) do
+ causal_depth = Workflow.ancestry_depth(workflow, fact) + 1
+
+ workflow =
+ Workflow.draw_connection(workflow, fact, join, :joined, weight: causal_depth)
+
+ join_order_weights =
+ join.joins
+ |> Enum.with_index()
+ |> Map.new()
+
+ joined_edges =
+ workflow.graph
+ |> Graph.in_edges(join)
+ |> Enum.filter(&(&1.label == :joined))
+
+ possible_priors_by_parent =
+ joined_edges
+ |> Enum.reduce(%{}, fn edge, acc ->
+ parent_hash = elem(edge.v1.ancestry, 0)
+
+ if Map.has_key?(join_order_weights, parent_hash) and not Map.has_key?(acc, parent_hash) do
+ Map.put(acc, parent_hash, edge.v1)
+ else
+ acc
+ end
+ end)
+
+ possible_priors =
+ join.joins
+ |> Enum.map(&Map.get(possible_priors_by_parent, &1))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.map(& &1.value)
+
+ if Enum.count(join.joins) == Enum.count(possible_priors) do
+ join_bindings_fact = Fact.new(value: possible_priors, ancestry: {join.hash, fact.hash})
+
+ workflow =
+ workflow
+ |> Workflow.log_fact(join_bindings_fact)
+ |> Workflow.prepare_next_runnables(join, join_bindings_fact)
+
+ workflow.graph
+ |> Graph.in_edges(join)
+ |> Enum.filter(&(&1.label in [:runnable, :joined]))
+ |> Enum.reduce(workflow, fn
+ %{v1: v1, label: :runnable}, wrk ->
+ Workflow.mark_runnable_as_ran(wrk, join, v1)
+
+ %{v1: v1, v2: v2, label: :joined}, wrk ->
+ %Workflow{
+ wrk
+ | graph:
+ wrk.graph |> Graph.update_labelled_edge(v1, v2, :joined, label: :join_satisfied)
+ }
+ end)
+ |> Workflow.draw_connection(join, join_bindings_fact, :produced, weight: causal_depth)
+ |> Workflow.run_after_hooks(join, join_bindings_fact)
+ else
+ Workflow.mark_runnable_as_ran(workflow, join, fact)
+ end
+ end
+
+ def prepare(%Join{} = join, %Workflow{} = workflow, %Fact{} = fact) do
+ join_order_weights =
+ join.joins
+ |> Enum.with_index()
+ |> Map.new()
+
+ # Get current join state from graph
+ joined_edges =
+ workflow.graph
+ |> Graph.in_edges(join)
+ |> Enum.filter(&(&1.label == :joined))
+
+ # Collect satisfied facts by parent
+ satisfied_by_parent =
+ joined_edges
+ |> Enum.reduce(%{}, fn edge, acc ->
+ parent_hash = elem(edge.v1.ancestry, 0)
+
+ if Map.has_key?(join_order_weights, parent_hash) and not Map.has_key?(acc, parent_hash) do
+ Map.put(acc, parent_hash, edge.v1)
+ else
+ acc
+ end
+ end)
+
+ # Check if adding this fact would complete the join
+ current_fact_parent = elem(fact.ancestry, 0)
+
+ satisfied_with_current =
+ if Map.has_key?(join_order_weights, current_fact_parent) do
+ Map.put(satisfied_by_parent, current_fact_parent, fact)
+ else
+ satisfied_by_parent
+ end
+
+ would_complete = map_size(satisfied_with_current) >= length(join.joins)
+
+ # Collect values in order if would complete
+ collected_values =
+ if would_complete do
+ join.joins
+ |> Enum.map(&Map.get(satisfied_with_current, &1))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.map(& &1.value)
+ else
+ nil
+ end
+
+ context =
+ CausalContext.new(
+ node_hash: join.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ hooks: Workflow.get_hooks(workflow, join.hash),
+ join_context: %{
+ joins: join.joins,
+ satisfied: satisfied_by_parent,
+ would_complete: would_complete,
+ values: collected_values
+ }
+ )
+
+ {:ok, Runnable.new(join, fact, context)}
+ end
+
+ def execute(%Join{} = _join, %Runnable{input_fact: fact, context: ctx} = runnable) do
+ parent_hash = elem(fact.ancestry, 0)
+
+ events = [
+ %JoinFactReceived{
+ fact_hash: fact.hash,
+ join_hash: ctx.node_hash,
+ parent_hash: parent_hash,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: ctx.node_hash,
+ from_label: :runnable
+ }
+ ]
+
+ Runnable.complete(runnable, :waiting, events)
+ end
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.FanOut do
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ FanOut,
+ Runnable,
+ CausalContext
+ }
+
+ alias Runic.Workflow.Events.{FanOutFactEmitted, ActivationConsumed}
+
+ def match_or_execute(_fan_out), do: :execute
+
+ def invoke(
+ %FanOut{} = fan_out,
+ %Workflow{} = workflow,
+ %Fact{} = source_fact
+ ) do
+ unless is_nil(Enumerable.impl_for(source_fact.value)) do
+ is_reduced? = is_reduced?(workflow, fan_out)
+
+ causal_depth = Workflow.ancestry_depth(workflow, source_fact) + 1
+ items = Enum.to_list(source_fact.value)
+ items_total = length(items)
+
+ Enum.reduce(Enum.with_index(items), workflow, fn {value, index}, wrk ->
+ fact =
+ Fact.new(
+ value: value,
+ ancestry: {fan_out.hash, source_fact.hash},
+ meta: %{item_index: index, items_total: items_total, fan_out_hash: fan_out.hash}
+ )
+
+ wrk
+ |> Workflow.log_fact(fact)
+ |> Workflow.prepare_next_runnables(fan_out, fact)
+ |> Workflow.draw_connection(fan_out, fact, :fan_out, weight: causal_depth)
+ |> maybe_prepare_map_reduce(is_reduced?, fan_out, fact, source_fact)
+ end)
+ |> Workflow.mark_runnable_as_ran(fan_out, source_fact)
+ else
+ Workflow.mark_runnable_as_ran(workflow, fan_out, source_fact)
+ end
+ end
+
+ def prepare(%FanOut{} = fan_out, %Workflow{} = workflow, %Fact{} = fact) do
+ if is_nil(Enumerable.impl_for(fact.value)) do
+ {:skip, fn wf -> Workflow.mark_runnable_as_ran(wf, fan_out, fact) end}
+ else
+ is_reduced = is_reduced?(workflow, fan_out)
+
+ context =
+ CausalContext.new(
+ node_hash: fan_out.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ fan_out_context: %{
+ is_reduced: is_reduced,
+ source_fact_hash: fact.hash
+ }
+ )
+
+ {:ok, Runnable.new(fan_out, fact, context)}
+ end
+ end
+
+ def execute(%FanOut{} = fan_out, %Runnable{input_fact: source_fact, context: ctx} = runnable) do
+ items = Enum.to_list(source_fact.value)
+ items_total = length(items)
+
+ emitted_facts =
+ Enum.map(Enum.with_index(items), fn {value, index} ->
+ Fact.new(
+ value: value,
+ ancestry: {fan_out.hash, source_fact.hash},
+ meta: %{item_index: index, items_total: items_total, fan_out_hash: fan_out.hash}
+ )
+ end)
+
+ fan_out_events =
+ Enum.map(emitted_facts, fn fact ->
+ %FanOutFactEmitted{
+ fan_out_hash: fan_out.hash,
+ source_fact_hash: source_fact.hash,
+ emitted_fact_hash: fact.hash,
+ emitted_value: fact.value,
+ emitted_ancestry: fact.ancestry,
+ item_index: get_in(fact.meta, [:item_index]),
+ items_total: get_in(fact.meta, [:items_total]),
+ weight: ctx.ancestry_depth + 1
+ }
+ end)
+
+ events =
+ fan_out_events ++
+ [
+ %ActivationConsumed{
+ fact_hash: source_fact.hash,
+ node_hash: fan_out.hash,
+ from_label: :runnable
+ }
+ ]
+
+ Runnable.complete(runnable, emitted_facts, events)
+ end
+
+ defp maybe_prepare_map_reduce(workflow, true, fan_out, fan_out_fact, source_fact) do
+ key = {source_fact.hash, fan_out.hash}
+ sister_facts = workflow.mapped[key] || []
+
+ Map.put(workflow, :mapped, Map.put(workflow.mapped, key, [fan_out_fact.hash | sister_facts]))
+ end
+
+ defp maybe_prepare_map_reduce(workflow, false, _fan_out, _fan_out_fact, _source_fact) do
+ workflow
+ end
+
+ def is_reduced?(workflow, fan_out) do
+ Graph.out_edges(workflow.graph, fan_out) |> Enum.any?(&(&1.label == :fan_in))
+ end
+end
+
+defimpl Runic.Workflow.Invokable, for: Runic.Workflow.FanIn do
+ alias Runic.Workflow.FanOut
+ alias Runic.Workflow
+
+ alias Runic.Workflow.{
+ Fact,
+ FanIn,
+ Runnable,
+ CausalContext,
+ HookRunner
+ }
+
+ alias Runic.Workflow.Events.{FactProduced, ActivationConsumed}
+
+ def match_or_execute(_fan_in), do: :execute
+
+ def invoke(
+ %FanIn{} = fan_in,
+ %Workflow{} = workflow,
+ %Fact{ancestry: {parent_step_hash, _parent_fact_hash}} = fact
+ ) do
+ fan_out =
+ workflow.graph
+ |> Graph.in_edges(fan_in)
+ |> Enum.filter(&(&1.label == :fan_in))
+ |> List.first(%{})
+ |> Map.get(:v1)
+
+ causal_depth = Workflow.ancestry_depth(workflow, fact) + 1
+
+ case fan_out do
+ nil ->
+ # basic step w/ enumerable output -> fan_in
+ reduced_value = reduce_with_context(fact.value, fan_in.init.(), fan_in.reducer, %{})
+
+ reduced_fact =
+ Fact.new(value: reduced_value, ancestry: {fan_in.hash, fact.hash})
+
+ workflow
+ |> Workflow.log_fact(reduced_fact)
+ |> Workflow.draw_connection(fan_in, reduced_fact, :reduced, weight: causal_depth)
+ |> Workflow.run_after_hooks(fan_in, reduced_fact)
+ |> Workflow.prepare_next_runnables(fan_in, reduced_fact)
+ |> Workflow.mark_runnable_as_ran(fan_in, fact)
+
+ %FanOut{} ->
+ # Find the source_fact_hash that triggered the FanOut batch
+ # This is stable across workflow merges and re-planning
+ source_fact_hash = find_fan_out_source_fact_hash(workflow, fact, fan_out.hash)
+
+ # Check if this batch has already been reduced by looking for a :reduced edge
+ # This is more robust than checking mapped data which may not survive merges
+ already_completed? = has_reduced_output?(workflow, fan_in, source_fact_hash)
+
+ # Also check mapped data as a fallback
+ completed_key = {:fan_in_completed, source_fact_hash, fan_in.hash}
+ already_completed? = already_completed? or Map.get(workflow.mapped, completed_key, false)
+
+ # Use source_fact_hash based keys (stable across merges)
+ expected_key = {source_fact_hash, fan_out.hash}
+ seen_key = {fan_out.hash, source_fact_hash, parent_step_hash}
+
+ expected_list = workflow.mapped[expected_key] || []
+ expected_set = MapSet.new(expected_list)
+
+ seen_map = workflow.mapped[seen_key] || %{}
+ seen_set = MapSet.new(Map.keys(seen_map))
+
+ ready? =
+ not already_completed? and
+ not Enum.empty?(expected_set) and
+ MapSet.equal?(expected_set, seen_set)
+
+ # DEBUG: Uncomment to trace FanIn readiness issues
+ # IO.inspect(%{
+ # fan_in_map: fan_in.map,
+ # source_fact_hash: source_fact_hash,
+ # expected_list_size: length(expected_list),
+ # seen_map_size: map_size(seen_map),
+ # ready?: ready?,
+ # already_completed?: already_completed?,
+ # fact_hash: fact.hash
+ # }, label: "FanIn.invoke DEBUG")
+
+ if ready? do
+ # reduce in FanOut emission order (list was prepended, so reverse)
+ expected_in_order = Enum.reverse(expected_list)
+
+ sister_fact_values =
+ for origin <- expected_in_order do
+ sister_hash = seen_map[origin]
+ workflow.graph.vertices[sister_hash].value
+ end
+
+ reduced_value =
+ reduce_with_context(sister_fact_values, fan_in.init.(), fan_in.reducer, %{})
+
+ reduced_fact =
+ Fact.new(value: reduced_value, ancestry: {fan_in.hash, fact.hash})
+
+ sister_facts =
+ for origin <- expected_in_order do
+ sister_hash = seen_map[origin]
+ workflow.graph.vertices[sister_hash]
+ end
+
+ workflow =
+ Enum.reduce(sister_facts, workflow, fn sister_fact, wrk ->
+ Workflow.mark_runnable_as_ran(wrk, fan_in, sister_fact)
+ end)
+
+ workflow
+ |> Workflow.log_fact(reduced_fact)
+ |> Workflow.draw_connection(fan_in, reduced_fact, :reduced, weight: causal_depth)
+ |> Workflow.run_after_hooks(fan_in, reduced_fact)
+ |> Workflow.prepare_next_runnables(fan_in, reduced_fact)
+ |> Workflow.mark_runnable_as_ran(fan_in, reduced_fact)
+ |> mark_fan_in_completed(completed_key)
+ |> cleanup_mapped(expected_key, seen_key, source_fact_hash)
+ else
+ workflow
+ |> Workflow.mark_runnable_as_ran(fan_in, fact)
+ end
+ end
+ end
+
+ # Find the source_fact hash that triggered the FanOut
+ # This walks up the ancestry chain from the current fact to find the FanOut producer,
+ # then returns its parent (the source_fact that was input to the FanOut)
+ defp find_fan_out_source_fact_hash(
+ workflow,
+ %Fact{ancestry: {producer_hash, input_fact_hash}}
+ ) do
+ case workflow.graph.vertices[producer_hash] do
+ %FanOut{} ->
+ input_fact_hash
+
+ _ ->
+ do_find_fan_out_source(workflow, input_fact_hash)
+ end
+ end
+
+ defp find_fan_out_source_fact_hash(workflow, fact, target_fan_out_hash) do
+ case find_fan_out_info(workflow, fact, target_fan_out_hash) do
+ {source_fact_hash, ^target_fan_out_hash, _fan_out_fact_hash} ->
+ source_fact_hash
+
+ nil ->
+ find_fan_out_source_fact_hash(workflow, fact)
+ end
+ end
+
+ defp do_find_fan_out_source(_workflow, nil), do: nil
+
+ defp do_find_fan_out_source(workflow, fact_hash) do
+ fact = workflow.graph.vertices[fact_hash]
+
+ case fact do
+ %Fact{ancestry: {producer_hash, parent_fact_hash}} ->
+ producer = workflow.graph.vertices[producer_hash]
+
+ case producer do
+ %FanOut{} ->
+ # This fact was produced by a FanOut
+ # Return the parent_fact_hash which is the source_fact that triggered FanOut
+ parent_fact_hash
+
+ _ ->
+ # Keep walking up the ancestry chain
+ do_find_fan_out_source(workflow, parent_fact_hash)
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp find_fan_out_info(
+ workflow,
+ %Fact{hash: fact_hash, ancestry: {producer_hash, input_fact_hash}},
+ target_fan_out_hash
+ ) do
+ case workflow.graph.vertices[producer_hash] do
+ %FanOut{} = fan_out when fan_out.hash == target_fan_out_hash ->
+ {input_fact_hash, fan_out.hash, fact_hash}
+
+ _ ->
+ do_find_fan_out_info(workflow, input_fact_hash, target_fan_out_hash)
+ end
+ end
+
+ defp find_fan_out_info(_workflow, _fact, _target_fan_out_hash), do: nil
+
+ defp do_find_fan_out_info(_workflow, nil, _target_fan_out_hash), do: nil
+
+ defp do_find_fan_out_info(workflow, fact_hash, target_fan_out_hash) do
+ fact = workflow.graph.vertices[fact_hash]
+
+ case fact do
+ %Fact{ancestry: {producer_hash, parent_fact_hash}} ->
+ producer = workflow.graph.vertices[producer_hash]
+
+ case producer do
+ %FanOut{} = fan_out when fan_out.hash == target_fan_out_hash ->
+ {parent_fact_hash, fan_out.hash, fact_hash}
+
+ _ ->
+ do_find_fan_out_info(workflow, parent_fact_hash, target_fan_out_hash)
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ # Mark that this FanIn has completed for this batch - survives merges
+ defp mark_fan_in_completed(workflow, completed_key) do
+ Map.put(workflow, :mapped, Map.put(workflow.mapped, completed_key, true))
+ end
+
+ # Check if FanIn has already produced a :reduced output for this batch
+ # by checking if there's a fact with ancestry matching (fan_in.hash, _)
+ # that traces back to this source_fact_hash
+ defp has_reduced_output?(workflow, fan_in, source_fact_hash) do
+ workflow.graph
+ |> Graph.out_edges(fan_in)
+ |> Enum.any?(fn edge ->
+ edge.label == :reduced and
+ traces_to_source_fact?(workflow, edge.v2, source_fact_hash)
+ end)
+ end
+
+ defp traces_to_source_fact?(_workflow, %Fact{ancestry: nil}, _source_fact_hash), do: false
+
+ defp traces_to_source_fact?(
+ workflow,
+ %Fact{ancestry: {_producer_hash, parent_fact_hash}},
+ source_fact_hash
+ ) do
+ cond do
+ parent_fact_hash == source_fact_hash ->
+ true
+
+ is_nil(parent_fact_hash) ->
+ false
+
+ true ->
+ # Check if parent fact is the source or traces to it
+ parent_fact = workflow.graph.vertices[parent_fact_hash]
+
+ if is_nil(parent_fact) do
+ false
+ else
+ case parent_fact do
+ %Fact{ancestry: {parent_producer, _}} ->
+ parent_producer_node = workflow.graph.vertices[parent_producer]
+
+ case parent_producer_node do
+ %FanOut{} ->
+ # Found FanOut, check its parent fact
+ parent_fact.ancestry |> elem(1) == source_fact_hash
+
+ _ ->
+ traces_to_source_fact?(workflow, parent_fact, source_fact_hash)
+ end
+
+ _ ->
+ false
+ end
+ end
+ end
+ end
+
+ defp traces_to_source_fact?(_workflow, _fact, _source_fact_hash), do: false
+
+ defp reduce_with_context(enumerable, acc, reducer, effective_context) do
+ arity = Function.info(reducer, :arity) |> elem(1)
+
+ if effective_context != %{} and arity == 3 do
+ Enum.reduce_while(enumerable, acc, fn value, acc ->
+ case reducer.(value, acc, effective_context) do
+ {:cont, new_acc} -> {:cont, new_acc}
+ {:halt, new_acc} -> {:halt, new_acc}
+ new_acc -> {:cont, new_acc}
+ end
+ end)
+ else
+ Enum.reduce_while(enumerable, acc, fn value, acc ->
+ case reducer.(value, acc) do
+ {:cont, new_acc} -> {:cont, new_acc}
+ {:halt, new_acc} -> {:halt, new_acc}
+ new_acc -> {:cont, new_acc}
+ end
+ end)
+ end
+ end
+
+ defp merge_effective_context(meta, run) when map_size(meta) == 0 and map_size(run) == 0, do: %{}
+ defp merge_effective_context(meta, run) when map_size(run) == 0, do: meta
+ defp merge_effective_context(meta, run) when map_size(meta) == 0, do: run
+ defp merge_effective_context(meta, run), do: Map.merge(run, meta)
+
+ defp cleanup_mapped(
+ workflow,
+ {source_fact_hash, fan_out_hash} = expected_key,
+ seen_key,
+ source_fact_hash
+ ) do
+ mapped =
+ workflow.mapped
+ |> Map.delete(seen_key)
+ |> maybe_delete_expected_batch(expected_key, fan_out_hash, source_fact_hash, workflow)
+
+ Map.put(workflow, :mapped, mapped)
+ end
+
+ defp maybe_delete_expected_batch(mapped, expected_key, fan_out_hash, source_fact_hash, workflow) do
+ if all_fan_ins_completed?(workflow, fan_out_hash, source_fact_hash) do
+ mapped
+ |> Map.delete(expected_key)
+ |> Map.delete({:fan_out_for_batch, source_fact_hash})
+ else
+ mapped
+ end
+ end
+
+ defp all_fan_ins_completed?(workflow, fan_out_hash, source_fact_hash) do
+ case workflow.graph.vertices[fan_out_hash] do
+ %FanOut{} = fan_out ->
+ workflow.graph
+ |> Graph.out_edges(fan_out)
+ |> Enum.filter(&(&1.label == :fan_in))
+ |> Enum.map(& &1.v2.hash)
+ |> Enum.all?(fn fan_in_hash ->
+ Map.get(workflow.mapped, {:fan_in_completed, source_fact_hash, fan_in_hash}, false)
+ end)
+
+ _ ->
+ true
+ end
+ end
+
+ def prepare(
+ %FanIn{} = fan_in,
+ %Workflow{} = workflow,
+ %Fact{ancestry: {parent_step_hash, _}} = fact
+ ) do
+ fan_out = find_upstream_fan_out(workflow, fan_in)
+
+ meta_context =
+ if FanIn.has_meta_refs?(fan_in) do
+ Workflow.prepare_meta_context(workflow, fan_in)
+ else
+ %{}
+ end
+
+ run_context = Workflow.get_run_context(workflow, fan_in.name)
+
+ case fan_out do
+ nil ->
+ # Simple reduce mode - no FanOut upstream
+ context =
+ CausalContext.new(
+ node_hash: fan_in.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ hooks: Workflow.get_hooks(workflow, fan_in.hash),
+ mergeable: fan_in.mergeable,
+ fan_in_context: %{mode: :simple},
+ meta_context: meta_context,
+ run_context: run_context
+ )
+
+ {:ok, Runnable.new(fan_in, fact, context)}
+
+ %FanOut{} ->
+ source_fact_hash = find_fan_out_source_fact_hash(workflow, fact, fan_out.hash)
+
+ already_completed = has_reduced_output?(workflow, fan_in, source_fact_hash)
+ completed_key = {:fan_in_completed, source_fact_hash, fan_in.hash}
+ already_completed = already_completed or Map.get(workflow.mapped, completed_key, false)
+
+ expected_key = {source_fact_hash, fan_out.hash}
+ seen_key = {fan_out.hash, source_fact_hash, parent_step_hash}
+
+ expected_list = workflow.mapped[expected_key] || []
+ expected_set = MapSet.new(expected_list)
+ seen_map = workflow.mapped[seen_key] || %{}
+ seen_set = MapSet.new(Map.keys(seen_map))
+
+ ready =
+ not already_completed and
+ not Enum.empty?(expected_set) and
+ MapSet.equal?(expected_set, seen_set)
+
+ # Collect sister values in order if ready
+ sister_values =
+ if ready do
+ expected_in_order = Enum.reverse(expected_list)
+
+ for origin <- expected_in_order do
+ sister_hash = seen_map[origin]
+ workflow.graph.vertices[sister_hash].value
+ end
+ else
+ nil
+ end
+
+ context =
+ CausalContext.new(
+ node_hash: fan_in.hash,
+ input_fact: fact,
+ ancestry_depth: Workflow.ancestry_depth(workflow, fact),
+ hooks: Workflow.get_hooks(workflow, fan_in.hash),
+ mergeable: fan_in.mergeable,
+ fan_in_context: %{
+ mode: :fan_out_reduce,
+ source_fact_hash: source_fact_hash,
+ fan_out_hash: fan_out.hash,
+ ready: ready,
+ already_completed: already_completed,
+ sister_values: sister_values,
+ expected_key: expected_key,
+ seen_key: seen_key,
+ expected_list: expected_list,
+ seen_map: seen_map
+ },
+ meta_context: meta_context,
+ run_context: run_context
+ )
+
+ {:ok, Runnable.new(fan_in, fact, context)}
+ end
+ end
+
+ def prepare(%FanIn{} = fan_in, %Workflow{} = _workflow, %Fact{ancestry: nil} = fact) do
+ # Root fact - shouldn't happen normally but handle gracefully
+ context =
+ CausalContext.new(
+ node_hash: fan_in.hash,
+ input_fact: fact,
+ ancestry_depth: 0,
+ hooks: {[], []},
+ mergeable: fan_in.mergeable,
+ fan_in_context: %{mode: :simple},
+ meta_context: %{},
+ run_context: %{}
+ )
+
+ {:ok, Runnable.new(fan_in, fact, context)}
+ end
+
+ def execute(
+ %FanIn{init: init, reducer: reducer} = fan_in,
+ %Runnable{input_fact: fact, context: ctx} = runnable
+ ) do
+ case ctx.fan_in_context.mode do
+ :simple ->
+ with {:ok, before_apply_fns} <- HookRunner.run_before(ctx, fan_in, fact) do
+ effective_context = merge_effective_context(ctx.meta_context, ctx.run_context)
+ reduced = reduce_with_context(fact.value, init.(), reducer, effective_context)
+ reduced_fact = Fact.new(value: reduced, ancestry: {fan_in.hash, fact.hash})
+
+ case HookRunner.run_after(ctx, fan_in, fact, reduced_fact) do
+ {:ok, after_apply_fns} ->
+ events = [
+ %FactProduced{
+ hash: reduced_fact.hash,
+ value: reduced_fact.value,
+ ancestry: reduced_fact.ancestry,
+ producer_label: :reduced,
+ weight: ctx.ancestry_depth + 1
+ },
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: fan_in.hash,
+ from_label: :runnable
+ }
+ ]
+
+ hook_fns = before_apply_fns ++ after_apply_fns
+ Runnable.complete(runnable, reduced_fact, events, hook_fns)
+
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+ else
+ {:error, reason} ->
+ Runnable.fail(runnable, {:hook_error, reason})
+ end
+
+ :fan_out_reduce ->
+ # Coordination node: always just produce ActivationConsumed.
+ # Completion check happens in maybe_finalize_coordination/2 during apply,
+ # which rechecks mapped state (handles concurrent arrivals correctly).
+ events = [
+ %ActivationConsumed{
+ fact_hash: fact.hash,
+ node_hash: fan_in.hash,
+ from_label: :runnable
+ }
+ ]
+
+ Runnable.complete(runnable, :waiting, events)
+ end
+ end
+
+ defp find_upstream_fan_out(workflow, fan_in) do
+ workflow.graph
+ |> Graph.in_edges(fan_in)
+ |> Enum.filter(&(&1.label == :fan_in))
+ |> List.first(%{})
+ |> Map.get(:v1)
+ end
+end
diff --git a/vendor/runic/lib/workflow/join.ex b/vendor/runic/lib/workflow/join.ex
new file mode 100644
index 0000000..3eb6bb0
--- /dev/null
+++ b/vendor/runic/lib/workflow/join.ex
@@ -0,0 +1,94 @@
+defmodule Runic.Workflow.Join do
+ alias Runic.Workflow.Components
+ defstruct [:hash, :joins]
+
+ def new(joins) when is_list(joins) do
+ %__MODULE__{joins: joins, hash: Components.fact_hash(joins)}
+ end
+end
+
+defimpl Runic.Workflow.Coordinator, for: Runic.Workflow.Join do
+ alias Runic.Workflow
+ alias Runic.Workflow.Fact
+ alias Runic.Workflow.Runnable
+ alias Runic.Workflow.Private
+ alias Runic.Workflow.Events.JoinCompleted
+ alias Runic.Workflow.Events.JoinEdgeRelabeled
+
+ def finalize(%Runic.Workflow.Join{} = join, %Workflow{} = wf, %Runnable{
+ input_fact: fact,
+ context: ctx
+ }) do
+ join_order_weights =
+ join.joins
+ |> Enum.with_index()
+ |> Map.new()
+
+ joined_edges =
+ wf.graph
+ |> Graph.in_edges(join)
+ |> Enum.filter(&(&1.label == :joined))
+
+ satisfied_by_parent =
+ joined_edges
+ |> Enum.reduce(%{}, fn edge, acc ->
+ parent_hash = elem(edge.v1.ancestry, 0)
+
+ if Map.has_key?(join_order_weights, parent_hash) and not Map.has_key?(acc, parent_hash) do
+ Map.put(acc, parent_hash, edge.v1)
+ else
+ acc
+ end
+ end)
+
+ can_complete = map_size(satisfied_by_parent) >= length(join.joins)
+
+ if can_complete do
+ collected_values =
+ join.joins
+ |> Enum.map(&Map.get(satisfied_by_parent, &1))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.map(& &1.value)
+
+ join_fact = Fact.new(value: collected_values, ancestry: {join.hash, fact.hash})
+
+ wf = Workflow.run_before_hooks(wf, join, fact)
+
+ completion_event = %JoinCompleted{
+ join_hash: join.hash,
+ result_fact_hash: join_fact.hash,
+ result_value: join_fact.value,
+ result_ancestry: join_fact.ancestry,
+ weight: ctx.ancestry_depth + 1
+ }
+
+ relabel_events =
+ joined_edges
+ |> Enum.map(fn edge ->
+ %JoinEdgeRelabeled{
+ fact_hash: edge.v1.hash,
+ join_hash: join.hash,
+ from_label: :joined,
+ to_label: :join_satisfied
+ }
+ end)
+
+ derived_events = [completion_event | relabel_events]
+
+ wf = Enum.reduce(derived_events, wf, fn event, w -> Workflow.apply_event(w, event) end)
+ wf = Workflow.run_after_hooks(wf, join, join_fact)
+
+ # Activate downstream nodes with the join result fact
+ next = Workflow.next_steps(wf, join)
+
+ wf =
+ Enum.reduce(next, wf, fn step, w ->
+ Workflow.draw_connection(w, join_fact, step, Private.connection_for_activatable(step))
+ end)
+
+ {wf, derived_events}
+ else
+ {wf, []}
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/map.ex b/vendor/runic/lib/workflow/map.ex
new file mode 100644
index 0000000..38f020f
--- /dev/null
+++ b/vendor/runic/lib/workflow/map.ex
@@ -0,0 +1,22 @@
+defmodule Runic.Workflow.Map do
+ @moduledoc """
+ Map operations contain a FanOut operator and LambdaStep operations to produce facts split from the input and process each.
+
+ ## Runtime Context
+
+ Steps within a map pipeline support `context/1` expressions. Context values
+ are resolved from the workflow's `run_context` using `_global` scope or
+ the individual step's name.
+
+ Runic.map(fn x -> x * context(:multiplier) end, name: :mult_map)
+ """
+ defstruct [
+ :hash,
+ :name,
+ :pipeline,
+ :components,
+ :closure,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/policy_driver.ex b/vendor/runic/lib/workflow/policy_driver.ex
new file mode 100644
index 0000000..ef106f2
--- /dev/null
+++ b/vendor/runic/lib/workflow/policy_driver.ex
@@ -0,0 +1,347 @@
+defmodule Runic.Workflow.PolicyDriver do
+ @moduledoc """
+ Executes a `%Runnable{}` according to a `%SchedulerPolicy{}`, handling retries,
+ timeouts, backoff, fallbacks, and failure modes.
+
+ The PolicyDriver is the bridge between the scheduler's declared execution policy
+ and the actual invocation of a runnable's work function. It wraps `Invokable.execute/2`
+ with policy-driven retry loops, timeout enforcement, and fallback resolution.
+
+ ## Event Emission
+
+ When `emit_events: true` is passed in options, `execute/3` returns
+ `{%Runnable{}, [event]}` instead of just `%Runnable{}`. Events are:
+
+ * `%RunnableDispatched{}` — emitted at each execution attempt
+ * `%RunnableCompleted{}` — emitted on successful completion
+ * `%RunnableFailed{}` — emitted on permanent failure (retries exhausted)
+ """
+
+ alias Runic.Workflow.{Runnable, Invokable, Fact, SchedulerPolicy}
+ alias Runic.Workflow.{RunnableDispatched, RunnableCompleted, RunnableFailed}
+
+ import Bitwise
+
+ @doc """
+ Execute a runnable according to the given scheduler policy.
+ """
+ @spec execute(Runnable.t(), SchedulerPolicy.t()) :: Runnable.t()
+ def execute(%Runnable{} = runnable, %SchedulerPolicy{} = policy) do
+ do_execute(runnable, policy, 0, [])
+ end
+
+ @doc """
+ Execute a runnable according to the given scheduler policy with options.
+
+ ## Options
+
+ * `:deadline_at` - monotonic time deadline (used by Phase 3, plumbed now)
+ * `:emit_events` - when `true`, returns `{%Runnable{}, [event]}` instead of `%Runnable{}`
+ """
+ @spec execute(Runnable.t(), SchedulerPolicy.t(), keyword()) ::
+ Runnable.t() | {Runnable.t(), list()}
+ def execute(%Runnable{} = runnable, %SchedulerPolicy{} = policy, opts) when is_list(opts) do
+ if Keyword.get(opts, :emit_events, false) do
+ {result, events} = do_execute_with_events(runnable, policy, 0, opts)
+ {result, Enum.reverse(events)}
+ else
+ do_execute(runnable, policy, 0, opts)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Non-event execution (original Phase 1 path)
+ # ---------------------------------------------------------------------------
+
+ defp do_execute(%Runnable{} = runnable, %SchedulerPolicy{} = policy, attempt, opts) do
+ case check_deadline(opts) do
+ :ok ->
+ result = execute_with_timeout(runnable, policy, opts)
+
+ case result.status do
+ :completed ->
+ result
+
+ :skipped ->
+ result
+
+ :failed ->
+ if attempt < policy.max_retries do
+ delay = compute_delay(policy, attempt)
+ if delay > 0, do: Process.sleep(delay)
+ reset = reset_for_retry(runnable)
+ do_execute(reset, policy, attempt + 1, opts)
+ else
+ case policy.fallback do
+ nil ->
+ case policy.on_failure do
+ :skip -> skip_runnable(result)
+ :halt -> result
+ end
+
+ fallback when is_function(fallback) ->
+ handle_fallback(result, result.error, policy)
+ end
+ end
+ end
+
+ {:deadline_exceeded, remaining_ms} ->
+ Runnable.fail(runnable, {:deadline_exceeded, remaining_ms})
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Event-emitting execution
+ # ---------------------------------------------------------------------------
+
+ defp do_execute_with_events(%Runnable{} = runnable, %SchedulerPolicy{} = policy, attempt, opts) do
+ case check_deadline(opts) do
+ :ok ->
+ dispatched_event = build_dispatched_event(runnable, policy, attempt)
+ start_time = System.monotonic_time(:millisecond)
+
+ result = execute_with_timeout(runnable, policy, opts)
+
+ case result.status do
+ :completed ->
+ duration = System.monotonic_time(:millisecond) - start_time
+ completed_event = build_completed_event(result, attempt, duration)
+ {result, [completed_event, dispatched_event]}
+
+ :skipped ->
+ {result, [dispatched_event]}
+
+ :failed ->
+ if attempt < policy.max_retries do
+ delay = compute_delay(policy, attempt)
+ if delay > 0, do: Process.sleep(delay)
+ reset = reset_for_retry(runnable)
+ {final, rest_events} = do_execute_with_events(reset, policy, attempt + 1, opts)
+ {final, rest_events ++ [dispatched_event]}
+ else
+ case policy.fallback do
+ nil ->
+ failure_action =
+ case policy.on_failure do
+ :skip -> :skip
+ :halt -> :halt
+ end
+
+ failed_event = build_failed_event(result, attempt + 1, failure_action)
+
+ final =
+ case policy.on_failure do
+ :skip -> skip_runnable(result)
+ :halt -> result
+ end
+
+ {final, [failed_event, dispatched_event]}
+
+ fallback when is_function(fallback) ->
+ fallback_result = handle_fallback(result, result.error, policy)
+
+ case fallback_result.status do
+ :completed ->
+ duration = System.monotonic_time(:millisecond) - start_time
+ completed_event = build_completed_event(fallback_result, attempt, duration)
+ {fallback_result, [completed_event, dispatched_event]}
+
+ :failed ->
+ failed_event = build_failed_event(fallback_result, attempt + 1, :halt)
+ {fallback_result, [failed_event, dispatched_event]}
+ end
+ end
+ end
+ end
+
+ {:deadline_exceeded, remaining_ms} ->
+ failed = Runnable.fail(runnable, {:deadline_exceeded, remaining_ms})
+ failed_event = build_failed_event(failed, attempt, :halt)
+ {failed, [failed_event]}
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Event builders
+ # ---------------------------------------------------------------------------
+
+ defp build_dispatched_event(%Runnable{} = runnable, %SchedulerPolicy{} = policy, attempt) do
+ %RunnableDispatched{
+ runnable_id: runnable.id,
+ node_name: Map.get(runnable.node, :name, runnable.node.hash),
+ node_hash: runnable.node.hash,
+ input_fact: runnable.input_fact,
+ dispatched_at: System.monotonic_time(:millisecond),
+ policy: strip_non_serializable(policy),
+ attempt: attempt
+ }
+ end
+
+ defp build_completed_event(%Runnable{} = runnable, attempt, duration_ms) do
+ %RunnableCompleted{
+ runnable_id: runnable.id,
+ node_hash: runnable.node.hash,
+ result_fact: runnable.result,
+ completed_at: System.monotonic_time(:millisecond),
+ attempt: attempt,
+ duration_ms: duration_ms
+ }
+ end
+
+ defp build_failed_event(%Runnable{} = runnable, attempts, failure_action) do
+ %RunnableFailed{
+ runnable_id: runnable.id,
+ node_hash: runnable.node.hash,
+ error: runnable.error,
+ failed_at: System.monotonic_time(:millisecond),
+ attempts: attempts,
+ failure_action: failure_action
+ }
+ end
+
+ defp strip_non_serializable(%SchedulerPolicy{} = policy) do
+ %{policy | fallback: nil, idempotency_key: nil}
+ end
+
+ # ---------------------------------------------------------------------------
+ # Deadline checking
+ # ---------------------------------------------------------------------------
+
+ defp check_deadline(opts) do
+ case Keyword.get(opts, :deadline_at) do
+ nil ->
+ :ok
+
+ deadline_at ->
+ remaining = deadline_at - System.monotonic_time(:millisecond)
+
+ if remaining > 0 do
+ :ok
+ else
+ {:deadline_exceeded, remaining}
+ end
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Timeout, backoff, retry helpers (shared)
+ # ---------------------------------------------------------------------------
+
+ defp execute_with_timeout(%Runnable{} = runnable, %SchedulerPolicy{} = policy, opts) do
+ timeout_ms = effective_timeout(policy, opts)
+
+ case timeout_ms do
+ :infinity ->
+ Invokable.execute(runnable.node, runnable)
+
+ ms ->
+ task = Task.async(fn -> Invokable.execute(runnable.node, runnable) end)
+
+ case Task.yield(task, ms) do
+ {:ok, result} ->
+ result
+
+ nil ->
+ Task.shutdown(task, :brutal_kill)
+ Runnable.fail(runnable, {:timeout, ms})
+ end
+ end
+ end
+
+ defp effective_timeout(%SchedulerPolicy{timeout_ms: :infinity}, opts) do
+ case Keyword.get(opts, :deadline_at) do
+ nil -> :infinity
+ deadline_at -> max(deadline_at - System.monotonic_time(:millisecond), 0)
+ end
+ end
+
+ defp effective_timeout(%SchedulerPolicy{timeout_ms: timeout_ms}, opts) do
+ case Keyword.get(opts, :deadline_at) do
+ nil -> timeout_ms
+ deadline_at -> min(timeout_ms, max(deadline_at - System.monotonic_time(:millisecond), 0))
+ end
+ end
+
+ defp compute_delay(%SchedulerPolicy{backoff: :none}, _attempt), do: 0
+
+ defp compute_delay(
+ %SchedulerPolicy{backoff: :linear, base_delay_ms: base, max_delay_ms: max},
+ attempt
+ ) do
+ min(base * (attempt + 1), max)
+ end
+
+ defp compute_delay(
+ %SchedulerPolicy{backoff: :exponential, base_delay_ms: base, max_delay_ms: max},
+ attempt
+ ) do
+ min(base * bsl(1, attempt), max)
+ end
+
+ defp compute_delay(
+ %SchedulerPolicy{backoff: :jitter, base_delay_ms: base, max_delay_ms: max},
+ attempt
+ ) do
+ raw = base * bsl(1, attempt)
+ min(:rand.uniform(max(raw, 1)), max)
+ end
+
+ defp reset_for_retry(%Runnable{} = runnable) do
+ %{runnable | status: :pending, result: nil, error: nil, events: nil}
+ end
+
+ defp handle_fallback(%Runnable{} = runnable, error, %SchedulerPolicy{fallback: fallback}) do
+ case fallback.(runnable, error) do
+ %Runnable{} = modified ->
+ no_retry_policy = %SchedulerPolicy{max_retries: 0, fallback: nil}
+ execute(modified, no_retry_policy)
+
+ {:retry_with, %{} = overrides} ->
+ merged_meta = Map.merge(runnable.context.meta_context, overrides)
+ updated_context = %{runnable.context | meta_context: merged_meta}
+ updated_runnable = reset_for_retry(%{runnable | context: updated_context})
+ no_retry_policy = %SchedulerPolicy{max_retries: 0, fallback: nil}
+ execute(updated_runnable, no_retry_policy)
+
+ {:value, term} ->
+ result_fact =
+ Fact.new(value: term, ancestry: {runnable.node.hash, runnable.input_fact.hash})
+
+ alias Runic.Workflow.Events.{FactProduced, ActivationConsumed}
+
+ events = [
+ %FactProduced{
+ hash: result_fact.hash,
+ value: result_fact.value,
+ ancestry: result_fact.ancestry,
+ producer_label: :produced,
+ weight: (runnable.context.ancestry_depth || 0) + 1
+ },
+ %ActivationConsumed{
+ fact_hash: runnable.input_fact.hash,
+ node_hash: runnable.node.hash,
+ from_label: :runnable
+ }
+ ]
+
+ Runnable.complete(runnable, result_fact, events)
+
+ other ->
+ Runnable.fail(runnable, {:invalid_fallback_return, other})
+ end
+ end
+
+ defp skip_runnable(%Runnable{} = runnable) do
+ alias Runic.Workflow.Events.ActivationConsumed
+
+ events = [
+ %ActivationConsumed{
+ fact_hash: runnable.input_fact.hash,
+ node_hash: runnable.node.hash,
+ from_label: :runnable
+ }
+ ]
+
+ Runnable.skip(runnable, events)
+ end
+end
diff --git a/vendor/runic/lib/workflow/private.ex b/vendor/runic/lib/workflow/private.ex
new file mode 100644
index 0000000..1da2c62
--- /dev/null
+++ b/vendor/runic/lib/workflow/private.ex
@@ -0,0 +1,684 @@
+defmodule Runic.Workflow.Private do
+ @moduledoc false
+ # Internal implementation functions for Runic.Workflow.
+ # These are not part of the public API and may change without notice.
+
+ alias Runic.Workflow
+ alias Runic.Workflow.Root
+ alias Runic.Workflow.Step
+ alias Runic.Workflow.Condition
+ alias Runic.Workflow.Fact
+ alias Runic.Workflow.FactRef
+ alias Runic.Workflow.Rule
+ alias Runic.Workflow.FanOut
+ alias Runic.Workflow.FanIn
+ alias Runic.Workflow.Join
+ alias Runic.Workflow.Invokable
+ alias Runic.Workflow.Events.RunnableActivated
+ alias Runic.Component
+
+ # =============================================================================
+ # Graph wiring / component registration
+ # =============================================================================
+
+ def root(), do: %Root{}
+
+ def register_component(%Workflow{} = workflow, component) do
+ hash = Component.hash(component)
+
+ workflow =
+ %Workflow{
+ workflow
+ | components: Map.put(workflow.components, component.name, hash)
+ }
+
+ case component do
+ %Runic.Workflow.Map{components: map_components, name: map_name}
+ when not is_nil(map_components) ->
+ Enum.reduce(map_components, workflow, fn {sub_name, sub_component}, acc ->
+ case sub_name do
+ :fan_out ->
+ components = Map.put(acc.components, {map_name, :fan_out}, sub_component.hash)
+ %Workflow{acc | components: components}
+
+ _ ->
+ acc
+ end
+ end)
+
+ _ ->
+ workflow
+ end
+ end
+
+ def maybe_put_component(
+ %Workflow{components: components} = workflow,
+ %FanOut{name: name} = step
+ ) do
+ %Workflow{
+ workflow
+ | components: Map.put(components, name, step)
+ }
+ end
+
+ def maybe_put_component(
+ %Workflow{components: components} = workflow,
+ %{name: name} = step
+ ) do
+ %Workflow{
+ workflow
+ | components: Map.put(components, name, step)
+ }
+ end
+
+ def maybe_put_component(%Workflow{} = workflow, %FanIn{map: nil}) do
+ workflow
+ end
+
+ def maybe_put_component(%Workflow{} = workflow, %{} = _step), do: workflow
+
+ def draw_connection(%Workflow{graph: g} = wrk, node_1, node_2, connection, opts \\ []) do
+ opts = Keyword.put(opts, :label, connection)
+ %Workflow{wrk | graph: Graph.add_edge(g, node_1, node_2, opts)}
+ end
+
+ def add_dependent_steps(workflow, {parent_step, dependent_steps}) do
+ Enum.reduce(dependent_steps, workflow, fn
+ {[_step | _] = parent_steps, dependent_steps}, wrk ->
+ wrk =
+ Enum.reduce(parent_steps, wrk, fn step, wrk ->
+ Workflow.add(wrk, step, to: parent_step)
+ end)
+
+ join =
+ parent_steps
+ |> Enum.map(& &1.hash)
+ |> Join.new()
+
+ wrk = add_step(wrk, parent_steps, join)
+
+ add_dependent_steps(wrk, {join, dependent_steps})
+
+ {%Join{} = step, _dependent_steps} = parent_and_children, wrk ->
+ wrk = add_step(wrk, parent_step, step)
+ add_dependent_steps(wrk, parent_and_children)
+
+ {step, _dependent_steps} = parent_and_children, wrk ->
+ wrk = Workflow.add(wrk, step, to: parent_step)
+ add_dependent_steps(wrk, parent_and_children)
+
+ step, wrk ->
+ Workflow.add(wrk, step, to: parent_step)
+ end)
+ end
+
+ def add_step(%Workflow{} = workflow, child_step) when is_function(child_step) do
+ add_step(workflow, %Root{}, Step.new(work: child_step))
+ end
+
+ def add_step(%Workflow{} = workflow, child_step) do
+ add_step(workflow, %Root{}, child_step)
+ end
+
+ def add_step(%Workflow{graph: g} = workflow, %Root{}, %Condition{} = child_step) do
+ %Workflow{
+ workflow
+ | graph:
+ g
+ |> Graph.add_vertex(child_step, child_step.hash)
+ |> Graph.add_edge(%Root{}, child_step, label: :flow, weight: 0)
+ }
+ end
+
+ def add_step(%Workflow{graph: g} = workflow, %Root{}, %{} = child_step) do
+ %Workflow{
+ workflow
+ | graph:
+ g
+ |> Graph.add_vertex(child_step, child_step.hash)
+ |> Graph.add_edge(%Root{}, child_step, label: :flow, weight: 0)
+ }
+ end
+
+ def add_step(
+ %Workflow{} = workflow,
+ %{} = parent_step,
+ {child_step, grand_child_steps}
+ ) do
+ Enum.reduce(
+ grand_child_steps,
+ add_step(workflow, parent_step, child_step),
+ fn grand_child_step, wrk -> add_step(wrk, child_step, grand_child_step) end
+ )
+ end
+
+ def add_step(
+ %Workflow{} = workflow,
+ {%FanOut{}, [%Step{} = map_step]},
+ child_step
+ ) do
+ add_step(workflow, map_step, child_step)
+ end
+
+ def add_step(%Workflow{graph: g} = workflow, %{} = parent_step, %{} = child_step) do
+ %Workflow{
+ workflow
+ | graph:
+ g
+ |> Graph.add_vertex(child_step, to_string(child_step.hash))
+ |> Graph.add_edge(parent_step, child_step, label: :flow, weight: 0)
+ }
+ end
+
+ def add_step(%Workflow{} = workflow, parent_steps, %{} = child_step)
+ when is_list(parent_steps) do
+ Enum.reduce(parent_steps, workflow, fn parent_step, wrk ->
+ add_step(wrk, parent_step, child_step)
+ end)
+ end
+
+ def add_step(%Workflow{} = workflow, parent_step_name, child_step)
+ when is_atom(parent_step_name) or is_binary(parent_step_name) do
+ add_step(workflow, Workflow.get_component!(workflow, parent_step_name), child_step)
+ end
+
+ def add_rule(
+ %Workflow{} = workflow,
+ %Rule{} = rule
+ ) do
+ Workflow.add(workflow, rule)
+ end
+
+ def mark_runnable_as_ran(%Workflow{graph: graph} = workflow, step, fact) do
+ graph =
+ case Graph.update_labelled_edge(graph, fact, step, connection_for_activatable(step),
+ label: :ran
+ ) do
+ %Graph{} = graph -> graph
+ {:error, :no_such_edge} -> graph
+ end
+
+ %Workflow{
+ workflow
+ | graph: graph
+ }
+ end
+
+ def satisfied_condition_hashes(%Workflow{graph: graph}, %Fact{} = fact) do
+ for %Graph.Edge{} = edge <- Graph.out_edges(graph, fact, by: :satisfied),
+ do: edge.v2.hash
+ end
+
+ def matches(%Workflow{graph: graph}) do
+ for %Graph.Edge{} = edge <- Graph.edges(graph, by: [:matchable, :satisfied]) do
+ edge.v2
+ end
+ end
+
+ def log_fact(%Workflow{graph: graph} = wrk, %Fact{} = fact) do
+ %Workflow{
+ wrk
+ | graph: Graph.add_vertex(graph, fact)
+ }
+ end
+
+ def log_fact(%Workflow{graph: graph} = wrk, %FactRef{} = ref) do
+ %Workflow{
+ wrk
+ | graph: Graph.add_vertex(graph, ref)
+ }
+ end
+
+ # =============================================================================
+ # Hooks
+ # =============================================================================
+
+ def add_before_hooks(%Workflow{} = workflow, nil), do: workflow
+
+ def add_before_hooks(%Workflow{} = workflow, hooks) do
+ %Workflow{
+ workflow
+ | before_hooks:
+ Enum.reduce(hooks, workflow.before_hooks, fn
+ {name, hook}, acc when is_function(hook, 3) ->
+ node = resolve_component_to_node(workflow, name)
+ node_hash = node.hash
+ hooks_for_component = Map.get(acc, node_hash, [])
+ Map.put(acc, node_hash, Enum.reverse([hook | hooks_for_component]))
+
+ {name, hooks}, acc when is_list(hooks) ->
+ node = resolve_component_to_node(workflow, name)
+ node_hash = node.hash
+ hooks_for_component = Map.get(acc, node_hash, [])
+ Map.put(acc, node_hash, hooks ++ hooks_for_component)
+ end)
+ }
+ end
+
+ def add_after_hooks(%Workflow{} = workflow, nil), do: workflow
+
+ def add_after_hooks(%Workflow{} = workflow, hooks) do
+ %Workflow{
+ workflow
+ | after_hooks:
+ Enum.reduce(hooks, workflow.after_hooks, fn
+ {name, hook}, acc when is_function(hook, 3) ->
+ node = resolve_component_to_node(workflow, name)
+ node_hash = node.hash
+ hooks_for_component = Map.get(acc, node_hash, [])
+ Map.put(acc, node_hash, Enum.reverse([hook | hooks_for_component]))
+
+ {name, hooks}, acc when is_list(hooks) ->
+ node = resolve_component_to_node(workflow, name)
+ node_hash = node.hash
+ hooks_for_component = Map.get(acc, node_hash, [])
+ Map.put(acc, node_hash, hooks ++ hooks_for_component)
+ end)
+ }
+ end
+
+ def run_before_hooks(%Workflow{} = workflow, %{hash: hash} = step, input_fact) do
+ case get_before_hooks(workflow, hash) do
+ nil ->
+ workflow
+
+ hooks ->
+ Enum.reduce(hooks, workflow, fn hook, wrk -> hook.(step, wrk, input_fact) end)
+ end
+ end
+
+ def run_before_hooks(%Workflow{} = workflow, _step, _input_fact), do: workflow
+
+ def run_after_hooks(%Workflow{} = workflow, %{hash: hash} = step, output_fact) do
+ case get_after_hooks(workflow, hash) do
+ nil ->
+ workflow
+
+ hooks ->
+ Enum.reduce(hooks, workflow, fn hook, wrk -> hook.(step, wrk, output_fact) end)
+ end
+ end
+
+ def run_after_hooks(%Workflow{} = workflow, _step, _input_fact), do: workflow
+
+ def get_hooks(%Workflow{before_hooks: before, after_hooks: after_hooks}, node_hash) do
+ {Map.get(before, node_hash, []), Map.get(after_hooks, node_hash, [])}
+ end
+
+ # =============================================================================
+ # Private hook helpers
+ # =============================================================================
+
+ defp resolve_component_to_node(%Workflow{} = workflow, {component_name, sub_component_kind}) do
+ Workflow.get_component(workflow, {component_name, sub_component_kind}) |> List.first()
+ end
+
+ defp resolve_component_to_node(%Workflow{} = workflow, component_name)
+ when is_atom(component_name) or is_binary(component_name) do
+ Workflow.get_component!(workflow, component_name)
+ end
+
+ defp resolve_component_to_node(%Workflow{graph: g}, hash) when is_integer(hash) do
+ Map.get(g.vertices, hash)
+ end
+
+ defp get_before_hooks(%Workflow{} = workflow, hash) do
+ Map.get(workflow.before_hooks, hash)
+ end
+
+ defp get_after_hooks(%Workflow{} = workflow, hash) do
+ Map.get(workflow.after_hooks, hash)
+ end
+
+ # =============================================================================
+ # Three-phase internals / planning
+ # =============================================================================
+
+ def prepare_next_runnables(%Workflow{} = workflow, node, fact) do
+ workflow
+ |> Workflow.next_steps(node)
+ |> Enum.reduce(workflow, fn step, wrk ->
+ draw_connection(wrk, fact, step, connection_for_activatable(step))
+ end)
+ end
+
+ @doc false
+ def activate_downstream_with_events(%Workflow{} = workflow, node, %Fact{} = fact) do
+ next = Workflow.next_steps(workflow, node)
+
+ activation_events =
+ Enum.map(next, fn step ->
+ %RunnableActivated{
+ fact_hash: fact.hash,
+ node_hash: step.hash,
+ activation_kind: connection_for_activatable(step)
+ }
+ end)
+
+ wf =
+ Enum.reduce(activation_events, workflow, fn event, w -> Workflow.apply_event(w, event) end)
+
+ {wf, activation_events}
+ end
+
+ @doc false
+ def prepare_next_generation(%Workflow{} = workflow, %Fact{}), do: workflow
+
+ def prepare_next_generation(%Workflow{} = workflow, [%Fact{} | _] = _facts),
+ do: workflow
+
+ @doc false
+ def causal_generation(%Workflow{} = workflow, %Fact{} = fact) do
+ Workflow.ancestry_depth(workflow, fact) + 1
+ end
+
+ def next_runnables(
+ %Workflow{} = wrk,
+ %Fact{ancestry: {parent_step_hash, _parent_fact}} = fact
+ ) do
+ wrk =
+ unless Graph.has_vertex?(wrk.graph, fact) do
+ log_fact(wrk, fact)
+ else
+ wrk
+ end
+
+ parent_step = Map.get(wrk.graph.vertices, parent_step_hash)
+
+ next_step_hashes =
+ wrk
+ |> Workflow.next_steps(parent_step)
+ |> Enum.map(& &1.hash)
+
+ for %Graph.Edge{} = edge <-
+ Enum.flat_map(next_step_hashes, &Graph.out_edges(wrk.graph, &1, by: :runnable)) do
+ {Map.get(wrk.graph.vertices, edge.v2), edge.v1}
+ end
+ end
+
+ def next_runnables(%Workflow{graph: graph}, raw_fact) do
+ for %Graph.Edge{} = edge <- Graph.out_edges(graph, root(), by: :flow) do
+ {edge.v1, Fact.new(value: raw_fact)}
+ end
+ end
+
+ # =============================================================================
+ # Meta expression support
+ # =============================================================================
+
+ def build_getter_fn(%{kind: :state_of, field_path: _field_path}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ case target_node do
+ %Runic.Workflow.Accumulator{} = acc ->
+ get_accumulator_state(workflow, acc)
+
+ _ ->
+ nil
+ end
+ end
+ end
+
+ def build_getter_fn(%{kind: :step_ran?}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ if target_node do
+ workflow.graph
+ |> Graph.out_edges(target_node, by: :ran)
+ |> Enum.any?()
+ else
+ false
+ end
+ end
+ end
+
+ def build_getter_fn(%{kind: :fact_count}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ if target_node do
+ workflow.graph
+ |> Graph.out_edges(target_node, by: [:produced, :state_produced, :reduced])
+ |> length()
+ else
+ 0
+ end
+ end
+ end
+
+ def build_getter_fn(%{kind: :latest_value_of, field_path: field_path}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ if target_node do
+ latest_fact =
+ workflow.graph
+ |> Graph.out_edges(target_node, by: [:produced, :state_produced, :reduced])
+ |> Enum.max_by(
+ fn edge ->
+ case edge.v2 do
+ %Fact{} = fact -> Workflow.ancestry_depth(workflow, fact)
+ _ -> 0
+ end
+ end,
+ fn -> nil end
+ )
+
+ case latest_fact do
+ %{v2: %Fact{value: value}} -> extract_field_path(value, field_path)
+ _ -> nil
+ end
+ else
+ nil
+ end
+ end
+ end
+
+ def build_getter_fn(%{kind: :latest_fact_of}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ if target_node do
+ workflow.graph
+ |> Graph.out_edges(target_node, by: [:produced, :state_produced, :reduced])
+ |> Enum.max_by(
+ fn edge ->
+ case edge.v2 do
+ %Fact{} = fact -> Workflow.ancestry_depth(workflow, fact)
+ _ -> 0
+ end
+ end,
+ fn -> nil end
+ )
+ |> case do
+ %{v2: %Fact{} = fact} -> fact
+ _ -> nil
+ end
+ else
+ nil
+ end
+ end
+ end
+
+ def build_getter_fn(%{kind: :all_values_of}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ if target_node do
+ workflow.graph
+ |> Graph.out_edges(target_node, by: [:produced, :state_produced, :reduced])
+ |> Enum.map(fn edge ->
+ case edge.v2 do
+ %Fact{value: value} -> value
+ _ -> nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+ else
+ []
+ end
+ end
+ end
+
+ def build_getter_fn(%{kind: :all_facts_of}) do
+ fn workflow, target ->
+ target_node = resolve_target_node(workflow, target)
+
+ if target_node do
+ workflow.graph
+ |> Graph.out_edges(target_node, by: [:produced, :state_produced, :reduced])
+ |> Enum.map(fn edge ->
+ case edge.v2 do
+ %Fact{} = fact -> fact
+ _ -> nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+ else
+ []
+ end
+ end
+ end
+
+ def build_getter_fn(_unknown), do: fn _workflow, _target -> nil end
+
+ def draw_meta_ref_edge(%Workflow{} = workflow, from, to, meta_ref) do
+ getter_fn = build_getter_fn(meta_ref)
+
+ properties =
+ meta_ref
+ |> Map.put(:getter_fn, getter_fn)
+
+ draw_connection(workflow, from, to, :meta_ref, properties: properties)
+ end
+
+ def prepare_meta_context(%Workflow{graph: graph} = workflow, node) do
+ node_vertex =
+ case node do
+ %{hash: hash} -> hash
+ _ -> node
+ end
+
+ # Resolve graph-based meta_refs via :meta_ref edges
+ graph_context =
+ graph
+ |> Graph.out_edges(node_vertex, by: :meta_ref)
+ |> Enum.reduce(%{}, fn edge, acc ->
+ properties = edge.properties || %{}
+ getter_fn = Map.get(properties, :getter_fn)
+ context_key = Map.get(properties, :context_key)
+ target = edge.v2
+
+ if getter_fn && context_key do
+ value = getter_fn.(workflow, target)
+ Map.put(acc, context_key, value)
+ else
+ acc
+ end
+ end)
+
+ # Resolve :context-kind refs from run_context
+ external_context = resolve_context_refs(workflow, node)
+
+ # Graph context overrides external (more specific wins)
+ Map.merge(external_context, graph_context)
+ end
+
+ defp resolve_context_refs(%Workflow{} = workflow, node) do
+ meta_refs = Map.get(node, :meta_refs, [])
+
+ meta_refs
+ |> Enum.filter(fn ref -> ref.kind == :context end)
+ |> Enum.reduce(%{}, fn ref, acc ->
+ component_ctx = Workflow.get_run_context(workflow, node.name)
+ value = Map.get(component_ctx, ref.target)
+
+ resolved =
+ case {value, Map.get(ref, :default)} do
+ {nil, nil} -> nil
+ {nil, default} when is_function(default) -> default.()
+ {nil, default} -> default
+ {value, _} -> value
+ end
+
+ Map.put(acc, ref.context_key, resolved)
+ end)
+ end
+
+ # =============================================================================
+ # Shared helpers (used by moved functions and also callable from Workflow)
+ # =============================================================================
+
+ @doc false
+ def connection_for_activatable(step) do
+ Invokable.match_or_execute(step)
+ |> case do
+ :match -> :matchable
+ :execute -> :runnable
+ end
+ end
+
+ # =============================================================================
+ # Private helpers only used by moved functions
+ # =============================================================================
+
+ defp invoke_init(init) when is_function(init), do: init.()
+ defp invoke_init(init), do: init
+
+ defp resolve_target_node(workflow, target) do
+ case target do
+ hash when is_integer(hash) ->
+ Map.get(workflow.graph.vertices, hash)
+
+ name when is_atom(name) ->
+ Workflow.get_component(workflow, name)
+
+ %{hash: _} = node ->
+ node
+
+ _ ->
+ nil
+ end
+ end
+
+ defp get_accumulator_state(
+ %Workflow{} = workflow,
+ %Runic.Workflow.Accumulator{} = acc
+ ) do
+ case latest_state_fact(workflow, acc) do
+ nil -> invoke_init(acc.init)
+ fact -> Map.get(fact, :value)
+ end
+ end
+
+ def latest_state_fact(
+ %Workflow{graph: graph, mapped: mapped},
+ %Runic.Workflow.Accumulator{} = acc
+ ) do
+ case Map.get(mapped, {:latest_state_fact, acc.hash}) do
+ nil ->
+ graph
+ |> Graph.out_edges(acc, by: :state_produced)
+ |> Enum.with_index()
+ |> Enum.max_by(fn {edge, idx} -> {edge.weight || 0, idx} end, fn -> nil end)
+ |> case do
+ nil -> nil
+ {edge, _idx} -> Map.get(edge, :v2)
+ end
+
+ fact_hash ->
+ Map.get(graph.vertices, fact_hash)
+ end
+ end
+
+ defp extract_field_path(value, []), do: value
+ defp extract_field_path(nil, _path), do: nil
+
+ defp extract_field_path(value, [field | rest]) when is_map(value) do
+ extract_field_path(Map.get(value, field), rest)
+ end
+
+ defp extract_field_path(_value, _path), do: nil
+end
diff --git a/vendor/runic/lib/workflow/process_manager.ex b/vendor/runic/lib/workflow/process_manager.ex
new file mode 100644
index 0000000..896b042
--- /dev/null
+++ b/vendor/runic/lib/workflow/process_manager.ex
@@ -0,0 +1,126 @@
+defmodule Runic.Workflow.ProcessManager do
+ @moduledoc """
+ An event-driven process orchestrator that coordinates across multiple aggregates.
+
+ A ProcessManager reacts to events from various sources, maintains its own
+ coordination state, and issues commands to drive a business process forward.
+ Unlike the `Saga` component (sequential forward-then-compensate pipeline),
+ a ProcessManager is reactive and event-driven — it subscribes to event
+ patterns and decides what to do based on its accumulated state.
+
+ ## How It Works
+
+ At compile time a ProcessManager is lowered into standard Runic primitives:
+
+ - An **Accumulator** that holds the coordination state (typically a map).
+ The reducer merges state updates from event handlers into the current state.
+ - One **Rule** per `on` event handler. Each rule pattern-matches on incoming
+ events and produces both a state update (fed back into the accumulator) and
+ an optional command emission. Handlers without `emit` produce no output
+ event rules — they only update state.
+ - An optional **Rule** for the `complete?` check. This rule observes the
+ accumulator's state via `state_of()` and fires when the completion predicate
+ returns true.
+
+ Each event handler rule is named `:"_on_"` based on declaration
+ order.
+
+ Timeouts are declared but scheduled externally by the Runner. Timeout blocks
+ compile to rules that match `{:timeout, :name}` events with `state_of()` guards.
+
+ ## DSL Syntax
+
+ ProcessManagers are created with the `Runic.process_manager/2` macro using a
+ block DSL:
+
+ Runic.process_manager name: :name do
+ state %{initial: :state}
+
+ on pattern do
+ update %{field: value}
+ emit command_value # optional
+ end
+
+ complete? fn state -> boolean end # optional
+ end
+
+ ### Directives
+
+ - `state value` — declares the initial coordination state, typically a map
+ (required).
+ - `on pattern do ... end` — declares an event handler that pattern-matches
+ on incoming events. The body may contain:
+ - `update %{key: value}` — a map that is merged into the current state.
+ - `emit value` — an optional command to emit as an output fact.
+ - `complete? fn state -> bool end` — optional completion predicate. When it
+ returns true, the process manager signals that the business process is
+ finished.
+
+ ## Examples
+
+ require Runic
+
+ # Order fulfillment process manager
+ pm = Runic.process_manager name: :fulfillment do
+ state %{order_id: nil, paid: false, shipped: false}
+
+ on {:order_submitted, order_id} do
+ update %{order_id: order_id}
+ emit {:charge_payment, order_id}
+ end
+
+ on {:payment_received, _} do
+ update %{paid: true}
+ emit {:ship_order, state.order_id}
+ end
+
+ on {:shipment_created, _} do
+ update %{shipped: true}
+ end
+
+ complete? fn state -> state.shipped end
+ end
+
+ # Add to workflow and react to events
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(pm)
+ wrk = Workflow.react(wrk, {:order_submitted, "order-123"})
+ # State updated, {:charge_payment, "order-123"} command emitted
+
+ ## Sub-Component Access
+
+ After adding a ProcessManager to a workflow, its internal primitives can be
+ retrieved via `Workflow.get_component/2` using a `{name, kind}` tuple:
+
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(pm)
+
+ # Get the underlying accumulator (holds coordination state)
+ [accumulator] = Workflow.get_component(wrk, {:fulfillment, :accumulator})
+
+ # Get all event handler rules
+ handlers = Workflow.get_component(wrk, {:fulfillment, :event_handler})
+
+ # Get the completion rule (if declared)
+ [completion] = Workflow.get_component(wrk, {:fulfillment, :completion})
+ """
+
+ defstruct [
+ :name,
+ :initial_state,
+ :event_handlers,
+ :timeout_handlers,
+ :completion_check,
+ :accumulator,
+ :event_rules,
+ :completion_rule,
+ :workflow,
+ :source,
+ :hash,
+ :bindings,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/reaction_occurred.ex b/vendor/runic/lib/workflow/reaction_occurred.ex
new file mode 100644
index 0000000..86b70a9
--- /dev/null
+++ b/vendor/runic/lib/workflow/reaction_occurred.ex
@@ -0,0 +1,4 @@
+defmodule Runic.Workflow.ReactionOccurred do
+ @derive JSON.Encoder
+ defstruct [:from, :to, :reaction, :properties, :weight]
+end
diff --git a/vendor/runic/lib/workflow/reduce.ex b/vendor/runic/lib/workflow/reduce.ex
new file mode 100644
index 0000000..2f86b65
--- /dev/null
+++ b/vendor/runic/lib/workflow/reduce.ex
@@ -0,0 +1,21 @@
+defmodule Runic.Workflow.Reduce do
+ @moduledoc """
+ Component represention of a reduce operation to implement the component protocol
+ for connecting a reduce to other components in a workflow.
+
+ ## Runtime Context
+
+ Reduce components support `context/1` in their reducer function. Context is
+ resolved via the reduce component's name in the workflow's `run_context`.
+
+ Runic.reduce(0, fn x, acc -> acc + x * context(:weight) end, name: :weighted_sum)
+ """
+ defstruct [
+ :name,
+ :fan_in,
+ :hash,
+ :closure,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/rehydration.ex b/vendor/runic/lib/workflow/rehydration.ex
new file mode 100644
index 0000000..13db3cf
--- /dev/null
+++ b/vendor/runic/lib/workflow/rehydration.ex
@@ -0,0 +1,319 @@
+defmodule Runic.Workflow.Rehydration do
+ @moduledoc """
+ Lineage classification and hybrid rehydration for checkpointed workflows.
+
+ Classifies facts into hot (needed for forward execution) and cold (historical)
+ sets, enabling memory-efficient recovery by keeping only hot fact values in
+ memory while replacing cold facts with lightweight `FactRef` structs.
+
+ ## Hot Fact Categories
+
+ 1. **Pending runnable inputs** — Facts on `:runnable` or `:matchable` edges,
+ waiting to be consumed by downstream nodes.
+
+ 2. **Active frontier** — Latest-generation facts in each causal lineage
+ (facts that are not parents of any other fact in the graph).
+
+ 3. **Meta-ref targets** — Facts produced by nodes referenced via `:meta_ref`
+ edges, needed for runtime meta-context resolution. Classification is
+ kind-aware: no values needed for `:fact_count` / `:step_ran?` kinds.
+
+ 4. **Pending join inputs** — Facts on `:joined` edges waiting for join
+ completion.
+
+ ## Usage
+
+ alias Runic.Workflow.Rehydration
+
+ # Classify facts in a rebuilt workflow
+ %{hot: hot, cold: cold} = Rehydration.classify(workflow)
+
+ # Dehydrate cold facts to FactRefs (frees memory)
+ workflow = Rehydration.dehydrate(workflow, cold)
+
+ # Or use the combined rehydrate/3 for the full flow
+ {workflow, resolver} = Rehydration.rehydrate(workflow, {StoreMod, store_state})
+ """
+
+ alias Runic.Workflow
+ alias Runic.Workflow.{Fact, FactRef, Facts, FactResolver}
+
+ @type classification :: %{hot: MapSet.t(), cold: MapSet.t()}
+
+ @doc """
+ Classifies all fact vertices in the workflow into hot and cold sets.
+
+ Hot facts are needed for forward execution. Cold facts are historical
+ and can be safely replaced with `FactRef` structs.
+
+ Returns `%{hot: MapSet.t(hash), cold: MapSet.t(hash)}`.
+ """
+ @spec classify(Workflow.t(), keyword()) :: classification()
+ def classify(%Workflow{graph: graph} = workflow, _opts \\ []) do
+ # Single O(|V|) scan: collect all fact hashes and parent fact hashes simultaneously
+ {all_fact_hashes, parent_hashes} = scan_facts(graph)
+
+ # Category 1: Facts on :runnable/:matchable edges — O(|activation edges|) via index
+ pending = pending_runnable_input_hashes(graph)
+
+ # Category 2: Frontier — facts not referenced as parent by any other fact
+ frontier = MapSet.difference(all_fact_hashes, parent_hashes)
+
+ # Category 3: Facts produced by meta-ref target nodes (kind-aware)
+ meta_targets = meta_ref_target_hashes(workflow)
+
+ # Category 4: Facts on :joined edges (pending join inputs)
+ join_inputs = pending_join_input_hashes(graph)
+
+ hot =
+ pending
+ |> MapSet.union(frontier)
+ |> MapSet.union(meta_targets)
+ |> MapSet.union(join_inputs)
+
+ cold = MapSet.difference(all_fact_hashes, hot)
+ %{hot: hot, cold: cold}
+ end
+
+ @doc """
+ Replaces cold `Fact` vertices with lightweight `FactRef` structs in the graph.
+
+ Preserves graph topology — edges reference vertex ids (hashes), which are
+ identical between a `Fact` and its corresponding `FactRef`. Only the vertex
+ value in the vertices map is swapped; no edges are modified.
+ """
+ @spec dehydrate(Workflow.t(), MapSet.t()) :: Workflow.t()
+ def dehydrate(%Workflow{graph: graph} = workflow, cold_hashes) do
+ vertices =
+ Enum.reduce(cold_hashes, graph.vertices, fn hash, vertices ->
+ case Map.get(vertices, hash) do
+ %Fact{} = fact ->
+ Map.put(vertices, hash, Facts.to_ref(fact))
+
+ _ ->
+ vertices
+ end
+ end)
+
+ %{workflow | graph: %{graph | vertices: vertices}}
+ end
+
+ @doc """
+ Classifies, dehydrates, and prepares a resolver for a rebuilt workflow.
+
+ Combines `classify/2` and `dehydrate/2` into a single call, returning
+ the dehydrated workflow paired with a `FactResolver` that can resolve
+ any `FactRef` on demand from the backing store.
+
+ ## Example
+
+ workflow = Workflow.from_events(events)
+ {workflow, resolver} = Rehydration.rehydrate(workflow, {ETS, store_state})
+ """
+ @spec rehydrate(Workflow.t(), {module(), term()}, keyword()) :: {Workflow.t(), FactResolver.t()}
+ def rehydrate(%Workflow{} = workflow, store, opts \\ []) do
+ rehydrate_fused(workflow, store, opts)
+ end
+
+ @doc """
+ Single-pass classify+dehydrate. Avoids intermediate hot/cold MapSet allocations.
+
+ Pre-computes edge-based hot criteria, then performs two mini-passes over vertices:
+ 1. Collect parent hashes (needed for frontier detection)
+ 2. Dehydrate cold facts inline based on hot criteria
+
+ Returns the dehydrated workflow paired with a `FactResolver`.
+ """
+ @spec rehydrate_fused(Workflow.t(), {module(), term()}, keyword()) ::
+ {Workflow.t(), FactResolver.t()}
+ def rehydrate_fused(%Workflow{graph: graph} = workflow, store, _opts \\ []) do
+ # Pre-compute edge-based hot sets (bounded by active edges, not total facts)
+ pending = pending_runnable_input_hashes(graph)
+ join_inputs = pending_join_input_hashes(graph)
+ meta_targets = meta_ref_target_hashes(workflow)
+
+ # Pass 1: collect parent hashes from ancestry tuples
+ parent_hashes =
+ Enum.reduce(graph.vertices, MapSet.new(), fn
+ {_hash, %Fact{ancestry: {_, parent}}}, parents -> MapSet.put(parents, parent)
+ {_hash, %FactRef{ancestry: {_, parent}}}, parents -> MapSet.put(parents, parent)
+ _, parents -> parents
+ end)
+
+ # Pass 2: dehydrate cold facts inline
+ vertices =
+ Enum.reduce(graph.vertices, graph.vertices, fn
+ {hash, %Fact{} = fact}, verts ->
+ is_hot =
+ MapSet.member?(pending, hash) or
+ not MapSet.member?(parent_hashes, hash) or
+ MapSet.member?(meta_targets, hash) or
+ MapSet.member?(join_inputs, hash)
+
+ if is_hot, do: verts, else: Map.put(verts, hash, Facts.to_ref(fact))
+
+ _, verts ->
+ verts
+ end)
+
+ workflow = %{workflow | graph: %{graph | vertices: vertices}}
+ {workflow, FactResolver.new(store)}
+ end
+
+ @doc """
+ Resolves hot FactRef vertices back to full Fact structs.
+
+ Used after lean replay to load only the values needed for forward execution.
+ Cold FactRefs remain as lightweight references.
+ """
+ @spec resolve_hot(Workflow.t(), MapSet.t(), FactResolver.t()) ::
+ {Workflow.t(), FactResolver.t()}
+ def resolve_hot(%Workflow{graph: graph} = workflow, hot_hashes, resolver) do
+ resolver = FactResolver.preload(resolver, MapSet.to_list(hot_hashes))
+
+ vertices =
+ Enum.reduce(hot_hashes, graph.vertices, fn hash, vertices ->
+ case Map.get(vertices, hash) do
+ %FactRef{} = ref ->
+ case FactResolver.resolve(ref, resolver) do
+ {:ok, fact} -> Map.put(vertices, hash, fact)
+ {:error, _} -> vertices
+ end
+
+ _ ->
+ vertices
+ end
+ end)
+
+ {%{workflow | graph: %{graph | vertices: vertices}}, resolver}
+ end
+
+ @doc """
+ Heuristic check: returns true if hybrid rehydration is likely to produce
+ meaningful memory savings for this workflow.
+
+ Samples fact values and checks total fact count against thresholds
+ derived from benchmark data.
+ """
+ @spec should_rehydrate?(Workflow.t(), keyword()) :: boolean()
+ def should_rehydrate?(%Workflow{graph: graph}, opts \\ []) do
+ min_facts = Keyword.get(opts, :min_facts, 50)
+ min_value_bytes = Keyword.get(opts, :min_value_bytes, 256)
+ sample_size = Keyword.get(opts, :sample_size, 10)
+
+ facts =
+ graph.vertices
+ |> Map.values()
+ |> Enum.filter(&is_struct(&1, Fact))
+
+ total_facts = length(facts)
+
+ if total_facts < min_facts do
+ false
+ else
+ sample =
+ facts
+ |> Enum.take_random(sample_size)
+ |> Enum.map(fn %Fact{value: v} -> :erlang.external_size(v) end)
+
+ case sample do
+ [] -> false
+ sizes -> Enum.sum(sizes) / length(sizes) >= min_value_bytes
+ end
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Scanning
+ # ---------------------------------------------------------------------------
+
+ # Single pass over all vertices: collects fact hashes and the set of hashes
+ # that appear as a parent_fact_hash in some other fact's ancestry tuple.
+ # O(|V|) time and space.
+ defp scan_facts(graph) do
+ Enum.reduce(Graph.vertices(graph), {MapSet.new(), MapSet.new()}, fn vertex, {all, parents} ->
+ case vertex do
+ %Fact{hash: h, ancestry: {_producer, parent_hash}} ->
+ {MapSet.put(all, h), MapSet.put(parents, parent_hash)}
+
+ %Fact{hash: h} ->
+ {MapSet.put(all, h), parents}
+
+ %FactRef{hash: h, ancestry: {_producer, parent_hash}} ->
+ {MapSet.put(all, h), MapSet.put(parents, parent_hash)}
+
+ %FactRef{hash: h} ->
+ {MapSet.put(all, h), parents}
+
+ _ ->
+ {all, parents}
+ end
+ end)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Category 1 — Pending runnable inputs
+ # ---------------------------------------------------------------------------
+
+ # Facts on :runnable or :matchable activation edges.
+ # Leverages the multigraph edge adjacency index for O(|activation edges|).
+ defp pending_runnable_input_hashes(graph) do
+ for %Graph.Edge{v1: fact} <- Graph.edges(graph, by: [:runnable, :matchable]),
+ is_struct(fact, Fact) or is_struct(fact, FactRef),
+ into: MapSet.new() do
+ Facts.hash(fact)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Category 3 — Meta-ref target facts
+ # ---------------------------------------------------------------------------
+
+ # For each :meta_ref edge (source_node → target_node), collects fact hashes
+ # produced by the target node. Kind-aware: count/boolean meta-refs don't need
+ # values, so their target facts are excluded.
+ defp meta_ref_target_hashes(%Workflow{graph: graph}) do
+ for %Graph.Edge{v2: target, properties: props} <- Graph.edges(graph, by: :meta_ref),
+ kind = Map.get(props || %{}, :kind),
+ kind not in [:fact_count, :step_ran?],
+ reduce: MapSet.new() do
+ acc ->
+ target_hash =
+ case target do
+ %{hash: h} -> h
+ h when is_integer(h) -> h
+ _ -> nil
+ end
+
+ if target_hash do
+ collect_produced_fact_hashes(graph, target_hash, acc)
+ else
+ acc
+ end
+ end
+ end
+
+ @production_labels [:produced, :state_produced, :reduced, :state_initiated]
+
+ defp collect_produced_fact_hashes(graph, node_hash, acc) do
+ for %Graph.Edge{v2: fact} <- Graph.out_edges(graph, node_hash, by: @production_labels),
+ is_struct(fact, Fact) or is_struct(fact, FactRef),
+ into: acc do
+ Facts.hash(fact)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Category 4 — Pending join inputs
+ # ---------------------------------------------------------------------------
+
+ # Facts on :joined edges (fact → join_node) that haven't been relabeled yet.
+ # Leverages the multigraph edge adjacency index.
+ defp pending_join_input_hashes(graph) do
+ for %Graph.Edge{v1: fact} <- Graph.edges(graph, by: :joined),
+ is_struct(fact, Fact) or is_struct(fact, FactRef),
+ into: MapSet.new() do
+ Facts.hash(fact)
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/root.ex b/vendor/runic/lib/workflow/root.ex
new file mode 100644
index 0000000..f4dd65e
--- /dev/null
+++ b/vendor/runic/lib/workflow/root.ex
@@ -0,0 +1,3 @@
+defmodule Runic.Workflow.Root do
+ defstruct []
+end
diff --git a/vendor/runic/lib/workflow/rule.ex b/vendor/runic/lib/workflow/rule.ex
new file mode 100644
index 0000000..759ed44
--- /dev/null
+++ b/vendor/runic/lib/workflow/rule.ex
@@ -0,0 +1,68 @@
+defmodule Runic.Workflow.Rule do
+ alias Runic.Workflow
+ alias Runic.Closure
+
+ defstruct name: nil,
+ arity: nil,
+ workflow: nil,
+ closure: nil,
+ condition_hash: nil,
+ reaction_hash: nil,
+ hash: nil,
+ inputs: nil,
+ outputs: nil,
+ condition_refs: []
+
+ @typedoc """
+ A rule.
+
+ The `condition_refs` field carries compile-time condition reference markers
+ that need to be resolved at connect-time. Each entry is a `{ref_name, target_hash}`
+ tuple where `target_hash` is the hash of the node (Conjunction or reaction Step)
+ that the resolved condition should wire to.
+ """
+ @type t() :: %__MODULE__{
+ name: String.t(),
+ arity: arity(),
+ workflow: Workflow.t(),
+ hash: integer(),
+ condition_hash: integer(),
+ reaction_hash: integer(),
+ closure: Closure.t() | nil,
+ condition_refs: [{atom(), integer()}]
+ }
+
+ def new(opts \\ []) do
+ __MODULE__
+ |> struct!(opts)
+ |> Map.put_new(:name, Uniq.UUID.uuid4())
+ end
+
+ @spec check(Runic.Workflow.Rule.t(), any) :: boolean
+ @doc """
+ Checks a rule's left hand side.
+ """
+ def check(%__MODULE__{} = rule, input) do
+ rule
+ |> Runic.transmute()
+ |> Workflow.plan_eagerly(input)
+ |> Workflow.is_runnable?()
+ end
+
+ @spec run(Runic.Workflow.Rule.t(), any) :: any
+ @doc """
+ Evaluates a rule, checking its left hand side, then evaluating the right.
+ """
+ def run(%__MODULE__{} = rule, input) do
+ rule
+ |> Runic.transmute()
+ |> Workflow.plan_eagerly(input)
+ |> Workflow.react()
+ |> Workflow.raw_productions()
+ |> List.first()
+ |> case do
+ nil -> {:error, :no_conditions_satisfied}
+ result_otherwise -> result_otherwise
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/runnable.ex b/vendor/runic/lib/workflow/runnable.ex
new file mode 100644
index 0000000..ecc3fe9
--- /dev/null
+++ b/vendor/runic/lib/workflow/runnable.ex
@@ -0,0 +1,131 @@
+defmodule Runic.Workflow.Runnable do
+ @moduledoc """
+ A prepared unit of work ready for execution.
+
+ Contains everything needed to execute independently of the source workflow.
+ After execute/2, contains result and events for reducing back into workflow.
+
+ ## Three-Phase Execution Model
+
+ 1. **Prepare** - Extract minimal context from workflow, build a Runnable
+ 2. **Execute** - Run the node's work function in isolation (potentially parallel)
+ 3. **Apply** - Fold events back into the workflow via `apply_event/2`
+
+ The Runnable struct is the carrier between these phases, holding:
+ - The node to invoke
+ - The input fact triggering invocation
+ - Minimal causal context (no full workflow reference)
+ - After execution: result, status, and events for reducing into workflow
+ """
+
+ alias Runic.Workflow.{Fact, CausalContext}
+
+ @type status :: :pending | :completed | :failed | :skipped
+
+ @type t :: %__MODULE__{
+ id: integer() | nil,
+ node: struct(),
+ input_fact: Fact.t(),
+ context: CausalContext.t() | nil,
+ status: status(),
+ result: term() | nil,
+ events: [struct()] | nil,
+ hook_apply_fns: [function()] | nil,
+ error: term() | nil
+ }
+
+ defstruct [
+ :id,
+ :node,
+ :input_fact,
+ :context,
+ :status,
+ :result,
+ :events,
+ :hook_apply_fns,
+ :error
+ ]
+
+ @doc """
+ Creates a new Runnable in pending state.
+
+ The id is a hash of {node.hash, fact.hash} for idempotency tracking.
+ """
+ @spec new(struct(), Fact.t(), CausalContext.t()) :: t()
+ def new(node, fact, context) do
+ %__MODULE__{
+ id: :erlang.phash2({node.hash, fact.hash}),
+ node: node,
+ input_fact: fact,
+ context: context,
+ status: :pending
+ }
+ end
+
+ @doc """
+ Creates a new Runnable with explicit id.
+ """
+ @spec new(integer(), struct(), Fact.t(), CausalContext.t()) :: t()
+ def new(id, node, fact, context) do
+ %__MODULE__{
+ id: id,
+ node: node,
+ input_fact: fact,
+ context: context,
+ status: :pending
+ }
+ end
+
+ @doc """
+ Generates a stable runnable id from node and fact hashes.
+ """
+ @spec runnable_id(struct(), Fact.t()) :: integer()
+ def runnable_id(node, fact) do
+ :erlang.phash2({node.hash, fact.hash})
+ end
+
+ @doc """
+ Marks a runnable as completed with result and events.
+
+ Events are the list of event structs produced by `Invokable.execute/2`.
+ They will be folded into the workflow via `apply_event/2` during the apply phase.
+ """
+ @spec complete(t(), term(), [struct()]) :: t()
+ def complete(%__MODULE__{} = runnable, result, events) when is_list(events) do
+ %{runnable | status: :completed, result: result, events: events}
+ end
+
+ @doc """
+ Marks a runnable as completed with events and hook apply_fns.
+ """
+ @spec complete(t(), term(), [struct()], [function()]) :: t()
+ def complete(%__MODULE__{} = runnable, result, events, hook_apply_fns)
+ when is_list(events) and is_list(hook_apply_fns) do
+ %{
+ runnable
+ | status: :completed,
+ result: result,
+ events: events,
+ hook_apply_fns: hook_apply_fns
+ }
+ end
+
+ @doc """
+ Marks a runnable as failed with an error.
+ """
+ @spec fail(t(), term()) :: t()
+ def fail(%__MODULE__{} = runnable, error) do
+ %{runnable | status: :failed, error: error}
+ end
+
+ @doc """
+ Marks a runnable as skipped with events.
+
+ The events (typically just `ActivationConsumed`) are folded during apply,
+ and downstream nodes are marked as `:upstream_failed`.
+ """
+ @spec skip(t(), [struct()]) :: t()
+ def skip(%__MODULE__{} = runnable, events) when is_list(events) do
+ %{runnable | status: :skipped, events: events}
+ end
+end
diff --git a/vendor/runic/lib/workflow/runnable_completed.ex b/vendor/runic/lib/workflow/runnable_completed.ex
new file mode 100644
index 0000000..5e81238
--- /dev/null
+++ b/vendor/runic/lib/workflow/runnable_completed.ex
@@ -0,0 +1,32 @@
+defmodule Runic.Workflow.RunnableCompleted do
+ @moduledoc """
+ Event recording that a runnable completed execution successfully.
+
+ Fields:
+
+ - `duration_ms` — wall-clock execution time in milliseconds (monotonic)
+ - `duration_us` — wall-clock execution time in microseconds when preserved by the producer
+ - `attempt` — zero-based attempt index (0 = first try, 1 = first retry, etc.)
+ - `result_fact` — the `%Fact{}` produced by the step
+ """
+
+ @type t :: %__MODULE__{
+ runnable_id: term(),
+ node_hash: non_neg_integer(),
+ result_fact: Runic.Workflow.Fact.t(),
+ completed_at: integer(),
+ attempt: non_neg_integer(),
+ duration_ms: non_neg_integer(),
+ duration_us: non_neg_integer() | nil
+ }
+
+ defstruct [
+ :runnable_id,
+ :node_hash,
+ :result_fact,
+ :completed_at,
+ :attempt,
+ :duration_ms,
+ :duration_us
+ ]
+end
diff --git a/vendor/runic/lib/workflow/runnable_dispatched.ex b/vendor/runic/lib/workflow/runnable_dispatched.ex
new file mode 100644
index 0000000..92879b8
--- /dev/null
+++ b/vendor/runic/lib/workflow/runnable_dispatched.ex
@@ -0,0 +1,28 @@
+defmodule Runic.Workflow.RunnableDispatched do
+ @moduledoc """
+ Event recording that a runnable was dispatched for execution.
+
+ Captures the dispatch moment including the resolved policy (with non-serializable
+ fields like `fallback` stripped) and the attempt number.
+ """
+
+ @type t :: %__MODULE__{
+ runnable_id: term(),
+ node_name: atom() | binary() | nil,
+ node_hash: non_neg_integer(),
+ input_fact: Runic.Workflow.Fact.t(),
+ dispatched_at: integer(),
+ policy: Runic.Workflow.SchedulerPolicy.t(),
+ attempt: non_neg_integer()
+ }
+
+ defstruct [
+ :runnable_id,
+ :node_name,
+ :node_hash,
+ :input_fact,
+ :dispatched_at,
+ :policy,
+ :attempt
+ ]
+end
diff --git a/vendor/runic/lib/workflow/runnable_failed.ex b/vendor/runic/lib/workflow/runnable_failed.ex
new file mode 100644
index 0000000..f4680fd
--- /dev/null
+++ b/vendor/runic/lib/workflow/runnable_failed.ex
@@ -0,0 +1,32 @@
+defmodule Runic.Workflow.RunnableFailed do
+ @moduledoc """
+ Event recording that a runnable failed permanently (retries exhausted).
+
+ Fields:
+
+ - `duration_us` — wall-clock execution time in microseconds when preserved by the producer
+ - `attempts` — total number of execution attempts (initial + retries)
+ - `failure_action` — the `on_failure` action taken: `:halt` or `:skip`
+ - `error` — the error term from the last failed attempt
+ """
+
+ @type t :: %__MODULE__{
+ runnable_id: term(),
+ node_hash: non_neg_integer(),
+ error: term(),
+ failed_at: integer(),
+ duration_us: non_neg_integer() | nil,
+ attempts: non_neg_integer(),
+ failure_action: :halt | :skip
+ }
+
+ defstruct [
+ :runnable_id,
+ :node_hash,
+ :error,
+ :failed_at,
+ :duration_us,
+ :attempts,
+ :failure_action
+ ]
+end
diff --git a/vendor/runic/lib/workflow/saga.ex b/vendor/runic/lib/workflow/saga.ex
new file mode 100644
index 0000000..b1c2101
--- /dev/null
+++ b/vendor/runic/lib/workflow/saga.ex
@@ -0,0 +1,131 @@
+defmodule Runic.Workflow.Saga do
+ @moduledoc """
+ A sequential transaction pipeline with compensating actions for failure recovery.
+
+ A Saga orchestrates a series of steps that must all succeed or be rolled back.
+ Each transaction step has a paired compensation that undoes its effect. If any
+ step fails, previously completed steps are compensated in reverse order. Think
+ of it as a more powerful `with` statement where each clause has a paired undo.
+
+ Unlike the `Aggregate` component (CQRS/ES semantics) or `ProcessManager`
+ (event-driven orchestration), a Saga is an explicit forward-then-compensate
+ pipeline with sequential execution semantics.
+
+ ## How It Works
+
+ At compile time a Saga is lowered into standard Runic primitives:
+
+ - An **Accumulator** that tracks the saga's execution state as a map with keys:
+ - `:status` — one of `:running`, `:completed`, or `:aborted`
+ - `:current_step` — the name of the step currently being executed
+ - `:results` — map of step name to result for completed steps
+ - `:failure_reason` — the error reason if a step failed
+ - `:compensated` — map of compensation results
+ - `:step_order` — list of step names in declaration order
+ - One **Rule** per transaction step (forward execution). Each rule gates on
+ the accumulator's state via `state_of()` meta-references and executes when
+ it is the current step's turn.
+ - **Rules** for compensation triggers and compensation steps that fire when
+ a failure is detected, executing compensations in reverse declaration order.
+ - Optional rules for `on_complete` and `on_abort` terminal handlers.
+
+ Compile-time validation ensures every `transaction` has a corresponding
+ `compensate` with the same name.
+
+ Each transaction rule is named `:"_"`.
+
+ ## DSL Syntax
+
+ Sagas are created with the `Runic.saga/2` macro using a block DSL:
+
+ Runic.saga name: :name do
+ transaction :step_name do
+ fn input -> {:ok, result} | {:error, reason} end
+ end
+ compensate :step_name do
+ fn results_map -> compensation_result end
+ end
+
+ on_complete fn results -> value end # optional
+ on_abort fn reason, compensated -> value end # optional
+ end
+
+ ### Directives
+
+ - `transaction :name do fn -> ... end end` — declares a forward step.
+ The function receives the accumulated results map and must return
+ `{:ok, result}` on success or `{:error, reason}` on failure.
+ - `compensate :name do fn -> ... end end` — declares the compensation for
+ a transaction. Receives the results map and returns a compensation result.
+ Every transaction must have a matching compensate (validated at compile time).
+ - `on_complete fn results -> value end` — optional handler called when all
+ steps complete successfully. Receives the final results map.
+ - `on_abort fn reason, compensated -> value end` — optional handler called
+ when the saga is aborted after compensations. Receives the failure reason
+ and a map of compensation results.
+
+ Steps execute in declaration order. On failure, compensations run in reverse
+ declaration order for all previously completed steps.
+
+ ## Examples
+
+ require Runic
+
+ # Order fulfillment saga
+ saga = Runic.saga name: :fulfillment do
+ transaction :reserve_inventory do
+ fn input -> {:ok, :reserved} end
+ end
+ compensate :reserve_inventory do
+ fn %{reserve_inventory: _reservation} -> :released end
+ end
+
+ transaction :charge_payment do
+ fn %{reserve_inventory: _} -> {:ok, :charged} end
+ end
+ compensate :charge_payment do
+ fn %{charge_payment: _charge} -> :refunded end
+ end
+
+ on_complete fn results -> {:saga_completed, results} end
+ on_abort fn reason, compensated -> {:saga_aborted, reason, compensated} end
+ end
+
+ # Add to workflow and execute
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(saga)
+ wrk = Workflow.react(wrk, :start)
+
+ ## Sub-Component Access
+
+ After adding a Saga to a workflow, its internal primitives can be retrieved
+ via `Workflow.get_component/2` using a `{name, kind}` tuple:
+
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(saga)
+
+ # Get the underlying accumulator (tracks saga execution state)
+ [accumulator] = Workflow.get_component(wrk, {:fulfillment, :accumulator})
+
+ # Get all transaction (forward) rules
+ transactions = Workflow.get_component(wrk, {:fulfillment, :transaction})
+ """
+
+ defstruct [
+ :name,
+ :steps,
+ :on_complete,
+ :on_abort,
+ :accumulator,
+ :forward_rules,
+ :compensation_rules,
+ :workflow,
+ :source,
+ :hash,
+ :bindings,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/scheduler_policy.ex b/vendor/runic/lib/workflow/scheduler_policy.ex
new file mode 100644
index 0000000..f4217a2
--- /dev/null
+++ b/vendor/runic/lib/workflow/scheduler_policy.ex
@@ -0,0 +1,284 @@
+defmodule Runic.Workflow.SchedulerPolicy do
+ @moduledoc """
+ Defines per-node scheduling policies for workflow execution.
+
+ A `SchedulerPolicy` controls retry behavior, timeouts, failure handling,
+ execution mode, and other scheduling concerns for individual workflow nodes.
+
+ Policies are resolved at runtime by matching against runnable nodes using
+ a list of `{matcher, policy_map}` tuples. The first matching rule wins,
+ and its policy map is merged over the default policy.
+
+ ## Matcher Types
+
+ - `atom()` — exact match on the node's name
+ - `:default` — catch-all, always matches
+ - `{:name, %Regex{}}` — regex match on the node's name
+ - `{:type, module}` — match on the node's struct module
+ - `{:type, [modules]}` — match on any of the listed struct modules
+ - `fn/1` — custom predicate function receiving the node
+
+ ## Backoff Strategies
+
+ - `:none` — no delay between retries
+ - `:linear` — `min(base_delay_ms * (attempt + 1), max_delay_ms)`
+ - `:exponential` — `min(base_delay_ms * 2^attempt, max_delay_ms)`
+ - `:jitter` — randomized exponential: `min(rand(base_delay_ms * 2^attempt), max_delay_ms)`
+
+ ## Execution Modes
+
+ - `:sync` — standard synchronous execution (default)
+ - `:async` — asynchronous execution within the workflow's react cycle
+ - `:durable` — enables event emission (`%RunnableDispatched{}`, `%RunnableCompleted{}`,
+ `%RunnableFailed{}`) for crash recovery and audit trails. Used by `Runic.Runner.Worker`
+ to persist runnable lifecycle events in the workflow log.
+
+ ## Fallback Functions
+
+ When all retries are exhausted and a `fallback` function is set, it receives
+ `(runnable, error)` and must return one of:
+
+ - `%Runnable{}` — a modified runnable to execute once (no further retries)
+ - `{:retry_with, %{key: value}}` — overrides merged into `meta_context`, then executed once
+ - `{:value, term}` — a synthetic value used as the step's output
+
+ Any other return value causes the runnable to fail with `{:invalid_fallback_return, value}`.
+
+ ## Example
+
+ alias Runic.Workflow.SchedulerPolicy
+
+ policies = [
+ {:call_llm, %{max_retries: 3, backoff: :exponential, timeout_ms: 30_000}},
+ {{:type, Runic.Workflow.Step}, %{max_retries: 1, backoff: :linear}},
+ {:default, %{timeout_ms: 10_000}}
+ ]
+
+ policy = SchedulerPolicy.resolve(runnable, policies)
+ """
+
+ alias Runic.Workflow.Runnable
+
+ @known_keys [
+ :max_retries,
+ :backoff,
+ :base_delay_ms,
+ :max_delay_ms,
+ :timeout_ms,
+ :on_failure,
+ :fallback,
+ :execution_mode,
+ :priority,
+ :idempotency_key,
+ :deadline_ms,
+ :circuit_breaker,
+ :executor,
+ :executor_opts
+ ]
+
+ defstruct max_retries: 0,
+ backoff: :none,
+ base_delay_ms: 500,
+ max_delay_ms: 30_000,
+ timeout_ms: :infinity,
+ on_failure: :halt,
+ fallback: nil,
+ execution_mode: :sync,
+ priority: :normal,
+ idempotency_key: nil,
+ deadline_ms: nil,
+ circuit_breaker: nil,
+ executor: nil,
+ executor_opts: []
+
+ @type fallback_return ::
+ Runnable.t()
+ | {:retry_with, map()}
+ | {:value, term()}
+
+ @type fallback_fn :: (Runnable.t(), term() -> fallback_return()) | nil
+
+ @type t :: %__MODULE__{
+ max_retries: non_neg_integer(),
+ backoff: :none | :linear | :exponential | :jitter,
+ base_delay_ms: non_neg_integer(),
+ max_delay_ms: non_neg_integer(),
+ timeout_ms: non_neg_integer() | :infinity,
+ on_failure: :halt | :skip,
+ fallback: fallback_fn(),
+ execution_mode: :sync | :async | :durable,
+ priority: :low | :normal | :high | :critical,
+ idempotency_key: term() | nil,
+ deadline_ms: non_neg_integer() | nil,
+ circuit_breaker: map() | nil,
+ executor: module() | :inline | nil,
+ executor_opts: keyword()
+ }
+
+ @doc """
+ Creates a new `SchedulerPolicy` from a map or keyword list.
+
+ Raises `ArgumentError` if any unknown keys are provided.
+ """
+ @spec new(map() | keyword()) :: t()
+ def new(opts) when is_map(opts) do
+ validate_keys!(Map.keys(opts))
+ struct!(__MODULE__, opts)
+ end
+
+ def new(opts) when is_list(opts) do
+ validate_keys!(Keyword.keys(opts))
+ struct!(__MODULE__, opts)
+ end
+
+ @doc """
+ Returns the default policy struct.
+ """
+ @spec default_policy() :: t()
+ def default_policy, do: %__MODULE__{}
+
+ @doc """
+ Resolves a `SchedulerPolicy` for a given runnable by walking a list of
+ `{matcher, policy_map}` tuples top-to-bottom. First match wins.
+
+ The matched policy map is merged over the default policy. If no match is
+ found or `policies` is `nil` or `[]`, returns the default policy.
+ """
+ @spec resolve(Runnable.t(), list() | nil) :: t()
+ def resolve(%Runnable{}, nil), do: %__MODULE__{}
+ def resolve(%Runnable{}, []), do: %__MODULE__{}
+
+ def resolve(%Runnable{node: node}, policies) when is_list(policies) do
+ case Enum.find(policies, fn {matcher, _policy_map} -> matches?(matcher, node) end) do
+ {_matcher, policy_map} ->
+ struct!(__MODULE__, Map.merge(Map.from_struct(%__MODULE__{}), policy_map))
+
+ nil ->
+ %__MODULE__{}
+ end
+ end
+
+ @doc """
+ Merges runtime override policies with workflow base policies.
+
+ In `:merge` mode (default), runtime overrides are prepended to the workflow base.
+ In `:replace` mode, only the runtime overrides are returned.
+
+ When `runtime_overrides` is `nil` or `[]`, returns `workflow_base` unchanged.
+ """
+ @spec merge_policies(list() | nil, list()) :: list()
+ def merge_policies(runtime_overrides, workflow_base) do
+ merge_policies(runtime_overrides, workflow_base, :merge)
+ end
+
+ @spec merge_policies(list() | nil, list(), :merge | :replace) :: list()
+ def merge_policies(nil, workflow_base, _mode), do: workflow_base
+ def merge_policies([], workflow_base, _mode), do: workflow_base
+ def merge_policies(overrides, _workflow_base, :replace), do: overrides
+ def merge_policies(overrides, workflow_base, :merge), do: overrides ++ workflow_base
+
+ # ---------------------------------------------------------------------------
+ # Presets
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Policy preset for LLM / external AI model calls.
+
+ Defaults: 3 retries, exponential backoff (1s base, 30s max), 30s timeout, halt on failure.
+ Override any default via `opts`.
+ """
+ @spec llm_policy(keyword()) :: t()
+ def llm_policy(opts \\ []) do
+ %__MODULE__{
+ max_retries: Keyword.get(opts, :max_retries, 3),
+ backoff: :exponential,
+ base_delay_ms: 1_000,
+ max_delay_ms: 30_000,
+ timeout_ms: Keyword.get(opts, :timeout_ms, 30_000),
+ on_failure: :halt
+ }
+ end
+
+ @doc """
+ Policy preset for I/O-bound operations (HTTP, database, file system).
+
+ Defaults: 2 retries, linear backoff (500ms base), 10s timeout, skip on failure.
+ Override any default via `opts`.
+ """
+ @spec io_policy(keyword()) :: t()
+ def io_policy(opts \\ []) do
+ %__MODULE__{
+ max_retries: Keyword.get(opts, :max_retries, 2),
+ backoff: :linear,
+ base_delay_ms: 500,
+ timeout_ms: Keyword.get(opts, :timeout_ms, 10_000),
+ on_failure: :skip
+ }
+ end
+
+ @doc """
+ Policy preset for fast-fail scenarios: no retries, 5s timeout, halt on failure.
+ """
+ @spec fast_fail() :: t()
+ def fast_fail do
+ %__MODULE__{max_retries: 0, timeout_ms: 5_000, on_failure: :halt}
+ end
+
+ @doc """
+ Merges two policy maps, with `overrides` taking precedence over `base`.
+ """
+ @spec merge(map(), map()) :: map()
+ def merge(base, overrides) when is_map(base) and is_map(overrides) do
+ Map.merge(base, overrides)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Matchers
+ # ---------------------------------------------------------------------------
+
+ defp matches?(matcher, node) when is_atom(matcher) and matcher != :default do
+ case Map.get(node, :name) do
+ nil -> false
+ name -> normalize_name(name) == matcher
+ end
+ end
+
+ defp matches?(:default, _node), do: true
+
+ defp matches?({:name, %Regex{} = regex}, node) do
+ case Map.get(node, :name) do
+ nil -> false
+ name -> Regex.match?(regex, to_string(name))
+ end
+ end
+
+ defp matches?({:type, modules}, node) when is_list(modules) do
+ node.__struct__ in modules
+ end
+
+ defp matches?({:type, module}, node) when is_atom(module) do
+ node.__struct__ == module
+ end
+
+ defp matches?(matcher, node) when is_function(matcher, 1) do
+ matcher.(node)
+ end
+
+ defp normalize_name(name) when is_atom(name), do: name
+ defp normalize_name(nil), do: nil
+
+ defp normalize_name(name) when is_binary(name) do
+ String.to_existing_atom(name)
+ rescue
+ ArgumentError -> name
+ end
+
+ defp validate_keys!(keys) do
+ unknown = keys -- @known_keys
+
+ unless unknown == [] do
+ raise ArgumentError,
+ "unknown keys #{inspect(unknown)} in SchedulerPolicy. Known keys: #{inspect(@known_keys)}"
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/serializer.ex b/vendor/runic/lib/workflow/serializer.ex
new file mode 100644
index 0000000..bcf4f38
--- /dev/null
+++ b/vendor/runic/lib/workflow/serializer.ex
@@ -0,0 +1,194 @@
+defmodule Runic.Workflow.Serializer do
+ @moduledoc """
+ Behaviour for workflow serialization to various graph formats.
+
+ Implements serializers for:
+ - Mermaid (flowcharts and sequence diagrams)
+ - DOT (Graphviz)
+ - Cytoscape.js (JSON elements)
+ - Edgelist (simple edge pairs)
+
+ ## Usage
+
+ # Serialize workflow structure (excludes memory/runtime state)
+ Runic.Workflow.Serializer.Mermaid.serialize(workflow)
+
+ # Serialize causal reactions (for sequence diagrams)
+ Runic.Workflow.Serializer.Mermaid.serialize_causal(workflow)
+
+ ## Edge Labels
+
+ The workflow graph uses a multigraph with labeled edges:
+ - `:flow` - Static dataflow connections between steps
+ - `:component_of` - Component hierarchy (with :kind in properties)
+ - `:produced` / `:state_produced` / `:reduced` - Causal memory edges
+ - `:matchable` / `:runnable` / `:ran` / `:satisfied` - Runtime state edges
+ """
+
+ @type serialization_opts :: [
+ include_memory: boolean(),
+ include_facts: boolean(),
+ direction: :TB | :LR | :BT | :RL,
+ title: String.t() | nil
+ ]
+
+ @callback serialize(Runic.Workflow.t(), serialization_opts()) :: String.t() | map() | list()
+
+ @doc """
+ Returns a unique, Mermaid-safe node ID for a vertex.
+ """
+ def node_id(%{hash: hash}) when is_integer(hash), do: "n#{hash}"
+ def node_id(%{hash: hash}) when is_binary(hash), do: "n#{:erlang.phash2(hash)}"
+ def node_id(%Runic.Workflow.Root{}), do: "root"
+ def node_id(hash) when is_integer(hash), do: "n#{hash}"
+ def node_id(other), do: "n#{:erlang.phash2(other)}"
+
+ @doc """
+ Returns a display label for a vertex node.
+ """
+ def node_label(%Runic.Workflow.Root{}), do: "root"
+ def node_label(%Runic.Workflow.Step{name: name}) when not is_nil(name), do: escape_label(name)
+ def node_label(%Runic.Workflow.Condition{hash: hash}), do: "Condition(#{hash})"
+
+ def node_label(%Runic.Workflow.FanOut{name: name}) when not is_nil(name),
+ do: "FanOut: #{escape_label(name)}"
+
+ def node_label(%Runic.Workflow.FanOut{hash: hash}), do: "FanOut(#{hash})"
+
+ def node_label(%Runic.Workflow.FanIn{hash: hash}), do: "FanIn(#{hash})"
+
+ def node_label(%Runic.Workflow.Join{hash: hash}), do: "Join(#{hash})"
+
+ def node_label(%Runic.Workflow.Accumulator{name: name}) when not is_nil(name),
+ do: "Acc: #{escape_label(name)}"
+
+ def node_label(%Runic.Workflow.Accumulator{hash: hash}), do: "Accumulator(#{hash})"
+
+ def node_label(%Runic.Workflow.Rule{name: name}) when not is_nil(name),
+ do: "Rule: #{escape_label(name)}"
+
+ def node_label(%Runic.Workflow.Rule{hash: hash}), do: "Rule(#{hash})"
+
+ def node_label(%Runic.Workflow.Map{name: name}) when not is_nil(name),
+ do: "Map: #{escape_label(name)}"
+
+ def node_label(%Runic.Workflow.Map{hash: hash}), do: "Map(#{hash})"
+
+ def node_label(%Runic.Workflow.Reduce{name: name}) when not is_nil(name),
+ do: "Reduce: #{escape_label(name)}"
+
+ def node_label(%Runic.Workflow.Reduce{hash: hash}), do: "Reduce(#{hash})"
+
+ def node_label(%Runic.Workflow.StateMachine{name: name}) when not is_nil(name),
+ do: "SM: #{escape_label(name)}"
+
+ def node_label(%Runic.Workflow.StateMachine{hash: hash}), do: "StateMachine(#{hash})"
+
+ def node_label(%Runic.Workflow.Conjunction{hash: hash}), do: "AND(#{hash})"
+
+ def node_label(%Runic.Workflow.Fact{value: value, hash: hash}) do
+ val_str =
+ value
+ |> inspect(limit: 30, printable_limit: 50)
+ |> String.slice(0, 40)
+
+ "Fact: #{val_str} (#{hash})"
+ end
+
+ def node_label(%{name: name}) when not is_nil(name), do: escape_label(name)
+ def node_label(%{hash: hash}), do: "Node(#{hash})"
+ def node_label(other), do: inspect(other, limit: 20)
+
+ @doc """
+ Returns the node shape for Mermaid based on node type.
+ """
+ def node_shape(%Runic.Workflow.Root{}), do: {:circle, "((", "))"}
+ def node_shape(%Runic.Workflow.Step{}), do: {:rect, "[", "]"}
+ def node_shape(%Runic.Workflow.Condition{}), do: {:diamond, "{", "}"}
+ def node_shape(%Runic.Workflow.FanOut{}), do: {:parallelogram, "[/", "/]"}
+ def node_shape(%Runic.Workflow.FanIn{}), do: {:parallelogram, "[\\", "\\]"}
+ def node_shape(%Runic.Workflow.Join{}), do: {:hexagon, "{{", "}}"}
+ def node_shape(%Runic.Workflow.Accumulator{}), do: {:cylinder, "[(", ")]"}
+ def node_shape(%Runic.Workflow.Rule{}), do: {:subroutine, "[[", "]]"}
+ def node_shape(%Runic.Workflow.Map{}), do: {:stadium, "([", "])"}
+ def node_shape(%Runic.Workflow.Reduce{}), do: {:stadium, "([", "])"}
+ def node_shape(%Runic.Workflow.StateMachine{}), do: {:cylinder, "[(", ")]"}
+ def node_shape(%Runic.Workflow.Conjunction{}), do: {:diamond, "{", "}"}
+ def node_shape(%Runic.Workflow.Fact{}), do: {:rounded, "(", ")"}
+ def node_shape(_), do: {:rect, "[", "]"}
+
+ @doc """
+ Returns Mermaid CSS class based on node type.
+ """
+ def node_class(%Runic.Workflow.Root{}), do: "root"
+ def node_class(%Runic.Workflow.Step{}), do: "step"
+ def node_class(%Runic.Workflow.Condition{}), do: "condition"
+ def node_class(%Runic.Workflow.FanOut{}), do: "fanout"
+ def node_class(%Runic.Workflow.FanIn{}), do: "fanin"
+ def node_class(%Runic.Workflow.Join{}), do: "join"
+ def node_class(%Runic.Workflow.Accumulator{}), do: "accumulator"
+ def node_class(%Runic.Workflow.Rule{}), do: "rule"
+ def node_class(%Runic.Workflow.Map{}), do: "map"
+ def node_class(%Runic.Workflow.Reduce{}), do: "reduce"
+ def node_class(%Runic.Workflow.StateMachine{}), do: "statemachine"
+ def node_class(%Runic.Workflow.Conjunction{}), do: "conjunction"
+ def node_class(%Runic.Workflow.Fact{}), do: "fact"
+ def node_class(_), do: "default"
+
+ @doc """
+ Escapes special characters for Mermaid labels.
+ """
+ def escape_label(label) when is_atom(label), do: escape_label(to_string(label))
+
+ def escape_label(label) when is_binary(label) do
+ label
+ |> String.replace("\"", "'")
+ |> String.replace("\n", " ")
+ |> String.replace("[", "(")
+ |> String.replace("]", ")")
+ |> String.replace("{", "(")
+ |> String.replace("}", ")")
+ |> String.replace("<", "‹")
+ |> String.replace(">", "›")
+ |> String.replace("#", "♯")
+ |> String.slice(0, 60)
+ end
+
+ def escape_label(other), do: escape_label(inspect(other))
+
+ @doc """
+ Groups vertices by their parent component using :component_of edges.
+ Returns a map of %{component => [child_vertices]}.
+ """
+ def group_by_component(%Runic.Workflow{graph: graph}) do
+ component_edges = Graph.edges(graph, by: :component_of)
+
+ # Build parent -> children map
+ Enum.reduce(component_edges, %{}, fn %{v1: parent, v2: child}, acc ->
+ Map.update(acc, parent, [child], &[child | &1])
+ end)
+ end
+
+ @doc """
+ Returns flow edges only (static dataflow, no memory).
+ """
+ def flow_edges(%Runic.Workflow{graph: graph}) do
+ Graph.edges(graph, by: :flow)
+ end
+
+ @doc """
+ Returns causal memory edges for sequence diagram generation.
+ """
+ def causal_edges(%Runic.Workflow{graph: graph}) do
+ Graph.edges(graph, by: [:produced, :state_produced, :reduced])
+ end
+
+ @doc """
+ Returns all vertices that are invokable nodes (not facts or memory).
+ """
+ def invokable_vertices(%Runic.Workflow{graph: graph}) do
+ graph
+ |> Graph.vertices()
+ |> Enum.reject(&match?(%Runic.Workflow.Fact{}, &1))
+ end
+end
diff --git a/vendor/runic/lib/workflow/serializers/cytoscape.ex b/vendor/runic/lib/workflow/serializers/cytoscape.ex
new file mode 100644
index 0000000..7c0a7fd
--- /dev/null
+++ b/vendor/runic/lib/workflow/serializers/cytoscape.ex
@@ -0,0 +1,212 @@
+defmodule Runic.Workflow.Serializers.Cytoscape do
+ @moduledoc """
+ Serializes Runic Workflows to Cytoscape.js JSON element format.
+
+ Output is a list of node and edge elements compatible with Cytoscape.js.
+
+ ## Examples
+
+ # Get Cytoscape elements
+ elements = Runic.Workflow.Serializers.Cytoscape.serialize(workflow)
+
+ # Use with Kino.Cytoscape in LiveBook
+ Kino.Cytoscape.new(elements)
+
+ ## Element Format
+
+ Each element follows Cytoscape.js notation:
+
+ %{
+ data: %{
+ id: "n123",
+ name: "step_name",
+ kind: "step",
+ parent: "nParentHash", # for compound nodes
+ background_color: "#2d3748",
+ shape: "rectangle"
+ }
+ }
+
+ See: https://js.cytoscape.org/#notation/elements-json
+ """
+
+ alias Runic.Workflow
+ alias Runic.Workflow.Serializer
+
+ @behaviour Runic.Workflow.Serializer
+
+ @default_opts [
+ include_memory: false,
+ include_facts: false,
+ include_components: true
+ ]
+
+ @impl true
+ def serialize(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.merge(@default_opts, opts)
+
+ vertices = build_vertices(workflow, opts)
+ edges = build_edges(workflow, opts)
+
+ vertices ++ edges
+ end
+
+ defp build_vertices(%Workflow{graph: graph} = workflow, opts) do
+ component_groups = Serializer.group_by_component(workflow)
+
+ # Build parent mapping (child_id => parent_id)
+ parent_map =
+ component_groups
+ |> Enum.flat_map(fn {parent, children} ->
+ parent_id = Serializer.node_id(parent)
+ Enum.map(children, fn child -> {Serializer.node_id(child), parent_id} end)
+ end)
+ |> Map.new()
+
+ # Get all vertices
+ vertices =
+ graph
+ |> Graph.vertices()
+ |> maybe_filter_facts(opts)
+ |> Enum.map(fn vertex ->
+ build_vertex(vertex, parent_map)
+ end)
+
+ # Add component container nodes if requested
+ if opts[:include_components] do
+ component_nodes =
+ component_groups
+ |> Map.keys()
+ |> Enum.map(&build_component_node/1)
+
+ component_nodes ++ vertices
+ else
+ vertices
+ end
+ end
+
+ defp maybe_filter_facts(vertices, opts) do
+ if opts[:include_facts] do
+ vertices
+ else
+ Enum.reject(vertices, &match?(%Workflow.Fact{}, &1))
+ end
+ end
+
+ defp build_vertex(vertex, parent_map) do
+ id = Serializer.node_id(vertex)
+ label = Serializer.node_label(vertex)
+ kind = Serializer.node_class(vertex)
+ {shape, color} = node_cytoscape_style(vertex)
+
+ data = %{
+ id: id,
+ name: label,
+ kind: kind,
+ background_color: color,
+ shape: shape
+ }
+
+ data =
+ case Map.get(parent_map, id) do
+ nil -> data
+ parent_id -> Map.put(data, :parent, parent_id)
+ end
+
+ data = add_hash_if_present(data, vertex)
+
+ %{data: data}
+ end
+
+ defp build_component_node(component) do
+ id = Serializer.node_id(component)
+ label = Serializer.node_label(component)
+ kind = Serializer.node_class(component)
+ {_shape, color} = node_cytoscape_style(component)
+
+ %{
+ data: %{
+ id: id,
+ name: label,
+ kind: kind,
+ background_color: color,
+ shape: "roundrectangle",
+ is_component: true
+ }
+ }
+ end
+
+ defp add_hash_if_present(data, %{hash: hash}), do: Map.put(data, :hash, hash)
+ defp add_hash_if_present(data, _), do: data
+
+ defp build_edges(%Workflow{} = workflow, opts) do
+ flow_edges = build_flow_edges(workflow)
+
+ if opts[:include_memory] do
+ causal_edges = build_causal_edges(workflow)
+ flow_edges ++ causal_edges
+ else
+ flow_edges
+ end
+ end
+
+ defp build_flow_edges(%Workflow{} = workflow) do
+ workflow
+ |> Serializer.flow_edges()
+ |> Enum.reject(fn %{v1: v1, v2: v2} ->
+ match?(%Workflow.Fact{}, v1) or match?(%Workflow.Fact{}, v2)
+ end)
+ |> Enum.uniq_by(fn %{v1: v1, v2: v2} ->
+ {Serializer.node_id(v1), Serializer.node_id(v2)}
+ end)
+ |> Enum.map(fn %{v1: v1, v2: v2} ->
+ source = Serializer.node_id(v1)
+ target = Serializer.node_id(v2)
+
+ %{
+ data: %{
+ id: "e_#{source}_#{target}",
+ source: source,
+ target: target,
+ label: "flow",
+ edge_type: "flow"
+ }
+ }
+ end)
+ end
+
+ defp build_causal_edges(%Workflow{} = workflow) do
+ workflow
+ |> Serializer.causal_edges()
+ |> Enum.map(fn %{v1: v1, v2: v2, label: label, weight: weight} ->
+ source = Serializer.node_id(v1)
+ target = Serializer.node_id(v2)
+
+ %{
+ data: %{
+ id: "e_#{label}_#{source}_#{target}_#{weight}",
+ source: source,
+ target: target,
+ label: to_string(label),
+ edge_type: "causal",
+ weight: weight
+ }
+ }
+ end)
+ end
+
+ defp node_cytoscape_style(%Workflow.Root{}), do: {"ellipse", "#1a1a2e"}
+ defp node_cytoscape_style(%Workflow.Step{}), do: {"rectangle", "#2d3748"}
+ defp node_cytoscape_style(%Workflow.Condition{}), do: {"diamond", "#553c9a"}
+ defp node_cytoscape_style(%Workflow.FanOut{}), do: {"rhomboid", "#2c5282"}
+ defp node_cytoscape_style(%Workflow.FanIn{}), do: {"rhomboid", "#285e61"}
+ defp node_cytoscape_style(%Workflow.Join{}), do: {"hexagon", "#744210"}
+ defp node_cytoscape_style(%Workflow.Accumulator{}), do: {"barrel", "#22543d"}
+ defp node_cytoscape_style(%Workflow.Rule{}), do: {"octagon", "#742a2a"}
+ defp node_cytoscape_style(%Workflow.Map{}), do: {"round-rectangle", "#1a365d"}
+ defp node_cytoscape_style(%Workflow.Reduce{}), do: {"round-rectangle", "#234e52"}
+ defp node_cytoscape_style(%Workflow.StateMachine{}), do: {"barrel", "#44337a"}
+ defp node_cytoscape_style(%Workflow.Conjunction{}), do: {"diamond", "#5a3e1b"}
+ defp node_cytoscape_style(%Workflow.Fact{}), do: {"ellipse", "#1e3a5f"}
+ defp node_cytoscape_style(_), do: {"rectangle", "#2d3748"}
+end
diff --git a/vendor/runic/lib/workflow/serializers/dot.ex b/vendor/runic/lib/workflow/serializers/dot.ex
new file mode 100644
index 0000000..e986b2b
--- /dev/null
+++ b/vendor/runic/lib/workflow/serializers/dot.ex
@@ -0,0 +1,203 @@
+defmodule Runic.Workflow.Serializers.DOT do
+ @moduledoc """
+ Serializes Runic Workflows to DOT (Graphviz) format.
+
+ ## Examples
+
+ # Generate DOT graph
+ dot = Runic.Workflow.Serializers.DOT.serialize(workflow)
+
+ # Write to file and render
+ File.write!("workflow.dot", dot)
+ System.cmd("dot", ["-Tpng", "workflow.dot", "-o", "workflow.png"])
+ """
+
+ alias Runic.Workflow
+ alias Runic.Workflow.Serializer
+
+ @behaviour Runic.Workflow.Serializer
+
+ @default_opts [
+ direction: :TB,
+ include_memory: false,
+ include_facts: false,
+ title: nil
+ ]
+
+ @impl true
+ def serialize(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.merge(@default_opts, opts)
+ rankdir = direction_to_rankdir(opts[:direction])
+
+ name = workflow.name || "Workflow"
+ name = escape_dot(name)
+
+ lines = [
+ "digraph \"#{name}\" {",
+ " rankdir=#{rankdir};",
+ " node [fontname=\"Arial\", fontsize=10];",
+ " edge [fontname=\"Arial\", fontsize=9];",
+ ""
+ ]
+
+ # Add node style definitions
+ lines = lines ++ node_style_defs()
+
+ # Get component groupings
+ component_groups = Serializer.group_by_component(workflow)
+
+ # Add subgraphs for components
+ lines = add_subgraphs(lines, workflow, component_groups)
+
+ # Add standalone nodes
+ lines = add_standalone_nodes(lines, workflow, component_groups)
+
+ # Add edges
+ lines = add_edges(lines, workflow, opts)
+
+ lines = lines ++ ["}"]
+
+ Enum.join(lines, "\n")
+ end
+
+ defp direction_to_rankdir(:TB), do: "TB"
+ defp direction_to_rankdir(:LR), do: "LR"
+ defp direction_to_rankdir(:BT), do: "BT"
+ defp direction_to_rankdir(:RL), do: "RL"
+ defp direction_to_rankdir(_), do: "TB"
+
+ defp node_style_defs do
+ [
+ " // Node styles",
+ " node [shape=box, style=\"rounded,filled\"];",
+ ""
+ ]
+ end
+
+ defp add_subgraphs(lines, _workflow, component_groups) do
+ {lines, _idx} =
+ Enum.reduce(component_groups, {lines, 0}, fn {component, children}, {acc, idx} ->
+ component_label =
+ component
+ |> Serializer.node_label()
+ |> escape_dot()
+
+ invokable_children =
+ children
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+ |> Enum.uniq_by(&Serializer.node_id/1)
+
+ if Enum.empty?(invokable_children) do
+ {acc, idx}
+ else
+ subgraph_lines = [
+ "",
+ " subgraph cluster_#{idx} {",
+ " label=\"#{component_label}\";",
+ " style=filled;",
+ " fillcolor=\"#1a202c\";",
+ " fontcolor=white;",
+ ""
+ ]
+
+ child_lines = Enum.map(invokable_children, &render_node(&1, 2))
+
+ subgraph_lines = subgraph_lines ++ child_lines ++ [" }"]
+ {acc ++ subgraph_lines, idx + 1}
+ end
+ end)
+
+ lines
+ end
+
+ defp add_standalone_nodes(lines, %Workflow{graph: graph}, component_groups) do
+ grouped_hashes =
+ component_groups
+ |> Map.values()
+ |> List.flatten()
+ |> MapSet.new(&Serializer.node_id/1)
+
+ standalone =
+ graph
+ |> Graph.vertices()
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+ |> Enum.reject(fn v -> MapSet.member?(grouped_hashes, Serializer.node_id(v)) end)
+ |> Enum.reject(fn v -> Map.has_key?(component_groups, v) end)
+
+ if Enum.empty?(standalone) do
+ lines
+ else
+ lines ++ [""] ++ Enum.map(standalone, &render_node(&1, 1))
+ end
+ end
+
+ defp add_edges(lines, %Workflow{} = workflow, opts) do
+ flow_edges = Serializer.flow_edges(workflow)
+
+ flow_lines =
+ flow_edges
+ |> Enum.reject(fn %{v1: v1, v2: v2} ->
+ match?(%Workflow.Fact{}, v1) or match?(%Workflow.Fact{}, v2)
+ end)
+ |> Enum.uniq_by(fn %{v1: v1, v2: v2} ->
+ {Serializer.node_id(v1), Serializer.node_id(v2)}
+ end)
+ |> Enum.map(fn %{v1: v1, v2: v2} ->
+ from_id = Serializer.node_id(v1)
+ to_id = Serializer.node_id(v2)
+ " #{from_id} -> #{to_id};"
+ end)
+
+ lines = lines ++ ["", " // Flow edges"] ++ flow_lines
+
+ if opts[:include_memory] do
+ causal_edges = Serializer.causal_edges(workflow)
+
+ causal_lines =
+ Enum.map(causal_edges, fn %{v1: v1, v2: v2, label: label} ->
+ from_id = Serializer.node_id(v1)
+ to_id = Serializer.node_id(v2)
+ " #{from_id} -> #{to_id} [label=\"#{label}\", style=dashed, color=gray];"
+ end)
+
+ lines ++ ["", " // Causal edges"] ++ causal_lines
+ else
+ lines
+ end
+ end
+
+ defp render_node(node, indent_level) do
+ id = Serializer.node_id(node)
+ label = Serializer.node_label(node) |> escape_dot()
+ {shape, fill, font} = node_style(node)
+ indent = String.duplicate(" ", indent_level)
+
+ "#{indent}#{id} [label=\"#{label}\", shape=#{shape}, fillcolor=\"#{fill}\", fontcolor=\"#{font}\"];"
+ end
+
+ defp node_style(%Workflow.Root{}), do: {"circle", "#1a1a2e", "white"}
+ defp node_style(%Workflow.Step{}), do: {"box", "#2d3748", "white"}
+ defp node_style(%Workflow.Condition{}), do: {"diamond", "#553c9a", "white"}
+ defp node_style(%Workflow.FanOut{}), do: {"parallelogram", "#2c5282", "white"}
+ defp node_style(%Workflow.FanIn{}), do: {"parallelogram", "#285e61", "white"}
+ defp node_style(%Workflow.Join{}), do: {"hexagon", "#744210", "white"}
+ defp node_style(%Workflow.Accumulator{}), do: {"cylinder", "#22543d", "white"}
+ defp node_style(%Workflow.Rule{}), do: {"doubleoctagon", "#742a2a", "white"}
+ defp node_style(%Workflow.Map{}), do: {"component", "#1a365d", "white"}
+ defp node_style(%Workflow.Reduce{}), do: {"component", "#234e52", "white"}
+ defp node_style(%Workflow.StateMachine{}), do: {"cylinder", "#44337a", "white"}
+ defp node_style(%Workflow.Conjunction{}), do: {"diamond", "#5a3e1b", "white"}
+ defp node_style(%Workflow.Fact{}), do: {"ellipse", "#1e3a5f", "white"}
+ defp node_style(_), do: {"box", "#2d3748", "white"}
+
+ defp escape_dot(str) when is_atom(str), do: escape_dot(to_string(str))
+
+ defp escape_dot(str) when is_binary(str) do
+ str
+ |> String.replace("\"", "\\\"")
+ |> String.replace("\n", "\\n")
+ |> String.slice(0, 60)
+ end
+
+ defp escape_dot(other), do: escape_dot(inspect(other))
+end
diff --git a/vendor/runic/lib/workflow/serializers/edgelist.ex b/vendor/runic/lib/workflow/serializers/edgelist.ex
new file mode 100644
index 0000000..628857b
--- /dev/null
+++ b/vendor/runic/lib/workflow/serializers/edgelist.ex
@@ -0,0 +1,103 @@
+defmodule Runic.Workflow.Serializers.Edgelist do
+ @moduledoc """
+ Serializes Runic Workflows to simple edgelist format.
+
+ Produces a list of tuples `{from, to, label}` or a string with one edge per line.
+
+ ## Examples
+
+ # Get list of edge tuples
+ edges = Runic.Workflow.Serializers.Edgelist.serialize(workflow)
+ # => [{:root, :tokenize, :flow}, {:tokenize, :count_words, :flow}, ...]
+
+ # Get string format
+ str = Runic.Workflow.Serializers.Edgelist.to_string(workflow)
+ # => "root -> tokenize [flow]\\ntokenize -> count_words [flow]\\n..."
+ """
+
+ alias Runic.Workflow
+ alias Runic.Workflow.Serializer
+
+ @behaviour Runic.Workflow.Serializer
+
+ @default_opts [
+ include_memory: false,
+ include_facts: false,
+ format: :tuples
+ ]
+
+ @impl true
+ def serialize(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.merge(@default_opts, opts)
+
+ edges = collect_edges(workflow, opts)
+
+ case opts[:format] do
+ :tuples -> edges
+ :string -> edges_to_string(edges)
+ _ -> edges
+ end
+ end
+
+ @doc """
+ Returns a string representation of the edgelist.
+ """
+ def to_string(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.put(opts, :format, :string)
+ serialize(workflow, opts)
+ end
+
+ defp collect_edges(%Workflow{} = workflow, opts) do
+ flow_edges =
+ workflow
+ |> Serializer.flow_edges()
+ |> maybe_filter_facts(opts)
+ |> Enum.map(&edge_to_tuple/1)
+
+ if opts[:include_memory] do
+ causal_edges =
+ workflow
+ |> Serializer.causal_edges()
+ |> Enum.map(&edge_to_tuple/1)
+
+ flow_edges ++ causal_edges
+ else
+ flow_edges
+ end
+ |> Enum.uniq()
+ end
+
+ defp maybe_filter_facts(edges, opts) do
+ if opts[:include_facts] do
+ edges
+ else
+ Enum.reject(edges, fn %{v1: v1, v2: v2} ->
+ match?(%Workflow.Fact{}, v1) or match?(%Workflow.Fact{}, v2)
+ end)
+ end
+ end
+
+ defp edge_to_tuple(%{v1: v1, v2: v2, label: label}) do
+ from = vertex_name(v1)
+ to = vertex_name(v2)
+ {from, to, label}
+ end
+
+ defp vertex_name(%Workflow.Root{}), do: :root
+ defp vertex_name(%{name: name}) when not is_nil(name), do: name
+ defp vertex_name(%{hash: hash}), do: hash
+ defp vertex_name(other), do: :erlang.phash2(other)
+
+ defp edges_to_string(edges) do
+ edges
+ |> Enum.map(fn {from, to, label} ->
+ "#{format_name(from)} -> #{format_name(to)} [#{label}]"
+ end)
+ |> Enum.join("\n")
+ end
+
+ defp format_name(name) when is_atom(name), do: Atom.to_string(name)
+ defp format_name(name) when is_binary(name), do: name
+ defp format_name(name) when is_integer(name), do: "n#{name}"
+ defp format_name(name), do: inspect(name)
+end
diff --git a/vendor/runic/lib/workflow/serializers/mermaid.ex b/vendor/runic/lib/workflow/serializers/mermaid.ex
new file mode 100644
index 0000000..b685b33
--- /dev/null
+++ b/vendor/runic/lib/workflow/serializers/mermaid.ex
@@ -0,0 +1,496 @@
+defmodule Runic.Workflow.Serializers.Mermaid do
+ @moduledoc """
+ Serializes Runic Workflows to Mermaid diagram format.
+
+ Supports two diagram types:
+ - **Flowchart**: Shows static workflow structure with components as subgraphs
+ - **Sequence**: Shows causal reactions between facts and steps
+
+ ## Examples
+
+ # Generate flowchart of workflow structure
+ mermaid = Runic.Workflow.Serializers.Mermaid.serialize(workflow)
+
+ # Generate sequence diagram of causal reactions
+ mermaid = Runic.Workflow.Serializers.Mermaid.serialize_causal(workflow)
+
+ # With options
+ mermaid = Runic.Workflow.Serializers.Mermaid.serialize(workflow,
+ direction: :LR,
+ include_memory: false,
+ title: "My Workflow"
+ )
+ """
+
+ alias Runic.Workflow
+ alias Runic.Workflow.Serializer
+
+ @behaviour Runic.Workflow.Serializer
+
+ @default_opts [
+ direction: :TB,
+ include_memory: false,
+ include_facts: false,
+ title: nil
+ ]
+
+ @impl true
+ def serialize(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.merge(@default_opts, opts)
+ direction = opts[:direction] |> to_string() |> String.upcase()
+
+ lines = ["flowchart #{direction}"]
+
+ lines = add_title(lines, opts[:title])
+ lines = add_style_definitions(lines)
+
+ # Get component groupings
+ component_groups = Serializer.group_by_component(workflow)
+
+ # Add subgraphs for components
+ lines = add_component_subgraphs(lines, workflow, component_groups)
+
+ # Add remaining nodes not in subgraphs
+ lines = add_standalone_nodes(lines, workflow, component_groups)
+
+ # Add flow edges
+ lines = add_flow_edges(lines, workflow)
+
+ # Optionally add memory edges
+ lines =
+ if opts[:include_memory] do
+ add_memory_edges(lines, workflow)
+ else
+ lines
+ end
+
+ # Add class assignments
+ lines = add_class_assignments(lines, workflow)
+
+ Enum.join(lines, "\n")
+ end
+
+ @doc """
+ Generates a sequence diagram showing causal reactions.
+
+ Shows how facts flow through top-level components (or standalone nodes).
+ Each column represents a top-level component with sub-component details.
+ Edges show facts traveling across nodes with cycle information.
+ Originating input facts (no ancestry) are displayed with their raw values.
+ """
+ def serialize_causal(%Workflow{} = workflow, opts \\ []) do
+ opts = Keyword.merge(@default_opts, opts)
+
+ lines = ["sequenceDiagram"]
+ lines = add_title(lines, opts[:title])
+
+ causal_edges =
+ Serializer.causal_edges(workflow)
+ |> Enum.sort_by(& &1.weight)
+
+ if Enum.empty?(causal_edges) do
+ lines ++ [" Note over Workflow: No causal reactions yet"]
+ else
+ component_groups = Serializer.group_by_component(workflow)
+ component_info = build_component_info(workflow, component_groups)
+
+ participants = build_participants(workflow, causal_edges, component_info)
+ lines = add_participants(lines, participants)
+
+ input_facts = find_input_facts(workflow, causal_edges)
+ lines = add_input_facts(lines, input_facts, participants)
+
+ lines = add_causal_sequence(lines, workflow, causal_edges, component_info, participants)
+
+ lines
+ end
+ |> Enum.join("\n")
+ end
+
+ defp build_component_info(%Workflow{graph: graph}, component_groups) do
+ component_edges = Graph.edges(graph, by: :component_of)
+
+ child_to_parent =
+ Enum.reduce(component_edges, %{}, fn %{v1: parent, v2: child, properties: props}, acc ->
+ kind = Map.get(props || %{}, :kind, :unknown)
+ Map.put(acc, Serializer.node_id(child), {parent, kind})
+ end)
+
+ top_level_components = Map.keys(component_groups) |> MapSet.new(&Serializer.node_id/1)
+
+ %{
+ child_to_parent: child_to_parent,
+ top_level: top_level_components,
+ groups: component_groups
+ }
+ end
+
+ defp build_participants(%Workflow{graph: graph}, causal_edges, component_info) do
+ producers =
+ causal_edges
+ |> Enum.map(& &1.v1)
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+
+ consumers =
+ causal_edges
+ |> Enum.flat_map(fn %{v2: fact} ->
+ case fact do
+ %Workflow.Fact{ancestry: {_producer_hash, _}} ->
+ graph
+ |> Graph.out_edges(fact)
+ |> Enum.map(& &1.v2)
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+
+ _ ->
+ []
+ end
+ end)
+
+ all_nodes = (producers ++ consumers) |> Enum.uniq()
+
+ all_nodes
+ |> Enum.map(fn node ->
+ node_id = Serializer.node_id(node)
+
+ case Map.get(component_info.child_to_parent, node_id) do
+ {parent, kind} ->
+ parent_id = Serializer.node_id(parent)
+
+ if MapSet.member?(component_info.top_level, parent_id) do
+ {parent, node, kind}
+ else
+ {node, node, :standalone}
+ end
+
+ _ ->
+ {node, node, :standalone}
+ end
+ end)
+ |> Enum.group_by(fn {parent, _, _} -> Serializer.node_id(parent) end)
+ |> Enum.map(fn {parent_id, entries} ->
+ {parent, _, _} = hd(entries)
+
+ children =
+ entries
+ |> Enum.map(fn {_, child, kind} -> {child, kind} end)
+ |> Enum.uniq_by(fn {child, _} -> Serializer.node_id(child) end)
+
+ label = build_participant_label(parent, children)
+ %{id: parent_id, parent: parent, children: children, label: label}
+ end)
+ |> Enum.sort_by(fn %{id: id} -> id end)
+ end
+
+ defp build_participant_label(parent, children) do
+ parent_name = Serializer.node_label(parent) |> Serializer.escape_label()
+
+ child_details =
+ children
+ |> Enum.reject(fn {child, _} -> Serializer.node_id(child) == Serializer.node_id(parent) end)
+ |> Enum.map(fn {child, kind} ->
+ child_name =
+ case child do
+ %{name: name} when not is_nil(name) -> to_string(name)
+ _ -> nil
+ end
+
+ case {kind, child_name} do
+ {:standalone, _} -> nil
+ {k, nil} -> "[#{k}]"
+ {k, name} -> "[#{k}: #{name}]"
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+
+ case child_details do
+ [] -> parent_name
+ details -> "#{parent_name} #{Enum.join(details, ", ")}"
+ end
+ end
+
+ defp add_participants(lines, participants) do
+ Enum.reduce(participants, lines, fn %{id: id, label: label}, acc ->
+ escaped = Serializer.escape_label(label)
+ acc ++ [" participant #{id} as #{escaped}"]
+ end)
+ end
+
+ defp find_input_facts(%Workflow{graph: graph}, causal_edges) do
+ all_vertices = Graph.vertices(graph)
+
+ causal_edges
+ |> Enum.flat_map(fn %{v2: fact} ->
+ case fact do
+ %Workflow.Fact{ancestry: {_producer_hash, parent_fact_hash}} ->
+ Enum.filter(all_vertices, fn
+ %Workflow.Fact{hash: ^parent_fact_hash, ancestry: nil} -> true
+ _ -> false
+ end)
+
+ _ ->
+ []
+ end
+ end)
+ |> Enum.filter(&match?(%Workflow.Fact{}, &1))
+ |> Enum.uniq_by(& &1.hash)
+ end
+
+ defp add_input_facts(lines, [], _participants), do: lines
+
+ defp add_input_facts(lines, input_facts, participants) do
+ first_participant =
+ case participants do
+ [%{id: id} | _] -> id
+ _ -> "Workflow"
+ end
+
+ lines = lines ++ [" Note left of #{first_participant}: Input Facts"]
+
+ Enum.reduce(input_facts, lines, fn %Workflow.Fact{value: value}, acc ->
+ fact_str =
+ value
+ |> inspect(limit: 50, printable_limit: 100)
+ |> Serializer.escape_label()
+
+ acc ++ [" Note left of #{first_participant}: #{fact_str}"]
+ end)
+ end
+
+ defp add_causal_sequence(
+ lines,
+ %Workflow{graph: graph},
+ causal_edges,
+ _component_info,
+ participants
+ ) do
+ participant_lookup =
+ participants
+ |> Enum.flat_map(fn %{id: parent_id, children: children} = p ->
+ [{parent_id, p} | Enum.map(children, fn {child, _} -> {Serializer.node_id(child), p} end)]
+ end)
+ |> Map.new()
+
+ edges_by_cycle =
+ causal_edges
+ |> Enum.group_by(& &1.weight)
+ |> Enum.sort_by(fn {cycle, _} -> cycle end)
+
+ Enum.reduce(edges_by_cycle, lines, fn {cycle, edges}, acc ->
+ acc =
+ acc ++
+ [
+ " rect rgb(40, 40, 60)",
+ " Note right of #{hd(participants).id}: Cycle #{cycle}"
+ ]
+
+ acc =
+ Enum.reduce(edges, acc, fn %{v1: producer, v2: fact, label: label}, inner_acc ->
+ producer_id = Serializer.node_id(producer)
+ producer_participant = Map.get(participant_lookup, producer_id)
+
+ {fact_label, consumers} = get_fact_info(graph, fact, participant_lookup)
+
+ case {producer_participant, consumers} do
+ {nil, _} ->
+ inner_acc
+
+ {%{id: from_id}, []} ->
+ inner_acc ++ [" #{from_id}->>#{from_id}: #{label}: #{fact_label}"]
+
+ {%{id: from_id}, consumer_list} ->
+ Enum.reduce(consumer_list, inner_acc, fn %{id: to_id}, edge_acc ->
+ edge_acc ++ [" #{from_id}->>#{to_id}: #{label}: #{fact_label}"]
+ end)
+ end
+ end)
+
+ acc ++ [" end"]
+ end)
+ end
+
+ defp get_fact_info(graph, fact, participant_lookup) do
+ fact_label =
+ case fact do
+ %Workflow.Fact{value: value} ->
+ value
+ |> inspect(limit: 30, printable_limit: 50)
+ |> String.slice(0, 40)
+ |> Serializer.escape_label()
+
+ _ ->
+ Serializer.node_label(fact)
+ end
+
+ consumers =
+ graph
+ |> Graph.out_edges(fact)
+ |> Enum.map(& &1.v2)
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+ |> Enum.map(fn consumer ->
+ Map.get(participant_lookup, Serializer.node_id(consumer))
+ end)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq_by(& &1.id)
+
+ {fact_label, consumers}
+ end
+
+ # Private helpers
+
+ defp add_title(lines, nil), do: lines
+ defp add_title(lines, title), do: lines ++ [" %% #{title}"]
+
+ defp add_style_definitions(lines) do
+ lines ++
+ [
+ "",
+ " %% Node styles",
+ " classDef root fill:#1a1a2e,stroke:#00d9ff,color:#fff",
+ " classDef step fill:#2d3748,stroke:#4fd1c5,color:#fff",
+ " classDef condition fill:#553c9a,stroke:#b794f4,color:#fff",
+ " classDef fanout fill:#2c5282,stroke:#63b3ed,color:#fff",
+ " classDef fanin fill:#285e61,stroke:#4fd1c5,color:#fff",
+ " classDef join fill:#744210,stroke:#f6ad55,color:#fff",
+ " classDef accumulator fill:#22543d,stroke:#68d391,color:#fff",
+ " classDef rule fill:#742a2a,stroke:#fc8181,color:#fff",
+ " classDef map fill:#1a365d,stroke:#90cdf4,color:#fff",
+ " classDef reduce fill:#234e52,stroke:#81e6d9,color:#fff",
+ " classDef statemachine fill:#44337a,stroke:#d6bcfa,color:#fff",
+ " classDef conjunction fill:#5a3e1b,stroke:#ecc94b,color:#fff",
+ " classDef memory fill:#2d3748,stroke:#a0aec0,color:#fff",
+ " classDef fact fill:#1e3a5f,stroke:#63b3ed,color:#fff,stroke-dasharray:3",
+ " classDef default fill:#2d3748,stroke:#718096,color:#fff",
+ ""
+ ]
+ end
+
+ defp add_component_subgraphs(lines, _workflow, component_groups) do
+ Enum.reduce(component_groups, lines, fn {component, children}, acc ->
+ component_id = Serializer.node_id(component)
+
+ component_label =
+ component
+ |> Serializer.node_label()
+ |> Serializer.escape_label()
+
+ # Filter children to only invokables (not facts) and exclude self-references
+ invokable_children =
+ children
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+ |> Enum.reject(fn child -> Serializer.node_id(child) == component_id end)
+ |> Enum.uniq_by(&Serializer.node_id/1)
+
+ case invokable_children do
+ [] ->
+ # No children other than self - render as standalone node
+ acc ++ ["", render_node(component, 1)]
+
+ _ ->
+ subgraph_lines = [
+ "",
+ " subgraph #{component_id}[\"#{component_label}\"]"
+ ]
+
+ child_lines =
+ Enum.map(invokable_children, fn child ->
+ render_node(child, 2)
+ end)
+
+ subgraph_lines = subgraph_lines ++ child_lines ++ [" end"]
+ acc ++ subgraph_lines
+ end
+ end)
+ end
+
+ defp add_standalone_nodes(lines, %Workflow{graph: graph}, component_groups) do
+ # Find vertices not in any subgraph
+ grouped_hashes =
+ component_groups
+ |> Map.values()
+ |> List.flatten()
+ |> MapSet.new(&Serializer.node_id/1)
+
+ standalone =
+ graph
+ |> Graph.vertices()
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+ |> Enum.reject(fn v -> MapSet.member?(grouped_hashes, Serializer.node_id(v)) end)
+ |> Enum.reject(fn v -> Map.has_key?(component_groups, v) end)
+
+ if Enum.empty?(standalone) do
+ lines
+ else
+ lines ++ [""] ++ Enum.map(standalone, &render_node(&1, 1))
+ end
+ end
+
+ defp add_flow_edges(lines, %Workflow{} = workflow) do
+ edges = Serializer.flow_edges(workflow)
+
+ if Enum.empty?(edges) do
+ lines
+ else
+ edge_lines =
+ edges
+ |> Enum.reject(fn %{v1: v1, v2: v2} ->
+ match?(%Workflow.Fact{}, v1) or match?(%Workflow.Fact{}, v2)
+ end)
+ |> Enum.uniq_by(fn %{v1: v1, v2: v2} ->
+ {Serializer.node_id(v1), Serializer.node_id(v2)}
+ end)
+ |> Enum.map(fn %{v1: v1, v2: v2} ->
+ from_id = Serializer.node_id(v1)
+ to_id = Serializer.node_id(v2)
+ " #{from_id} --> #{to_id}"
+ end)
+
+ lines ++ ["", " %% Flow edges"] ++ edge_lines
+ end
+ end
+
+ defp add_memory_edges(lines, %Workflow{} = workflow) do
+ edges = Serializer.causal_edges(workflow)
+
+ if Enum.empty?(edges) do
+ lines
+ else
+ edge_lines =
+ Enum.map(edges, fn %{v1: v1, v2: v2, label: label} ->
+ from_id = Serializer.node_id(v1)
+ to_id = Serializer.node_id(v2)
+ " #{from_id} -.->|#{label}| #{to_id}"
+ end)
+
+ lines ++ ["", " %% Causal edges"] ++ edge_lines
+ end
+ end
+
+ defp add_class_assignments(lines, %Workflow{graph: graph}) do
+ class_groups =
+ graph
+ |> Graph.vertices()
+ |> Enum.reject(&match?(%Workflow.Fact{}, &1))
+ |> Enum.group_by(&Serializer.node_class/1)
+
+ class_lines =
+ Enum.flat_map(class_groups, fn {class, vertices} ->
+ ids = Enum.map(vertices, &Serializer.node_id/1) |> Enum.join(",")
+ [" class #{ids} #{class}"]
+ end)
+
+ if Enum.empty?(class_lines) do
+ lines
+ else
+ lines ++ [""] ++ class_lines
+ end
+ end
+
+ defp render_node(node, indent_level) do
+ id = Serializer.node_id(node)
+ label = Serializer.node_label(node) |> Serializer.escape_label()
+ {_shape, open, close} = Serializer.node_shape(node)
+ indent = String.duplicate(" ", indent_level)
+
+ "#{indent}#{id}#{open}\"#{label}\"#{close}"
+ end
+end
diff --git a/vendor/runic/lib/workflow/state_machine.ex b/vendor/runic/lib/workflow/state_machine.ex
new file mode 100644
index 0000000..20d4e87
--- /dev/null
+++ b/vendor/runic/lib/workflow/state_machine.ex
@@ -0,0 +1,165 @@
+defmodule Runic.Workflow.StateMachine do
+ @moduledoc """
+ A stateful workflow component that combines an accumulator with reactive rules.
+
+ A StateMachine maintains a piece of state via a reducer function and triggers
+ side-effects (reactors) whenever that state changes. Unlike the `FSM` component
+ which models discrete named states and transitions, StateMachine manages
+ arbitrary state values (counters, maps, lists, etc.) and reacts to them with
+ user-defined logic.
+
+ ## How It Works
+
+ At compile time a StateMachine is lowered into standard Runic primitives:
+
+ - An **Accumulator** that holds the current state value and applies the
+ `:reducer` function to each incoming fact to produce a new state.
+ - One **Rule** per reactor. Each reactor observes the accumulator's current
+ value via `state_of()` meta-references and fires whenever new state is
+ produced.
+
+ This means a StateMachine participates in the workflow graph like any other
+ set of Runic nodes — it is not a special runtime concept.
+
+ ## DSL Syntax
+
+ StateMachines are created with the `Runic.state_machine/1` macro, passing a
+ keyword list of options:
+
+ Runic.state_machine(
+ name: :my_machine,
+ init: initial_value,
+ reducer: fn input, acc -> new_acc end,
+ reactors: [...]
+ )
+
+ ### Options
+
+ - `:name` — atom name for the state machine (required).
+ - `:init` — initial state value. Accepts a literal (auto-wrapped into a
+ zero-arity function) or an explicit `fn -> value end` thunk.
+ - `:reducer` — a 2-arity function `fn input, accumulator -> new_accumulator end`.
+ Supports `context/1` expressions for accessing runtime context values.
+ - `:reactors` — a list of reactor functions or a keyword list of named
+ reactors. Unnamed reactors are auto-named `:"_reactor_"`.
+ Each reactor receives the current state and may return a derived fact.
+ Reactors also support `context/1` expressions.
+
+ ## Examples
+
+ require Runic
+
+ # Basic counter with a named reactor
+ sm = Runic.state_machine(
+ name: :counter,
+ init: 0,
+ reducer: fn x, acc -> acc + x end,
+ reactors: [
+ alert: fn count -> if count > 10, do: {:alert, count} end
+ ]
+ )
+
+ # Literal init value (auto-wrapped to thunk)
+ sm = Runic.state_machine(
+ name: :collector,
+ init: [],
+ reducer: fn item, items -> [item | items] end,
+ reactors: [fn items -> length(items) end]
+ )
+
+ # Using runtime context in reducer and reactors
+ sm = Runic.state_machine(
+ name: :scaled,
+ init: 0,
+ reducer: fn x, acc -> acc + x * context(:multiplier) end,
+ reactors: [
+ log: fn state -> {context(:logger), state} end
+ ]
+ )
+
+ ## Block DSL with `handle`/`react` (Form 2)
+
+ For state machines with complex state and event-driven transitions, the
+ block DSL provides a more expressive form. Each `handle` clause bundles
+ an event match, input pattern, state binding, and state transformation
+ into a named, addressable sub-component. `react` clauses observe state
+ without modifying it.
+
+ Runic.state_machine name: :cart, init: %{items: [], total: 0} do
+ handle :add_item, %{item: item}, state do
+ %{state | items: [item | state.items], total: state.total + item.price}
+ end
+
+ handle :checkout, _, state when state.items != [] do
+ %{state | status: :checked_out}
+ end
+
+ react :high_value do
+ fn %{total: t} when t > 1000 -> {:vip_alert, t} end
+ end
+ end
+
+ ### `handle` clause semantics
+
+ handle event_pattern, input_match, state_var [when state_guard] do
+ body # must return next state
+ end
+
+ - `event_pattern` — atom or pattern matched against the incoming fact's
+ event type discriminator.
+ - `input_match` — pattern match on the event payload / fact value.
+ - `state_var` — binds the current state via `state_of(:sm_name)` meta_ref.
+ - `when state_guard` — optional guard on current state.
+ - `body` — returns the next state value, fed to the accumulator.
+
+ Each `handle` compiles to a named Rule:
+ `:"_"` (e.g., `:cart_add_item`).
+
+ ### `react` clause semantics
+
+ react name do
+ fn state_pattern -> output end
+ end
+
+ - Name is explicitly required (the atom after `react`).
+ - Compiles to a Rule with a `state_of()` condition and a step that
+ produces an output fact.
+ - Does **not** modify state — observation only.
+
+ ### Compilation equivalence
+
+ Both the keyword form (Form 1) and the block DSL (Form 2) produce
+ identical `%StateMachine{}` structs. The `handle` block is sugar for
+ splitting a multi-clause reducer into individually named rules.
+
+ ## Sub-Component Access
+
+ After adding a StateMachine to a workflow, its internal primitives can be
+ retrieved via `Workflow.get_component/2` using a `{name, kind}` tuple:
+
+ alias Runic.Workflow
+
+ wrk = Workflow.new() |> Workflow.add(sm)
+
+ # Get the underlying accumulator
+ [accumulator] = Workflow.get_component(wrk, {:counter, :accumulator})
+
+ # Get all reactor rules
+ reactor_rules = Workflow.get_component(wrk, {:counter, :reactor})
+ """
+
+ defstruct [
+ :name,
+ :init,
+ :reducer,
+ :reactors,
+ :accumulator,
+ :reactor_rules,
+ :workflow,
+ :source,
+ :hash,
+ :bindings,
+ :inputs,
+ :outputs
+ ]
+end
diff --git a/vendor/runic/lib/workflow/step.ex b/vendor/runic/lib/workflow/step.ex
new file mode 100644
index 0000000..5c9e55b
--- /dev/null
+++ b/vendor/runic/lib/workflow/step.ex
@@ -0,0 +1,117 @@
+defmodule Runic.Workflow.Step do
+ @moduledoc """
+ Step nodes transform input facts into output facts.
+
+ A Step receives a fact, applies its work function, and produces a new fact
+ with the result. Steps are the primary computation nodes in a workflow.
+
+ ## Meta Expression Support
+
+ Steps can reference workflow state through meta expressions like `state_of(:component)`.
+ When a Step has meta references, the `:meta_refs` field is populated during
+ macro compilation, and `:meta_ref` edges are drawn during `Component.connect/3`.
+
+ During the prepare phase, these edges are traversed to populate `meta_context` in
+ the `CausalContext`, making the referenced state available during execution.
+
+ ## Runtime Context
+
+ Steps can also reference external runtime values via `context/1` expressions:
+
+ step = Runic.step(fn _x -> context(:api_key) end, name: :call_llm)
+ step = Runic.step(fn x -> x + context(:offset) end, name: :compute)
+
+ When `context/1` is detected, the step's work function is rewritten to arity-2
+ `(input, meta_ctx)` and `meta_refs` are populated with `kind: :context` entries.
+ Values are resolved from the workflow's `run_context` during the prepare phase.
+ """
+
+ alias Runic.Workflow.Step
+ alias Runic.Workflow.Fact
+ alias Runic.Workflow.Components
+ alias Runic.Closure
+
+ @type meta_ref :: %{
+ kind: atom(),
+ target: atom() | integer() | {atom(), atom()},
+ field_path: list(atom()),
+ context_key: atom()
+ }
+
+ @type t :: %__MODULE__{
+ name: String.t() | atom(),
+ work: function(),
+ hash: String.t() | nil,
+ work_hash: String.t() | nil,
+ closure: Closure.t() | nil,
+ inputs: term(),
+ outputs: term(),
+ meta_refs: list(meta_ref())
+ }
+
+ defstruct [
+ :name,
+ :work,
+ :hash,
+ :work_hash,
+ :closure,
+ :inputs,
+ :outputs,
+ meta_refs: []
+ ]
+
+ def new(params) do
+ params_map = if Keyword.keyword?(params), do: Map.new(params), else: params
+
+ struct!(__MODULE__, params_map)
+ |> maybe_hash_work()
+ |> maybe_set_name()
+ end
+
+ defp maybe_set_name(%__MODULE__{name: nil, hash: hash, work: work} = step) do
+ fun_name = work |> Function.info(:name) |> elem(1)
+ %__MODULE__{step | name: "#{fun_name}-#{hash}"}
+ end
+
+ defp maybe_set_name(%__MODULE__{name: nil, hash: hash} = step),
+ do: %__MODULE__{step | name: to_string(hash)}
+
+ defp maybe_set_name(%__MODULE__{name: name} = step) when not is_nil(name), do: step
+
+ defp maybe_hash_work(%Step{work: work, hash: nil} = step),
+ do: Map.put(step, :hash, Components.work_hash(work))
+
+ defp maybe_hash_work(%Step{work: _work, hash: _} = step), do: step
+
+ @doc """
+ Executes the work function of the Lambda step returning the raw unwrapped value.
+ """
+ def run(%__MODULE__{} = step, input) when not is_struct(input, Fact) do
+ Components.run(step.work, input)
+ end
+
+ @doc """
+ Returns whether this step has meta references that need to be resolved
+ during the prepare phase.
+ """
+ @spec has_meta_refs?(t()) :: boolean()
+ def has_meta_refs?(%__MODULE__{meta_refs: meta_refs}), do: meta_refs != []
+
+ @doc """
+ Runs the step work function with meta context available.
+
+ When a step has meta references, its work function is arity 2,
+ receiving `(input, meta_context)`.
+ """
+ @spec run_with_meta_context(t(), term(), map()) :: term()
+ def run_with_meta_context(%__MODULE__{work: work}, input, meta_context)
+ when is_map(meta_context) do
+ arity = Function.info(work, :arity) |> elem(1)
+
+ case arity do
+ 2 -> work.(input, meta_context)
+ 1 -> work.(input)
+ 0 -> work.()
+ end
+ end
+end
diff --git a/vendor/runic/lib/workflow/transmutable.ex b/vendor/runic/lib/workflow/transmutable.ex
new file mode 100644
index 0000000..78075bb
--- /dev/null
+++ b/vendor/runic/lib/workflow/transmutable.ex
@@ -0,0 +1,302 @@
+defprotocol Runic.Transmutable do
+ @moduledoc """
+ Protocol for converting data structures into Runic workflows or components.
+
+ The `Transmutable` protocol enables natural integration of domain-specific data structures
+ into Runic workflows. Any data type that implements this protocol can be converted to a
+ `%Runic.Workflow{}` or a Runic component (Step, Rule, etc.).
+
+ ## Protocol Functions
+
+ | Function | Purpose |
+ |----------|---------|
+ | `transmute/1` | *Deprecated* - use `to_workflow/1` instead |
+ | `to_workflow/1` | Converts data to a `%Runic.Workflow{}` |
+ | `to_component/1` | Converts data to a Runic component (Step, Rule, etc.) |
+
+ ## Built-in Implementations
+
+ | Type | `to_workflow/1` Behavior | `to_component/1` Behavior |
+ |------|-------------------------|--------------------------|
+ | `Runic.Workflow` | Returns itself | Extracts first component or raises |
+ | `Runic.Workflow.Rule` | Wraps rule in workflow | Returns the rule |
+ | `Runic.Workflow.Step` | Wraps step in workflow | Returns the step |
+ | `Runic.Workflow.StateMachine` | Wraps FSM in workflow | Returns the FSM |
+ | `Function` | Creates workflow with function as step | Creates Step wrapping the function |
+ | `List` | Merges transmuted elements | Recursively converts elements |
+ | `Tuple` (AST) | Creates Rule from quoted function | Creates Rule from AST |
+ | `Any` | Creates workflow with constant step | Creates Step returning the value |
+
+ ## Usage
+
+ require Runic
+ alias Runic.Transmutable
+
+ # Convert a function to workflow
+ fn_workflow = Transmutable.to_workflow(fn x -> x * 2 end)
+
+ # Convert a rule to workflow
+ rule = Runic.rule(fn x when x > 0 -> :positive end)
+ rule_workflow = Transmutable.to_workflow(rule)
+
+ # Convert a list of components to merged workflow
+ components = [
+ Runic.step(fn x -> x + 1 end),
+ Runic.step(fn x -> x * 2 end)
+ ]
+ merged_workflow = Transmutable.to_workflow(components)
+
+ # Use the Runic.transmute/1 macro for convenient conversion
+ workflow = Runic.transmute(fn x -> x * 2 end)
+
+ ## Integration with Workflow.merge/2
+
+ The `Transmutable` protocol integrates with `Workflow.merge/2`:
+
+ workflow = Runic.Workflow.new()
+
+ # Merge a rule (transmuted to workflow first)
+ rule = Runic.rule(fn x when x > 0 -> :positive end)
+ workflow = Workflow.merge(workflow, rule)
+
+ # Merge a function directly
+ workflow = Workflow.merge(workflow, fn x -> x * 2 end)
+
+ ## Implementing Custom Transmutable
+
+ defmodule MyApp.DataProcessor do
+ defstruct [:name, :transform_fn]
+ end
+
+ defimpl Runic.Transmutable, for: MyApp.DataProcessor do
+ alias Runic.Workflow
+
+ def transmute(processor), do: to_workflow(processor)
+
+ def to_workflow(%MyApp.DataProcessor{} = processor) do
+ step = Runic.Workflow.Step.new(
+ work: processor.transform_fn,
+ name: processor.name
+ )
+
+ Workflow.new(name: processor.name)
+ |> Workflow.add_step(step)
+ |> Map.put(:components, %{processor.name => processor})
+ end
+
+ def to_component(%MyApp.DataProcessor{} = processor) do
+ Runic.Workflow.Step.new(
+ work: processor.transform_fn,
+ name: processor.name
+ )
+ end
+ end
+
+ See the [Protocols Guide](protocols.html) for more details and examples.
+ """
+ @fallback_to_any true
+
+ @doc """
+ DEPRECATED: Use to_workflow/1 instead.
+ Converts a component to a Runic Workflow.
+ """
+ def transmute(component)
+
+ @doc """
+ Converts a component to a Runic Workflow.
+ """
+ def to_workflow(component)
+
+ @doc """
+ Converts user data to a Runic component.
+ """
+ def to_component(component)
+end
+
+defimpl Runic.Transmutable, for: List do
+ alias Runic.Workflow
+
+ def transmute(list), do: to_workflow(list)
+
+ def to_workflow([first_flowable | remaining_flowables]) do
+ Enum.reduce(remaining_flowables, Runic.Transmutable.to_workflow(first_flowable), fn flowable,
+ wrk ->
+ Workflow.merge(wrk, Runic.Transmutable.to_workflow(flowable))
+ end)
+ end
+
+ def to_component(list) when is_list(list) do
+ # Convert list to a composite workflow component
+ case list do
+ [single_item] ->
+ Runic.Transmutable.to_component(single_item)
+
+ multiple_items ->
+ Runic.Workflow.Step.new(
+ work: fn _input -> Enum.map(multiple_items, &Runic.Transmutable.to_component/1) end,
+ name: "list_processor"
+ )
+ end
+ end
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow do
+ def transmute(wrk), do: wrk
+ def to_workflow(wrk), do: wrk
+
+ def to_component(wrk) do
+ # Workflows can't be converted directly to components
+ # Return the first component from the workflow if available
+ case wrk.components |> Map.values() |> List.first() do
+ nil -> raise ArgumentError, "Cannot convert empty workflow to component"
+ component -> component
+ end
+ end
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.Rule do
+ def transmute(rule), do: to_workflow(rule)
+
+ def to_workflow(rule) do
+ rule.workflow |> Map.put(:components, Map.put(rule.workflow.components, rule.name, rule))
+ end
+
+ def to_component(rule), do: rule
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.Step do
+ alias Runic.Workflow
+ require Runic
+
+ def transmute(step), do: to_workflow(step)
+
+ def to_workflow(step),
+ do: step.hash |> to_string() |> Workflow.new() |> Workflow.add_step(step)
+
+ def to_component(step), do: step
+end
+
+defimpl Runic.Transmutable, for: Tuple do
+ alias Runic.Workflow.Rule
+
+ def transmute(tuple), do: to_workflow(tuple)
+
+ def to_workflow({:fn, _meta, _clauses} = quoted_anonymous_function) do
+ Runic.Transmutable.to_workflow(Rule.new(quoted_anonymous_function))
+ end
+
+ def to_component({:fn, _meta, _clauses} = _quoted_anonymous_function) do
+ # For AST, just create a rule with no closure (old format)
+ Rule.new(closure: nil, arity: 1)
+ end
+end
+
+defimpl Runic.Transmutable, for: Function do
+ alias Runic.Workflow
+
+ def transmute(fun), do: to_workflow(fun)
+
+ def to_workflow(fun) do
+ fun |> Function.info(:name) |> elem(1) |> Workflow.new() |> Workflow.add_step(fun)
+ end
+
+ def to_component(fun) do
+ Runic.Workflow.Step.new(work: fun)
+ end
+end
+
+defimpl Runic.Transmutable, for: Any do
+ require Runic
+ alias Runic.Workflow
+
+ def transmute(anything_else), do: to_workflow(anything_else)
+
+ def to_workflow(anything_else) do
+ work = fn _anything -> anything_else end
+
+ work
+ |> Runic.Workflow.Components.work_hash()
+ |> to_string()
+ |> Workflow.new()
+ |> Workflow.add_step(work)
+ end
+
+ def to_component(%{type: :custom_processor, function: fun, metadata: %{name: name}} = _spec) do
+ # Handle custom processor with metadata
+ Runic.Workflow.Step.new(work: fun, name: name)
+ end
+
+ def to_component(fun) when is_function(fun) do
+ # Handle plain functions
+ Runic.Workflow.Step.new(work: fun)
+ end
+
+ def to_component(anything_else) do
+ # Fallback for other data
+ Runic.Workflow.Step.new(work: fn _anything -> anything_else end)
+ end
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.Condition do
+ alias Runic.Workflow
+
+ def transmute(condition), do: to_workflow(condition)
+
+ def to_workflow(condition) do
+ Workflow.new(to_string(condition.hash))
+ |> Workflow.add(condition)
+ end
+
+ def to_component(condition), do: condition
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.Aggregate do
+ def transmute(aggregate), do: to_workflow(aggregate)
+
+ def to_workflow(%Runic.Workflow.Aggregate{} = aggregate) do
+ aggregate.workflow
+ |> Map.put(:components, Map.put(aggregate.workflow.components, aggregate.name, aggregate))
+ end
+
+ def to_component(aggregate), do: aggregate
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.StateMachine do
+ def transmute(sm), do: to_workflow(sm)
+
+ def to_workflow(%Runic.Workflow.StateMachine{} = sm) do
+ sm.workflow |> Map.put(:components, Map.put(sm.workflow.components, sm.name, sm))
+ end
+
+ def to_component(sm), do: sm
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.Saga do
+ def transmute(saga), do: to_workflow(saga)
+
+ def to_workflow(%Runic.Workflow.Saga{} = saga) do
+ saga.workflow |> Map.put(:components, Map.put(saga.workflow.components, saga.name, saga))
+ end
+
+ def to_component(saga), do: saga
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.FSM do
+ def transmute(fsm), do: to_workflow(fsm)
+
+ def to_workflow(%Runic.Workflow.FSM{} = fsm) do
+ fsm.workflow |> Map.put(:components, Map.put(fsm.workflow.components, fsm.name, fsm))
+ end
+
+ def to_component(fsm), do: fsm
+end
+
+defimpl Runic.Transmutable, for: Runic.Workflow.ProcessManager do
+ def transmute(pm), do: to_workflow(pm)
+
+ def to_workflow(%Runic.Workflow.ProcessManager{} = pm) do
+ pm.workflow |> Map.put(:components, Map.put(pm.workflow.components, pm.name, pm))
+ end
+
+ def to_component(pm), do: pm
+end
diff --git a/vendor/runic/mix.exs b/vendor/runic/mix.exs
new file mode 100644
index 0000000..2f4a170
--- /dev/null
+++ b/vendor/runic/mix.exs
@@ -0,0 +1,138 @@
+defmodule Runic.MixProject do
+ use Mix.Project
+
+ @repo_url "https://github.com/zblanco/runic"
+ @version "0.1.0-alpha.4"
+
+ def project do
+ [
+ app: :runic,
+ version: @version,
+ elixir: "~> 1.18",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ deps: deps(),
+ aliases: aliases(),
+ description: "Powerful workflow graph composition and runtime for Elixir",
+ name: "Runic",
+ package: package(),
+ docs: docs()
+ ]
+ end
+
+ defp docs do
+ [
+ logo: "logo.png",
+ source_url: @repo_url,
+ extras: [
+ "README.md",
+ "guides/cheatsheet.md",
+ "guides/usage-rules.md",
+ "guides/protocols.md",
+ "guides/scheduling.md",
+ "guides/durable-execution.md",
+ "guides/execution-strategies.md",
+ "guides/state-based-components.md"
+ ],
+ groups_for_extras: [
+ Guides: ~r/guides\/.*/
+ ],
+ groups_for_modules: [
+ Core: [Runic, Runic.Workflow],
+ Components: [
+ Runic.Workflow.Step,
+ Runic.Workflow.Rule,
+ Runic.Workflow.Condition,
+ Runic.Workflow.StateMachine,
+ Runic.Workflow.FSM,
+ Runic.Workflow.Aggregate,
+ Runic.Workflow.Saga,
+ Runic.Workflow.ProcessManager,
+ Runic.Workflow.Accumulator,
+ Runic.Workflow.Map,
+ Runic.Workflow.Reduce,
+ Runic.Workflow.Join
+ ],
+ "Scheduling & Execution": [
+ Runic.Workflow.SchedulerPolicy,
+ Runic.Workflow.PolicyDriver,
+ Runic.Workflow.RunnableDispatched,
+ Runic.Workflow.RunnableCompleted,
+ Runic.Workflow.RunnableFailed
+ ],
+ Runner: [
+ Runic.Runner,
+ Runic.Runner.Worker,
+ Runic.Runner.Executor,
+ Runic.Runner.Executor.Task,
+ Runic.Runner.Executor.GenStage,
+ Runic.Runner.Scheduler,
+ Runic.Runner.Scheduler.Default,
+ Runic.Runner.Scheduler.ChainBatching,
+ Runic.Runner.Scheduler.FlowBatch,
+ Runic.Runner.Scheduler.ContractTest,
+ Runic.Runner.Promise,
+ Runic.Runner.PromiseBuilder,
+ Runic.Runner.Store,
+ Runic.Runner.Store.ETS,
+ Runic.Runner.Store.Mnesia,
+ Runic.Runner.Telemetry
+ ],
+ Protocols: [
+ Runic.Workflow.Invokable,
+ Runic.Component,
+ Runic.Transmutable
+ ],
+ Internal: [
+ Runic.Workflow.Fact,
+ Runic.Workflow.FanOut,
+ Runic.Workflow.FanIn,
+ Runic.Workflow.Runnable,
+ Runic.Workflow.Components,
+ Runic.Closure,
+ Runic.ClosureMetadata
+ ]
+ ]
+ ]
+ end
+
+ # Run "mix help compile.app" to learn about applications.
+ def application do
+ [
+ extra_applications: [:logger, :mnesia]
+ ]
+ end
+
+ # Run "mix help deps" to learn about dependencies.
+ defp deps do
+ [
+ {:uniq, "~> 0.6.1"},
+ {:telemetry, "~> 1.0"},
+ {:libgraph, "~> 0.16.1-mg.1", hex: :multigraph},
+ {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true},
+ {:tidewave, "~> 0.4", only: :dev},
+ {:bandit, "~> 1.0", only: :dev},
+ {:benchee, "~> 1.3", only: :dev},
+ {:gen_stage, "~> 1.2"},
+ {:flow, "~> 1.2"}
+ ]
+ end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ defp aliases do
+ [
+ tidewave:
+ "run --no-halt -e 'Agent.start(fn -> Bandit.start_link(plug: Tidewave, port: 4000) end)'"
+ ]
+ end
+
+ defp package do
+ [
+ maintainers: ["Zack White"],
+ licenses: ["Apache-2.0"],
+ links: %{"Github" => @repo_url}
+ ]
+ end
+end