defmodule Planck.Headless.ResourceStore do @moduledoc """ GenServer started at application boot that holds the loaded resources — the single source of truth for skills, teams, and available models. Resources are loaded once at startup from the directories configured in `Planck.Headless.Config`. New sessions pick up whatever is in the store at the time they start; in-flight sessions are not affected by reloads. ## Reload Planck.Headless.ResourceStore.reload() Triggers a synchronous reload of tools, skills, and teams from disk. Available models are also re-resolved. """ use GenServer require Logger alias Planck.Agent.{Skill, Team} alias Planck.Headless.Config @type t :: %__MODULE__{ tools: [Planck.Agent.Tool.t()], skills: [Skill.t()], teams: %{String.t() => Team.t()}, available_models: [Planck.AI.Model.t()] } defstruct tools: [], skills: [], teams: %{}, available_models: [] # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @doc "Start the ResourceStore under its supervisor." @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc "Return the full resource store state." @spec get() :: t() def get do GenServer.call(__MODULE__, :get) end @doc """ Reload tools, skills, and teams from disk. In-flight sessions keep their original resources. Only new sessions created after this call will see the updated resources. """ @spec reload() :: :ok def reload do GenServer.call(__MODULE__, :reload) end @doc "Replace the sidecar tool list. Called by SidecarManager on nodeup." @spec put_tools([Planck.Agent.Tool.t()]) :: :ok def put_tools(tools) do GenServer.call(__MODULE__, {:put_tools, tools}) end @doc "Clear all sidecar tools. Called by SidecarManager on nodedown." @spec clear_tools() :: :ok def clear_tools do GenServer.call(__MODULE__, :clear_tools) end # --------------------------------------------------------------------------- # GenServer callbacks # --------------------------------------------------------------------------- @impl true def init(_opts) do {:ok, load_resources()} end @impl true def handle_call(:get, _from, state) do {:reply, state, state} end @impl true def handle_call(:reload, _from, _state) do {:reply, :ok, load_resources()} end @impl true def handle_call({:put_tools, tools}, _from, state) do {:reply, :ok, %{state | tools: tools}} end @impl true def handle_call(:clear_tools, _from, state) do {:reply, :ok, %{state | tools: []}} end # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- @spec load_resources() :: t() defp load_resources do skills = Skill.load_all(Config.skills_dirs!()) teams = load_teams(Config.teams_dirs!()) available_models = detect_available_models() %__MODULE__{ skills: skills, teams: teams, available_models: available_models } end @spec load_teams([Path.t()]) :: %{String.t() => Team.t()} defp load_teams(dirs) do dirs |> Enum.flat_map(&scan_teams_dir/1) |> Enum.reduce(%{}, fn {alias, team}, acc -> # Project-local (later in the list) wins on collision. Map.put(acc, alias, team) end) end @spec scan_teams_dir(Path.t()) :: [{String.t(), Team.t()}] defp scan_teams_dir(dir) do expanded = Path.expand(dir) case File.ls(expanded) do {:error, _} -> [] {:ok, entries} -> Enum.flat_map(entries, &load_team_entry(expanded, &1)) end end @spec load_team_entry(Path.t(), String.t()) :: [{String.t(), Team.t()}] defp load_team_entry(dir, entry) do team_dir = Path.join(dir, entry) if File.dir?(team_dir) do case Team.load(team_dir) do {:ok, team} -> [{team.alias, team}] {:error, reason} -> Logger.warning("[Planck.Headless.ResourceStore] skipping #{team_dir}: #{reason}") [] end else [] end end # Cloud models: static LLMDB catalog filtered by API key presence (no network). # Local/custom models: explicitly declared in Config.models!() and already # parsed to Planck.AI.Model structs by the Models Skogsra type — no network, # no timeouts. @spec detect_available_models() :: [Planck.AI.Model.t()] defp detect_available_models do cloud = [:anthropic, :openai, :google] |> Enum.filter(&api_key_set?/1) |> Enum.flat_map(&Planck.AI.list_models/1) cloud ++ Config.models!() end @spec api_key_set?(atom()) :: boolean() defp api_key_set?(:anthropic), do: Config.anthropic_api_key!() != nil defp api_key_set?(:openai), do: Config.openai_api_key!() != nil defp api_key_set?(:google), do: Config.google_api_key!() != nil defp api_key_set?(_), do: false end