Files
org-garden/lib/org_garden/server.ex
Ignacio Ballesteros 01805dbf39 Add service infrastructure for long-running deployment
- 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
2026-02-21 20:38:47 +01:00

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