Skip to main content

lib/jido/runic/runnable_execution.ex

defmodule Jido.Runic.RunnableExecution do
  @moduledoc """
  Shared execution logic for Runic Runnables.

  Provides the canonical pipeline for executing a Runnable and building
  the corresponding completion signal. Used by both local directive execution
  and child agent workers to ensure consistent error handling and signal
  construction.

  ## Usage

  For framework-level execution (directives, workers):

      executed = RunnableExecution.execute(runnable)
      signal = RunnableExecution.completion_signal(executed)

  For custom child agents that need to add logic around execution:

      executed = RunnableExecution.execute(runnable)
      # ... custom post-processing ...
      signal = RunnableExecution.completion_signal(executed, source: "/my/custom/agent")
  """

  alias Runic.Workflow.{Invokable, Runnable}

  @doc """
  Execute a Runnable with standardized error handling.

  Calls `Invokable.execute/2` within a try/rescue/catch block,
  converting any exception or throw into a failed Runnable.
  """
  @spec execute(Runnable.t()) :: Runnable.t()
  def execute(%Runnable{} = runnable) do
    result =
      try do
        Invokable.execute(runnable.node, runnable)
      rescue
        e ->
          Runnable.fail(runnable, format_exception(runnable, e, __STACKTRACE__))
      catch
        kind, reason ->
          Runnable.fail(runnable, format_throw(runnable, kind, reason))
      end

    maybe_telemetry(result)
    result
  end

  @doc """
  Build a completion signal from an executed Runnable.

  Maps the runnable status to the appropriate signal type:
  - `:completed` → `"runic.runnable.completed"`
  - `:failed` → `"runic.runnable.failed"`
  - `:skipped` → `"runic.runnable.completed"`

  ## Options

  - `:source` - Signal source path. Defaults to `"/runic/executor"`.
  """
  @spec completion_signal(Runnable.t(), keyword()) :: Jido.Signal.t()
  def completion_signal(%Runnable{} = executed, opts \\ []) do
    source = Keyword.get(opts, :source, "/runic/executor")

    signal_type =
      case executed.status do
        :completed -> "runic.runnable.completed"
        :failed -> "runic.runnable.failed"
        :skipped -> "runic.runnable.completed"
      end

    Jido.Signal.new!(signal_type, %{runnable: executed}, source: source)
  end

  defp format_exception(runnable, exception, stacktrace) do
    node_label = runnable.node.name || runnable.node.hash || "unknown_node"
    "[#{node_label}] " <> Exception.format(:error, exception, stacktrace)
  end

  defp format_throw(runnable, kind, reason) do
    node_label = runnable.node.name || runnable.node.hash || "unknown_node"
    "[#{node_label}] Caught #{kind}: #{inspect(reason)}"
  end

  defp maybe_telemetry(%Runnable{} = runnable) do
    measurements = %{status: runnable.status}
    metadata = %{node: runnable.node, runnable_id: runnable.id}

    :telemetry.execute([:jido_runic, :runnable, runnable.status], measurements, metadata)
  rescue
    _ -> :ok
  catch
    _, _ -> :ok
  end
end