Skip to main content

lib/council_ex/supervisor.ex

defmodule CouncilEx.Supervisor do
  @moduledoc """
  Optional `DynamicSupervisor` wrapper for grouping council runs.

  CouncilEx itself does not ship a bundled supervisor — `CouncilEx.start/3`
  spawns runners unsupervised (`GenServer.start/3` semantics) and
  `CouncilEx.start_link/3` links them to the caller. Use this module
  only when you want **group management** of multiple runs:

    * Tenant isolation — one supervisor per tenant; bulk-cancel via
      `Supervisor.stop/1` or `terminate_all/1`.
    * Visibility — `which_children/1` enumerates in-flight runs.
    * Lifecycle ownership — runs survive caller exit, die when the
      supervisor exits.

  Children are `:temporary` (no restart on crash) — re-running a
  partially-completed council from scratch wastes tokens and produces
  weird state.

  ## Setup

  Place an instance in your supervision tree:

      children = [
        {CouncilEx.Supervisor, name: MyApp.RunSup}
      ]

  Then start runs against it:

      {:ok, pid} =
        CouncilEx.Supervisor.start_link(MyApp.RunSup, council, input)

      # Fetch the run_id when needed (e.g. for PubSub subscribe across
      # processes, persistence as a durable handle):
      run_id = CouncilEx.RunServer.run_id(pid)

  Pass any `CouncilEx.start/3` opt (e.g. `:registry`, `:recorder`,
  `:verbose`) — they're forwarded to the runner.
  """

  use DynamicSupervisor

  alias CouncilEx.RunServer

  @doc "Start the supervisor. Pass `:name` (required for named lookup)."
  @spec start_link(keyword()) :: Supervisor.on_start()
  def start_link(opts) do
    {init_opts, sup_opts} = Keyword.split(opts, [:name])
    DynamicSupervisor.start_link(__MODULE__, sup_opts, init_opts)
  end

  @impl true
  def init(_opts), do: DynamicSupervisor.init(strategy: :one_for_one)

  @doc """
  Start a run as a child of this supervisor. The runner is linked to
  the supervisor (not the caller).

  Returns `{:ok, pid}`. Fetch the run_id from the pid via
  `CouncilEx.RunServer.run_id/1` if needed, or supply your own with
  the `:run_id` opt. Validation, options, and PubSub semantics match
  `CouncilEx.start/3`.
  """
  @spec start_link(
          Supervisor.supervisor(),
          module() | CouncilEx.DynamicCouncil.t(),
          term(),
          keyword()
        ) :: {:ok, pid()} | {:error, term()}
  def start_link(supervisor, council, input, opts \\ []) do
    case prepare(council, input, opts) do
      {:ok, council_id, spec, server_opts} ->
        spawn(supervisor, council_id, spec, input, server_opts)

      {:error, _} = err ->
        err
    end
  end

  @doc """
  Terminate every child runner under `supervisor`. Synchronous.

  Each runner receives `:shutdown`. Subscribers monitoring a runner pid
  see `:DOWN`. Use this for tenant teardown.
  """
  @spec terminate_all(Supervisor.supervisor()) :: :ok
  def terminate_all(supervisor) do
    for {_, pid, _, _} <- DynamicSupervisor.which_children(supervisor),
        is_pid(pid) do
      DynamicSupervisor.terminate_child(supervisor, pid)
    end

    :ok
  end

  defp prepare(%CouncilEx.DynamicCouncil{} = council, _input, opts) do
    case CouncilEx.DynamicCouncil.validate(council) do
      :ok ->
        spec = CouncilEx.DynamicCouncil.to_spec(council)
        {:ok, "dynamic:" <> council.id, spec, opts}

      {:error, errors} ->
        {:error, {:invalid_council, errors}}
    end
  end

  defp prepare(council_module, _input, opts) when is_atom(council_module) do
    case CouncilEx.validate(council_module) do
      :ok ->
        spec = council_module.__council__()
        {:ok, council_module, spec, opts}

      {:error, errors} ->
        {:error, {:invalid_council, errors}}
    end
  end

  defp spawn(supervisor, council_id, spec, input, opts) do
    run_id = Keyword.get(opts, :run_id) || generate_run_id()
    registry = Keyword.get(opts, :registry, RunServer.default_registry())
    callers = [self() | Process.get(:"$callers", [])]

    child_spec =
      Supervisor.child_spec(
        {RunServer,
         [
           run_id: run_id,
           council: council_id,
           spec: spec,
           input: input,
           user_opts: opts,
           registry: registry,
           callers: callers
         ]},
        restart: :temporary
      )

    case DynamicSupervisor.start_child(supervisor, child_spec) do
      {:ok, pid} -> {:ok, pid}
      {:error, _} = err -> err
    end
  end

  defp generate_run_id do
    "run_" <> (:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false))
  end
end