- Add configuration system (config/*.exs, OrgGarden.Config) - Refactor supervision tree with DynamicSupervisor and Registry - Add OrgGarden.Server for serve mode lifecycle management - Add health check HTTP endpoints (Bandit/Plug on :9090) - Add telemetry events for export and watcher operations - Implement graceful shutdown with SIGTERM handling - Add Mix Release support with overlay scripts - Add NixOS module for systemd service deployment - Update documentation with service usage
213 lines
5.5 KiB
Elixir
213 lines
5.5 KiB
Elixir
defmodule OrgGarden.Server do
|
|
@moduledoc """
|
|
Manages the serve mode lifecycle.
|
|
|
|
This GenServer encapsulates the full serve mode workflow:
|
|
1. Run initial export pipeline
|
|
2. Start supervised Watcher + Quartz under DynamicSupervisor
|
|
3. Handle graceful shutdown on termination
|
|
|
|
## Usage
|
|
|
|
# Start the server (blocks until stopped)
|
|
{:ok, pid} = OrgGarden.Server.start_link(
|
|
notes_dir: "/path/to/notes",
|
|
output_dir: "/path/to/output"
|
|
)
|
|
|
|
# Stop gracefully
|
|
OrgGarden.Server.stop()
|
|
"""
|
|
|
|
use GenServer
|
|
|
|
require Logger
|
|
|
|
alias OrgGarden.Config
|
|
|
|
@transforms [OrgGarden.Transforms.Citations]
|
|
|
|
# -------------------------------------------------------------------
|
|
# Client API
|
|
# -------------------------------------------------------------------
|
|
|
|
@doc """
|
|
Start the serve mode server.
|
|
|
|
## Options
|
|
|
|
* `:notes_dir` — path to org-roam notes (required)
|
|
* `:output_dir` — output base directory (required)
|
|
* `:content_dir` — markdown output directory (default: output_dir/content)
|
|
* `:port` — HTTP server port (default: from config)
|
|
* `:ws_port` — WebSocket port (default: from config)
|
|
"""
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Stop the server gracefully.
|
|
"""
|
|
def stop(timeout \\ 10_000) do
|
|
GenServer.stop(__MODULE__, :normal, timeout)
|
|
end
|
|
|
|
@doc """
|
|
Check if the server is running.
|
|
"""
|
|
def running? do
|
|
Process.whereis(__MODULE__) != nil
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# GenServer callbacks
|
|
# -------------------------------------------------------------------
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
Process.flag(:trap_exit, true)
|
|
|
|
notes_dir = Keyword.fetch!(opts, :notes_dir)
|
|
output_dir = Keyword.fetch!(opts, :output_dir)
|
|
content_dir = Keyword.get(opts, :content_dir, Path.join(output_dir, "content"))
|
|
http_port = Keyword.get(opts, :port, Config.get(:http_port, 8080))
|
|
ws_port = Keyword.get(opts, :ws_port, Config.get(:ws_port, 3001))
|
|
|
|
pipeline_opts = Config.pipeline_opts()
|
|
|
|
state = %{
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts,
|
|
http_port: http_port,
|
|
ws_port: ws_port,
|
|
supervisor_pid: nil
|
|
}
|
|
|
|
# Run initial pipeline synchronously
|
|
case run_initial_pipeline(state) do
|
|
:ok ->
|
|
# Start supervised components
|
|
case start_supervisor(state) do
|
|
{:ok, sup_pid} ->
|
|
Logger.info("Server started on http://localhost:#{http_port}")
|
|
Logger.info("Watching #{notes_dir} for changes")
|
|
{:ok, %{state | supervisor_pid: sup_pid}}
|
|
|
|
{:error, reason} ->
|
|
{:stop, reason}
|
|
end
|
|
|
|
{:error, reason} ->
|
|
{:stop, reason}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:EXIT, pid, reason}, %{supervisor_pid: pid} = state) do
|
|
Logger.error("Supervisor exited: #{inspect(reason)}")
|
|
{:stop, {:supervisor_exit, reason}, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:EXIT, _pid, reason}, state) do
|
|
Logger.warning("Linked process exited: #{inspect(reason)}")
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def terminate(reason, state) do
|
|
Logger.info("Server shutting down: #{inspect(reason)}")
|
|
|
|
if state.supervisor_pid do
|
|
# Stop the supervisor gracefully
|
|
DynamicSupervisor.terminate_child(OrgGarden.DynamicSupervisor, state.supervisor_pid)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# Private functions
|
|
# -------------------------------------------------------------------
|
|
|
|
defp run_initial_pipeline(state) do
|
|
%{
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts
|
|
} = state
|
|
|
|
Logger.info("Running initial export pipeline...")
|
|
|
|
# Wipe content directory
|
|
wipe(content_dir)
|
|
|
|
# Export all org files
|
|
case OrgGarden.Export.export_all(notes_dir, output_dir) do
|
|
{:ok, 0} ->
|
|
Logger.warning("No .org files found in #{notes_dir}")
|
|
:ok
|
|
|
|
{:ok, count} ->
|
|
Logger.info("Exported #{count} file(s)")
|
|
|
|
# Run transforms
|
|
{:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts)
|
|
|
|
Enum.each(stats, fn {mod, c} ->
|
|
Logger.info("#{inspect(mod)}: #{c} file(s) modified")
|
|
end)
|
|
|
|
# Generate index
|
|
OrgGarden.Index.generate(content_dir)
|
|
:ok
|
|
|
|
{:error, failures} ->
|
|
Logger.error("Failed to export #{length(failures)} file(s)")
|
|
{:error, {:export_failed, failures}}
|
|
end
|
|
end
|
|
|
|
defp start_supervisor(state) do
|
|
%{
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts,
|
|
http_port: http_port,
|
|
ws_port: ws_port
|
|
} = state
|
|
|
|
child_spec = {
|
|
OrgGarden.Supervisor,
|
|
[
|
|
notes_dir: notes_dir,
|
|
output_dir: output_dir,
|
|
content_dir: content_dir,
|
|
pipeline_opts: pipeline_opts,
|
|
transforms: @transforms,
|
|
port: http_port,
|
|
ws_port: ws_port
|
|
]
|
|
}
|
|
|
|
DynamicSupervisor.start_child(OrgGarden.DynamicSupervisor, child_spec)
|
|
end
|
|
|
|
defp wipe(content_dir) do
|
|
Logger.info("Wiping #{content_dir}")
|
|
File.mkdir_p!(content_dir)
|
|
|
|
content_dir
|
|
|> File.ls!()
|
|
|> Enum.reject(&(&1 == ".gitkeep"))
|
|
|> Enum.each(fn entry ->
|
|
Path.join(content_dir, entry) |> File.rm_rf!()
|
|
end)
|
|
end
|
|
end
|