Skip to main content

lib/el_graph/agent.ex

defmodule ElGraph.Agent do
  @moduledoc """
  장수명 에이전트 = 그래프 + 영속 상태 + 메일박스를 가진 GenServer (SPEC §5).

      defmodule MyApp.ResearchAgent do
        use ElGraph.Agent

        @impl true
        def handle_signal(%ElGraph.Signal{data: data}, _context), do: {:run, data}

        @impl true
        def handle_result({:ok, state}, context), do: MyApp.notify(context.id, state)
      end

      {:ok, pid} = MyApp.ResearchAgent.start_link(graph: graph, id: "r1", checkpointer: cp)
      ElGraph.Agent.send_signal(pid, %ElGraph.Signal{type: "task.assigned", data: %{...}})

  실행은 GenServer 콜백 밖(별도 프로세스, nolink+monitor)에서 돌므로 에이전트는
  실행 중에도 시그널을 받는다. 실행 중 도착한 `{:run, _}`은 큐에 들어가 직렬 처리된다.
  체크포인터가 있으면 재시작 시 미완료 thread를 자동 재개한다 (crash-only, SPEC §5).
  """

  alias ElGraph.Signal

  @typedoc "콜백에 전달되는 컨텍스트: `%{id:, opts:}`"
  @type context :: %{id: term(), opts: keyword()}

  @doc "시그널을 받았을 때 — 그래프를 돌릴지(input과 함께) 무시할지 결정한다."
  @callback handle_signal(Signal.t(), context()) :: {:run, map()} | :ignore

  @doc "실행 결과를 받았을 때. 기본 구현은 no-op."
  @callback handle_result(ElGraph.Executor.result(), context()) :: :ok
  @optional_callbacks handle_result: 2

  defmacro __using__(_opts) do
    quote do
      @behaviour ElGraph.Agent

      def start_link(opts), do: ElGraph.Agent.Server.start_link(__MODULE__, opts)

      def child_spec(opts) do
        %{
          id: Keyword.get(opts, :id, __MODULE__),
          start: {__MODULE__, :start_link, [opts]},
          restart: :permanent
        }
      end

      @impl ElGraph.Agent
      def handle_result(_result, _context), do: :ok

      defoverridable handle_result: 2, child_spec: 1, start_link: 1
    end
  end

  @doc "에이전트에 시그널을 보낸다 (비동기). 멱등 수신을 위해 전달 id를 보장한다."
  @spec send_signal(GenServer.server(), Signal.t()) :: :ok
  def send_signal(server, %Signal{} = signal),
    do: GenServer.cast(server, {:signal, Signal.ensure_id(signal)})

  @doc "에이전트 상태 요약: `%{id:, running:, queued:}` — 실행 중에도 응답한다."
  @spec status(GenServer.server()) :: %{id: term(), running: boolean(), queued: non_neg_integer()}
  def status(server), do: GenServer.call(server, :status)

  @doc "Registry 기반 이름 — 동적 atom을 만들지 않는다 (SPEC §5)."
  @spec via(atom(), term()) :: {:via, Registry, {atom(), term()}}
  def via(registry, id), do: {:via, Registry, {registry, id}}
end