defmodule Planck.Headless.Watcher do @moduledoc """ Watches the configured skill, team, and config directories for file changes and calls `ResourceStore.reload/0` so running agents pick up updated skill descriptions and configuration without restarting. ## Event source vs debounce `FileSystem` (backed by `inotify` on Linux, `FSEvents` on macOS, and `ReadDirectoryChangesW` on Windows) delivers OS-level file events — no polling. However, editors typically emit several events per save (write, chmod, rename) in quick succession, especially those that use atomic saves. A 300ms debounce timer is used so those bursts collapse into a single `ResourceStore.reload/0` call: each new event cancels the pending timer and starts a fresh one; the reload fires only after 300ms of silence. ## Watched paths Directories are derived from `Config` at startup: - `Planck.Headless.Config.skills_dirs!/0` — skill content directories - `Planck.Headless.Config.teams_dirs!/0` — team definition directories - Parent directories of `Planck.Headless.Config.config_files!/0` and `Planck.Headless.Config.env_files!/0` — so config and API-key changes are also detected Only directories that exist on disk are passed to `FileSystem`; missing ones are silently skipped. If no directories exist the watcher starts in a no-op mode. """ use GenServer require Logger alias Planck.Headless.{Config, ResourceStore} @debounce_ms 300 @doc "Start the file watcher under its supervisor." @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @impl true def init(_opts) do dirs = watched_dirs() with [_ | _] <- dirs, {:ok, watcher} <- FileSystem.start_link(dirs: dirs) do FileSystem.subscribe(watcher) Logger.debug("[Planck.Headless.Watcher] watching #{length(dirs)} director(ies)") {:ok, %{watcher: watcher, timer: nil}} else [] -> {:ok, %{watcher: nil, timer: nil}} :ignore -> Logger.warning( "[Watcher] file_system backend unavailable (inotify-tools missing?), running without file watching" ) {:ok, %{watcher: nil, timer: nil}} end end @impl true def handle_info(event, state) def handle_info({:file_event, _pid, {_path, _events}}, state) do if state.timer, do: Process.cancel_timer(state.timer) timer = Process.send_after(self(), :reload, @debounce_ms) {:noreply, %{state | timer: timer}} end def handle_info(:reload, state) do if Planck.Headless.Secrets.resolve() == Planck.Headless.Secrets.EnvFile do Planck.Headless.Secrets.preload_to_env() end ResourceStore.reload() {:noreply, %{state | timer: nil}} end def handle_info(_msg, state) do {:noreply, state} end @spec watched_dirs() :: [Path.t()] defp watched_dirs do skill_dirs = Config.skills_dirs!() |> Enum.map(&Path.expand/1) team_dirs = Config.teams_dirs!() |> Enum.map(&Path.expand/1) # Watch the parent directories of config and env files so renames/writes # (common in editors) are detected even when the file itself doesn't exist yet. config_dirs = (Config.config_files!() ++ Config.env_files!()) |> Enum.map(&(&1 |> Path.expand() |> Path.dirname())) (skill_dirs ++ team_dirs ++ config_dirs) |> Enum.uniq() |> Enum.filter(&File.dir?/1) end end