defmodule OrgGarden.Quartz do @moduledoc """ Manages Quartz Node.js process as an Erlang Port. Required environment: - QUARTZ_PATH: path to quartz repo (with node_modules) - NODE_PATH: path to node executable (default: "node") Starts Quartz in serve mode (`npx quartz build --serve`) and forwards all stdout/stderr output to the Logger with a `[quartz]` prefix. If Quartz exits, this GenServer will stop, which triggers the supervisor to restart the entire supervision tree (strategy: :one_for_all). """ use GenServer require Logger alias OrgGarden.Config @shutdown_timeout 5_000 defstruct [:port, :os_pid, :quartz_path, :content_dir, :http_port, :ws_port] # ------------------------------------------------------------------- # Client API # ------------------------------------------------------------------- @doc """ Start the Quartz process as a linked GenServer. ## Options * `:content_dir` — directory where markdown files are located (required) * `:port` — HTTP server port (default: 8080) * `:ws_port` — WebSocket hot reload port (default: 3001) """ def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Check if Quartz is running. """ def running? do Process.whereis(__MODULE__) != nil end # ------------------------------------------------------------------- # GenServer callbacks # ------------------------------------------------------------------- @impl true def init(opts) do Process.flag(:trap_exit, true) quartz_path = Config.get!(:quartz_path) node_path = Config.get(:node_path, "node") content_dir = Keyword.fetch!(opts, :content_dir) http_port = Keyword.get(opts, :port, Config.get(:http_port, 8080)) ws_port = Keyword.get(opts, :ws_port, Config.get(:ws_port, 3001)) cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs") unless File.exists?(cli_path) do raise "Quartz CLI not found at #{cli_path}. Check QUARTZ_PATH." end args = [ cli_path, "build", "--serve", "--directory", content_dir, "--port", to_string(http_port), "--wsPort", to_string(ws_port) ] Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}") Logger.info("[quartz] Working directory: #{quartz_path}") port = Port.open({:spawn_executable, node_path}, [ :binary, :exit_status, :stderr_to_stdout, args: args, cd: quartz_path, env: [{~c"NODE_NO_WARNINGS", ~c"1"}] ]) # Get the OS process ID for graceful shutdown {:os_pid, os_pid} = Port.info(port, :os_pid) state = %__MODULE__{ port: port, os_pid: os_pid, quartz_path: quartz_path, content_dir: content_dir, http_port: http_port, ws_port: ws_port } {:ok, state} end @impl true def handle_info({port, {:data, data}}, %{port: port} = state) do data |> String.split("\n", trim: true) |> Enum.each(&Logger.info("[quartz] #{&1}")) {:noreply, state} end @impl true def handle_info({port, {:exit_status, status}}, %{port: port} = state) do Logger.error("[quartz] Process exited with status #{status}") {:stop, {:quartz_exit, status}, %{state | port: nil, os_pid: nil}} end @impl true def handle_info({:EXIT, port, reason}, %{port: port} = state) do Logger.warning("[quartz] Port terminated: #{inspect(reason)}") {:stop, {:port_exit, reason}, %{state | port: nil, os_pid: nil}} end @impl true def terminate(_reason, %{os_pid: nil}) do # Process already exited :ok end @impl true def terminate(_reason, %{port: port, os_pid: os_pid}) do Logger.info("[quartz] Shutting down gracefully...") # Send SIGTERM to the Node.js process case System.cmd("kill", ["-TERM", to_string(os_pid)], stderr_to_stdout: true) do {_, 0} -> # Wait for graceful exit wait_for_exit(port, @shutdown_timeout) {output, _} -> Logger.warning("[quartz] Failed to send SIGTERM: #{output}") force_close(port, os_pid) end :ok end # ------------------------------------------------------------------- # Private functions # ------------------------------------------------------------------- defp wait_for_exit(port, timeout) do receive do {^port, {:exit_status, status}} -> Logger.info("[quartz] Exited with status #{status}") :ok after timeout -> Logger.warning("[quartz] Shutdown timeout, forcing kill") {:os_pid, os_pid} = Port.info(port, :os_pid) force_close(port, os_pid) end end defp force_close(port, os_pid) do # Send SIGKILL System.cmd("kill", ["-KILL", to_string(os_pid)], stderr_to_stdout: true) # Close the port try do Port.close(port) rescue _ -> :ok end end end