Use Task.async_stream for parallel org->md export. Configurable via EXPORT_CONCURRENCY env var or :export_concurrency config.
9040 lines
264 KiB
Markdown
9040 lines
264 KiB
Markdown
# Agent Shell Transcript
|
|
|
|
**Agent:** OpenCode
|
|
**Started:** 2026-02-21 16:40:33
|
|
**Working Directory:** /home/ignaciobll/code/org-garden/
|
|
|
|
---
|
|
|
|
## User (2026-02-21 16:40:33)
|
|
|
|
What does it need to be converted into a proper service, could be a nix service configuration, and other improvements for long running programs.
|
|
|
|
|
|
|
|
### Tool Call [completed]: task
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 16:42:01
|
|
|
|
```
|
|
task_id: ses_37f23cae7ffe0edPpg3vlYt4Sl (for resuming to continue this task if needed)
|
|
|
|
<task_result>
|
|
Now I have a complete picture of the project. Let me compile my analysis:
|
|
|
|
---
|
|
|
|
# OrgGarden Service Architecture Analysis
|
|
|
|
## Executive Summary
|
|
|
|
OrgGarden is an Elixir-based org-roam to static website publishing pipeline. It currently operates as a **CLI escript** with some supervisor infrastructure, but lacks several components needed for production-grade long-running service operation.
|
|
|
|
---
|
|
|
|
## 1. Current Architecture
|
|
|
|
### How It Runs Currently
|
|
|
|
| Mode | Entry Point | Behavior |
|
|
|------|-------------|----------|
|
|
| **serve** | `OrgGarden.CLI.handle_serve/1` | Starts supervisor tree, runs forever via `Process.sleep(:infinity)` |
|
|
| **build** | `OrgGarden.CLI.handle_build/1` | One-shot batch process, exits on completion |
|
|
| **export** | `OrgGarden.CLI.handle_export/1` | Batch export, optional watch mode with manual Watcher start |
|
|
|
|
**Key Files:**
|
|
- `/home/ignaciobll/code/org-garden/lib/org_garden/cli.ex` - Escript entry point
|
|
- `/home/ignaciobll/code/org-garden/lib/org_garden/application.ex` - Application callback
|
|
- `/home/ignaciobll/code/org-garden/mix.exs` - Configured as an escript (`main_module: OrgGarden.CLI`)
|
|
|
|
---
|
|
|
|
## 2. Process Supervision
|
|
|
|
### Current Supervisor Structure
|
|
|
|
```
|
|
OrgGarden.AppSupervisor (Application-level, :one_for_one)
|
|
└── Finch (HTTP client pool)
|
|
|
|
OrgGarden.Supervisor (Dynamic, started by CLI, :one_for_all)
|
|
├── OrgGarden.Watcher (GenServer - file system watcher)
|
|
└── OrgGarden.Quartz (GenServer - Node.js port wrapper)
|
|
```
|
|
|
|
### Issues Identified
|
|
|
|
| Issue | Location | Problem |
|
|
|-------|----------|---------|
|
|
| **Disconnected trees** | `application.ex` vs `supervisor.ex` | `OrgGarden.Supervisor` is started dynamically by CLI, not part of the application supervision tree |
|
|
| **No Registry** | - | Named processes (`name: __MODULE__`) prevent multiple instances |
|
|
| **Missing supervision** | `cli.ex:97,209` | Main process just sleeps forever (`Process.sleep(:infinity)`) - crash in CLI = orphaned children |
|
|
| **Export mode** | `cli.ex:200-207` | Watcher started outside supervisor when using `--watch` flag |
|
|
|
|
### Relevant Code
|
|
|
|
**Application.ex (line 7-12):**
|
|
```elixir
|
|
children = [
|
|
{Finch, name: OrgGarden.Finch}
|
|
]
|
|
opts = [strategy: :one_for_one, name: OrgGarden.AppSupervisor]
|
|
```
|
|
|
|
**Supervisor.ex (line 32-38):**
|
|
```elixir
|
|
children = [
|
|
{OrgGarden.Watcher, ...},
|
|
{OrgGarden.Quartz, ...}
|
|
]
|
|
Supervisor.init(children, strategy: :one_for_all)
|
|
```
|
|
|
|
---
|
|
|
|
## 3. State Management
|
|
|
|
### Current State
|
|
|
|
| Component | State Handling | Storage |
|
|
|-----------|----------------|---------|
|
|
| **Watcher** | GenServer state (in-memory) | Transforms, pending debounce timers, paths |
|
|
| **Quartz** | GenServer state (in-memory) | Port reference, config |
|
|
| **Citations** | Transform state passed through | BibTeX entries (cached), Zotero availability flag |
|
|
|
|
### Issues
|
|
|
|
- **No persistent state** - All state is in-memory only
|
|
- **No state recovery** - Crash = lost state, full restart required
|
|
- **No ETS/Mnesia** - No shared state between processes
|
|
- **Transform state per-request** - BibTeX is reloaded on each batch run (but cached during watch mode)
|
|
|
|
---
|
|
|
|
## 4. Error Recovery
|
|
|
|
### Current Behavior
|
|
|
|
| Scenario | Handling | Location |
|
|
|----------|----------|----------|
|
|
| **Quartz exit** | Stops GenServer with `{:stop, {:quartz_exit, status}, state}` | `quartz.ex:103-105` |
|
|
| **File watcher stop** | Stops GenServer with `{:stop, :watcher_stopped, state}` | `watcher.ex:99-101` |
|
|
| **Export failure** | Logs error, continues with other files | `watcher.ex:154-156` |
|
|
| **Emacs export error** | Returns `{:error, reason}`, caller handles | `export.ex:59-63` |
|
|
|
|
### What's Missing
|
|
|
|
- **No circuit breakers** - Zotero/external service failures can cascade
|
|
- **No backoff/retry** - Immediate restart attempts only (via supervisor)
|
|
- **No dead-letter handling** - Failed files are logged but not queued for retry
|
|
- **No crash reporting** - No integration with Sentry/Honeybadger/etc.
|
|
|
|
---
|
|
|
|
## 5. Graceful Shutdown
|
|
|
|
### Current Implementation
|
|
|
|
**Quartz terminate (line 109-117):**
|
|
```elixir
|
|
def terminate(_reason, %{port: port}) when is_port(port) do
|
|
Port.close(port)
|
|
:ok
|
|
rescue
|
|
_ -> :ok
|
|
end
|
|
```
|
|
|
|
**Watcher terminate (line 119-123):**
|
|
```elixir
|
|
def terminate(_reason, state) do
|
|
OrgGarden.teardown_transforms(state.initialized_transforms)
|
|
:ok
|
|
end
|
|
```
|
|
|
|
### Issues
|
|
|
|
- **No shutdown timeout** - `Port.close/1` may not wait for Node.js to exit cleanly
|
|
- **No SIGTERM handling** - Escript mode doesn't trap exits properly
|
|
- **No drain period** - In-flight file processing may be interrupted
|
|
- **Pending debounce timers lost** - Files changed just before shutdown may not be processed
|
|
- **No cleanup of temporary files** - ox-hugo may leave intermediates
|
|
|
|
---
|
|
|
|
## 6. Configuration
|
|
|
|
### Current Configuration Method
|
|
|
|
**Environment Variables Only:**
|
|
|
|
| Variable | Purpose | Default |
|
|
|----------|---------|---------|
|
|
| `QUARTZ_PATH` | Path to Quartz installation | **Required** for serve/build |
|
|
| `NODE_PATH` | Node.js executable | `"node"` |
|
|
| `BIBTEX_FILE` | BibTeX fallback file | None |
|
|
| `ZOTERO_URL` | Zotero Better BibTeX URL | `http://localhost:23119` |
|
|
| `CITATION_MODE` | silent/warn/strict | `warn` |
|
|
| `NOTES_DIR` | Source directory | **Required** (or CLI arg) |
|
|
| `OUTPUT_DIR` | Output directory | Current working directory |
|
|
|
|
### What's Missing
|
|
|
|
- **No config/*.exs files** - Standard Elixir config not used at all
|
|
- **No runtime.exs** - No Release-style configuration
|
|
- **No Application.get_env** - All config via System.get_env directly
|
|
- **No validation** - Missing required vars cause runtime crashes
|
|
- **No secrets management** - Everything plain environment variables
|
|
- **No config reload** - Changes require full restart
|
|
|
|
---
|
|
|
|
## 7. Logging/Observability
|
|
|
|
### Current Logging
|
|
|
|
**Logger Usage:**
|
|
- `Logger.info/1` - General progress messages
|
|
- `Logger.warning/1` - Non-fatal issues (no BibTeX, Zotero unreachable)
|
|
- `Logger.error/1` - Failures (Quartz exit, export failures)
|
|
- `Logger.debug/1` - Detailed resolution tracing
|
|
- `IO.puts/1` - Direct console output (bypasses Logger)
|
|
|
|
**Examples from code:**
|
|
```elixir
|
|
# watcher.ex:71
|
|
Logger.info("Watcher: monitoring #{notes_dir} for .org changes")
|
|
|
|
# quartz.ex:104
|
|
Logger.error("[quartz] Process exited with status #{status}")
|
|
|
|
# Direct IO (cli.ex:94-95)
|
|
IO.puts("==> Server running at http://localhost:#{opts[:port] || 8080}")
|
|
```
|
|
|
|
### What's Missing
|
|
|
|
- **No structured logging** - Plain strings only, no metadata
|
|
- **No log levels configurable** - Hardcoded Logger calls
|
|
- **No telemetry** - No `:telemetry` events for metrics
|
|
- **No tracing** - No OpenTelemetry/distributed tracing
|
|
- **Mixed IO/Logger** - Some output bypasses Logger entirely
|
|
- **No request IDs** - No correlation for file processing events
|
|
- **No metrics** - No processing time, file counts, error rates
|
|
|
|
---
|
|
|
|
## 8. Health Checks
|
|
|
|
### Current Status
|
|
|
|
**None exist.** There are no health check endpoints, liveness probes, or readiness checks.
|
|
|
|
### What Would Be Needed
|
|
|
|
| Check Type | Purpose | Implementation Needed |
|
|
|------------|---------|----------------------|
|
|
| **Liveness** | Is the process alive? | Simple HTTP endpoint |
|
|
| **Readiness** | Can it process files? | Check Quartz port alive, file watcher subscribed |
|
|
| **Dependency** | Are external services up? | Zotero probe, filesystem accessible |
|
|
| **Deep health** | Full system check | BibTeX loaded, Emacs available, Node.js working |
|
|
|
|
---
|
|
|
|
## Summary: What Exists vs What's Missing
|
|
|
|
### Exists (Good Foundation)
|
|
|
|
| Component | Status |
|
|
|-----------|--------|
|
|
| GenServer-based workers | Watcher, Quartz |
|
|
| Basic supervisor | OrgGarden.Supervisor with :one_for_all |
|
|
| Terminate callbacks | Cleanup for port and transforms |
|
|
| HTTP client pooling | Finch in application tree |
|
|
| Transform lifecycle | init/apply/teardown pattern |
|
|
| File event debouncing | 500ms debounce in Watcher |
|
|
|
|
### Missing for Production Service
|
|
|
|
| Category | Gap |
|
|
|----------|-----|
|
|
| **Supervision** | Supervisor not in application tree; CLI process is unsupervised |
|
|
| **Configuration** | No config files; env vars only; no validation |
|
|
| **Health checks** | None |
|
|
| **Metrics/Telemetry** | None |
|
|
| **Structured logging** | Plain strings, mixed IO/Logger |
|
|
| **Graceful shutdown** | No SIGTERM trapping, no drain period |
|
|
| **State persistence** | All in-memory |
|
|
| **Error recovery** | No retries, no circuit breakers |
|
|
| **Release support** | Escript only, no Mix Release config |
|
|
|
|
---
|
|
|
|
## Recommendations for Service Conversion
|
|
|
|
### Priority 1: Supervision Tree
|
|
|
|
1. Move `OrgGarden.Supervisor` into the Application supervision tree
|
|
2. Add dynamic supervisor for multiple watch directories
|
|
3. Replace `Process.sleep(:infinity)` with proper blocking
|
|
|
|
### Priority 2: Configuration
|
|
|
|
1. Add `config/config.exs` and `config/runtime.exs`
|
|
2. Use `Application.get_env/3` throughout
|
|
3. Add config validation at startup
|
|
|
|
### Priority 3: Observability
|
|
|
|
1. Add `:telemetry` events for key operations
|
|
2. Implement structured logging with metadata
|
|
3. Create health check module with HTTP endpoint (via Plug/Bandit)
|
|
|
|
### Priority 4: Release Support
|
|
|
|
1. Convert from escript to Mix Release
|
|
2. Add `rel/env.sh.eex` for environment configuration
|
|
3. Implement SIGTERM handler via `:init.stop/0`
|
|
</task_result>
|
|
```
|
|
## Agent (2026-02-21 16:42:03)
|
|
|
|
Based on the analysis, here's what's needed to make org-garden a proper long-running service:
|
|
|
|
## Critical Issues
|
|
|
|
### 1. **Supervision Tree Not Integrated**
|
|
- `OrgGarden.Supervisor` is started dynamically by CLI, not part of the Application tree
|
|
- CLI process just does `Process.sleep(:infinity)` - if it crashes, children are orphaned
|
|
- Export `--watch` mode starts Watcher outside any supervisor
|
|
|
|
### 2. **No Configuration System**
|
|
- All config via `System.get_env/1` directly, no `config/*.exs` files
|
|
- No validation of required values
|
|
- No runtime configuration support
|
|
|
|
### 3. **No Health Checks**
|
|
- No liveness/readiness probes
|
|
- No way to check if Quartz port or file watcher are healthy
|
|
|
|
### 4. **No Graceful Shutdown**
|
|
- No SIGTERM trapping (escript doesn't handle signals well)
|
|
- No drain period for in-flight file processing
|
|
- Pending debounce timers lost on shutdown
|
|
|
|
### 5. **No Observability**
|
|
- No `:telemetry` events for metrics
|
|
- Plain string logging, no structured metadata
|
|
- Mixed `IO.puts`/`Logger` usage
|
|
|
|
---
|
|
|
|
## For a Nix Service, You'd Need:
|
|
|
|
1. **NixOS module** (`module.nix`) with:
|
|
- systemd service definition
|
|
- User/group configuration
|
|
- Environment variables from NixOS options
|
|
- ExecReload for config changes
|
|
- Proper restart policies
|
|
|
|
2. **Mix Release instead of escript** for:
|
|
- Proper signal handling
|
|
- Runtime configuration via `releases.exs`
|
|
- Hot code upgrades (optional)
|
|
|
|
3. **Health check endpoint** (via Plug/Bandit) for systemd `Type=notify` or watchdog
|
|
|
|
---
|
|
|
|
## Quick Improvement Roadmap
|
|
|
|
| Priority | Item | Effort |
|
|
|----------|------|--------|
|
|
| 1 | Integrate supervisor into Application tree | Medium |
|
|
| 2 | Add `config/runtime.exs` | Low |
|
|
| 3 | Add `:telemetry` events | Medium |
|
|
| 4 | Convert to Mix Release | Medium |
|
|
| 5 | Add health check HTTP endpoint | Medium |
|
|
| 6 | Create NixOS module | Medium |
|
|
| 7 | Add structured logging | Low |
|
|
|
|
Want me to implement any of these improvements?
|
|
|
|
## User (2026-02-21 16:43:41)
|
|
|
|
Plan how to do these improvements
|
|
|
|
|
|
|
|
### Tool Call [failed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 16:43:44
|
|
|
|
```
|
|
Tool execution aborted
|
|
```
|
|
## User (2026-02-21 16:43:50)
|
|
|
|
Plan how to do these improvements
|
|
|
|
## Agent (2026-02-21 16:43:52)
|
|
|
|
Let me read the key files to create a detailed implementation plan.
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:53
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/application.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.Application do
|
|
2: @moduledoc false
|
|
3: use Application
|
|
4:
|
|
5: @impl true
|
|
6: def start(_type, _args) do
|
|
7: children = [
|
|
8: {Finch, name: OrgGarden.Finch}
|
|
9: ]
|
|
10:
|
|
11: opts = [strategy: :one_for_one, name: OrgGarden.AppSupervisor]
|
|
12: Supervisor.start_link(children, opts)
|
|
13: end
|
|
14: end
|
|
15:
|
|
|
|
(End of file - total 15 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:54
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/supervisor.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.Supervisor do
|
|
2: @moduledoc """
|
|
3: Supervises development server components.
|
|
4:
|
|
5: Strategy: :one_for_all
|
|
6: If either child fails, restart both to ensure consistent state.
|
|
7:
|
|
8: Children:
|
|
9: 1. OrgGarden.Watcher - watches .org files for changes
|
|
10: 2. OrgGarden.Quartz - runs Quartz Node.js server
|
|
11:
|
|
12: ## Usage
|
|
13:
|
|
14: OrgGarden.Supervisor.start_link(
|
|
15: notes_dir: "/path/to/notes",
|
|
16: output_dir: "/path/to/output",
|
|
17: content_dir: "/path/to/output/content",
|
|
18: pipeline_opts: %{zotero_url: "...", ...},
|
|
19: transforms: [OrgGarden.Transforms.Citations],
|
|
20: port: 8080,
|
|
21: ws_port: 3001
|
|
22: )
|
|
23: """
|
|
24: use Supervisor
|
|
25:
|
|
26: def start_link(opts) do
|
|
27: Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
|
28: end
|
|
29:
|
|
30: @impl true
|
|
31: def init(opts) do
|
|
32: children = [
|
|
33: {OrgGarden.Watcher,
|
|
34: Keyword.take(opts, [:notes_dir, :output_dir, :content_dir, :pipeline_opts, :transforms])},
|
|
35: {OrgGarden.Quartz, Keyword.take(opts, [:content_dir, :port, :ws_port])}
|
|
36: ]
|
|
37:
|
|
38: Supervisor.init(children, strategy: :one_for_all)
|
|
39: end
|
|
40: end
|
|
41:
|
|
|
|
(End of file - total 41 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:54
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/cli.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.CLI do
|
|
2: @moduledoc """
|
|
3: Escript entry point for the org-garden pipeline.
|
|
4:
|
|
5: ## Commands
|
|
6:
|
|
7: org-garden serve <notes-dir> [--port 8080] [--ws-port 3001]
|
|
8: org-garden build <notes-dir> [--output <path>]
|
|
9: org-garden export <notes-dir> [--watch]
|
|
10:
|
|
11: ### serve
|
|
12: Development server with watch + live reload. Starts both the org→md
|
|
13: watcher and Quartz in serve mode.
|
|
14:
|
|
15: ### build
|
|
16: One-shot build for CI/production. Exports org files, runs transforms,
|
|
17: then builds static site with Quartz.
|
|
18:
|
|
19: ### export
|
|
20: Just export org→md (current pipeline behavior). Use --watch for
|
|
21: incremental re-export on file changes.
|
|
22:
|
|
23: ## Arguments
|
|
24:
|
|
25: notes-dir Path to the directory containing `.org` notes (required).
|
|
26: Also accepts the `NOTES_DIR` env var.
|
|
27:
|
|
28: ## Options
|
|
29:
|
|
30: --output <path> Output root directory (used as ox-hugo base dir).
|
|
31: Defaults to the `OUTPUT_DIR` env var, or the current
|
|
32: working directory.
|
|
33: --content-dir <p> Output directory for exported Markdown. Defaults to
|
|
34: `<output>/content`.
|
|
35: --port <n> HTTP server port (default: 8080). Only for `serve`.
|
|
36: --ws-port <n> WebSocket hot reload port (default: 3001). Only for `serve`.
|
|
37: --watch After initial batch, watch notes-dir for changes and
|
|
38: incrementally re-export affected files. Only for `export`.
|
|
39:
|
|
40: ## Environment Variables
|
|
41:
|
|
42: BIBTEX_FILE Path to a `.bib` file used as citation fallback.
|
|
43: ZOTERO_URL Zotero Better BibTeX base URL (default: http://localhost:23119).
|
|
44: CITATION_MODE silent | warn (default) | strict.
|
|
45: QUARTZ_PATH Path to quartz directory (required for serve/build).
|
|
46: NODE_PATH Node.js executable (default: node).
|
|
47: """
|
|
48:
|
|
49: require Logger
|
|
50:
|
|
51: @transforms [OrgGarden.Transforms.Citations]
|
|
52:
|
|
53: def main(argv) do
|
|
54: Application.ensure_all_started(:org_garden)
|
|
55:
|
|
56: case argv do
|
|
57: ["serve" | rest] -> handle_serve(rest)
|
|
58: ["build" | rest] -> handle_build(rest)
|
|
59: ["export" | rest] -> handle_export(rest)
|
|
60: # Legacy: treat bare args as export command for backward compatibility
|
|
61: [_ | _] -> handle_export(argv)
|
|
62: _ -> abort("Usage: org-garden <serve|build|export> <notes-dir> [options]")
|
|
63: end
|
|
64: end
|
|
65:
|
|
66: # ---------------------------------------------------------------------------
|
|
67: # Command: serve
|
|
68: # ---------------------------------------------------------------------------
|
|
69:
|
|
70: def handle_serve(argv) do
|
|
71: require_quartz_env()
|
|
72: {notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv)
|
|
73: pipeline_opts = build_pipeline_opts()
|
|
74:
|
|
75: # Initial batch export
|
|
76: wipe(content_dir)
|
|
77: export_all(notes_dir, output_dir)
|
|
78: run_pipeline(content_dir, pipeline_opts)
|
|
79: generate_index(content_dir)
|
|
80:
|
|
81: IO.puts("==> Starting development server...")
|
|
82:
|
|
83: {:ok, _pid} =
|
|
84: OrgGarden.Supervisor.start_link(
|
|
85: notes_dir: notes_dir,
|
|
86: output_dir: output_dir,
|
|
87: content_dir: content_dir,
|
|
88: pipeline_opts: pipeline_opts,
|
|
89: transforms: @transforms,
|
|
90: port: opts[:port] || 8080,
|
|
91: ws_port: opts[:ws_port] || 3001
|
|
92: )
|
|
93:
|
|
94: IO.puts("==> Server running at http://localhost:#{opts[:port] || 8080}")
|
|
95: IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)")
|
|
96:
|
|
97: Process.sleep(:infinity)
|
|
98: end
|
|
99:
|
|
100: defp parse_serve_args(argv) do
|
|
101: {opts, positional, _invalid} =
|
|
102: OptionParser.parse(argv,
|
|
103: strict: [
|
|
104: output: :string,
|
|
105: content_dir: :string,
|
|
106: port: :integer,
|
|
107: ws_port: :integer
|
|
108: ]
|
|
109: )
|
|
110:
|
|
111: notes_dir = extract_notes_dir(positional, "serve")
|
|
112: output_dir = extract_output_dir(opts)
|
|
113: content_dir = extract_content_dir(opts, output_dir)
|
|
114:
|
|
115: {notes_dir, output_dir, content_dir, opts}
|
|
116: end
|
|
117:
|
|
118: # ---------------------------------------------------------------------------
|
|
119: # Command: build
|
|
120: # ---------------------------------------------------------------------------
|
|
121:
|
|
122: def handle_build(argv) do
|
|
123: quartz_path = require_quartz_env()
|
|
124: {notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv)
|
|
125: pipeline_opts = build_pipeline_opts()
|
|
126:
|
|
127: # Full batch export
|
|
128: wipe(content_dir)
|
|
129: export_all(notes_dir, output_dir)
|
|
130: run_pipeline(content_dir, pipeline_opts)
|
|
131: generate_index(content_dir)
|
|
132:
|
|
133: node_path = System.get_env("NODE_PATH", "node")
|
|
134:
|
|
135: IO.puts("==> Building static site with Quartz...")
|
|
136:
|
|
137: {output, status} =
|
|
138: System.cmd(
|
|
139: node_path,
|
|
140: [
|
|
141: Path.join(quartz_path, "quartz/bootstrap-cli.mjs"),
|
|
142: "build",
|
|
143: "--directory",
|
|
144: content_dir,
|
|
145: "--output",
|
|
146: Path.join(output_dir, "public")
|
|
147: ],
|
|
148: cd: quartz_path,
|
|
149: stderr_to_stdout: true
|
|
150: )
|
|
151:
|
|
152: IO.puts(output)
|
|
153:
|
|
154: if status != 0 do
|
|
155: abort("Quartz build failed with status #{status}")
|
|
156: end
|
|
157:
|
|
158: IO.puts("==> Build complete. Output: #{Path.join(output_dir, "public")}")
|
|
159: end
|
|
160:
|
|
161: defp parse_build_args(argv) do
|
|
162: {opts, positional, _invalid} =
|
|
163: OptionParser.parse(argv,
|
|
164: strict: [output: :string, content_dir: :string]
|
|
165: )
|
|
166:
|
|
167: notes_dir = extract_notes_dir(positional, "build")
|
|
168: output_dir = extract_output_dir(opts)
|
|
169: content_dir = extract_content_dir(opts, output_dir)
|
|
170:
|
|
171: {notes_dir, output_dir, content_dir, opts}
|
|
172: end
|
|
173:
|
|
174: # ---------------------------------------------------------------------------
|
|
175: # Command: export (original pipeline behavior)
|
|
176: # ---------------------------------------------------------------------------
|
|
177:
|
|
178: def handle_export(argv) do
|
|
179: {notes_dir, output_dir, content_dir, watch?} = parse_export_args(argv)
|
|
180: pipeline_opts = build_pipeline_opts()
|
|
181:
|
|
182: # Phase 1-4: full batch export
|
|
183: wipe(content_dir)
|
|
184: export_all(notes_dir, output_dir)
|
|
185: run_pipeline(content_dir, pipeline_opts)
|
|
186: generate_index(content_dir)
|
|
187:
|
|
188: md_count =
|
|
189: content_dir
|
|
190: |> Path.join("**/*.md")
|
|
191: |> Path.wildcard()
|
|
192: |> length()
|
|
193:
|
|
194: IO.puts("==> Done. #{md_count} markdown files in #{content_dir}")
|
|
195:
|
|
196: # Phase 5: optional watch mode
|
|
197: if watch? do
|
|
198: IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)")
|
|
199:
|
|
200: {:ok, _pid} =
|
|
201: OrgGarden.Watcher.start_link(
|
|
202: notes_dir: notes_dir,
|
|
203: output_dir: output_dir,
|
|
204: content_dir: content_dir,
|
|
205: pipeline_opts: pipeline_opts,
|
|
206: transforms: @transforms
|
|
207: )
|
|
208:
|
|
209: Process.sleep(:infinity)
|
|
210: end
|
|
211: end
|
|
212:
|
|
213: defp parse_export_args(argv) do
|
|
214: {opts, positional, _invalid} =
|
|
215: OptionParser.parse(argv,
|
|
216: strict: [output: :string, content_dir: :string, watch: :boolean]
|
|
217: )
|
|
218:
|
|
219: notes_dir = extract_notes_dir(positional, "export")
|
|
220: output_dir = extract_output_dir(opts)
|
|
221: content_dir = extract_content_dir(opts, output_dir)
|
|
222: watch? = Keyword.get(opts, :watch, false)
|
|
223:
|
|
224: {notes_dir, output_dir, content_dir, watch?}
|
|
225: end
|
|
226:
|
|
227: # ---------------------------------------------------------------------------
|
|
228: # Shared argument extraction
|
|
229: # ---------------------------------------------------------------------------
|
|
230:
|
|
231: defp extract_notes_dir(positional, command) do
|
|
232: notes_dir =
|
|
233: case positional do
|
|
234: [dir | _] ->
|
|
235: dir
|
|
236:
|
|
237: [] ->
|
|
238: System.get_env("NOTES_DIR") ||
|
|
239: abort("Usage: org-garden #{command} <notes-dir> [options]")
|
|
240: end
|
|
241:
|
|
242: notes_dir = Path.expand(notes_dir)
|
|
243:
|
|
244: unless File.dir?(notes_dir) do
|
|
245: abort("Error: notes directory does not exist: #{notes_dir}")
|
|
246: end
|
|
247:
|
|
248: notes_dir
|
|
249: end
|
|
250:
|
|
251: defp extract_output_dir(opts) do
|
|
252: (opts[:output] || System.get_env("OUTPUT_DIR") || File.cwd!())
|
|
253: |> Path.expand()
|
|
254: end
|
|
255:
|
|
256: defp extract_content_dir(opts, output_dir) do
|
|
257: (opts[:content_dir] || Path.join(output_dir, "content"))
|
|
258: |> Path.expand()
|
|
259: end
|
|
260:
|
|
261: # ---------------------------------------------------------------------------
|
|
262: # Phase 1: Wipe content/
|
|
263: # ---------------------------------------------------------------------------
|
|
264:
|
|
265: defp wipe(content_dir) do
|
|
266: IO.puts("==> Wiping #{content_dir}")
|
|
267: File.mkdir_p!(content_dir)
|
|
268:
|
|
269: content_dir
|
|
270: |> File.ls!()
|
|
271: |> Enum.reject(&(&1 == ".gitkeep"))
|
|
272: |> Enum.each(fn entry ->
|
|
273: Path.join(content_dir, entry) |> File.rm_rf!()
|
|
274: end)
|
|
275: end
|
|
276:
|
|
277: # ---------------------------------------------------------------------------
|
|
278: # Phase 2: Export org files via Emacs + ox-hugo
|
|
279: # ---------------------------------------------------------------------------
|
|
280:
|
|
281: defp export_all(notes_dir, output_dir) do
|
|
282: IO.puts("==> Exporting org files from #{notes_dir}")
|
|
283:
|
|
284: case OrgGarden.Export.export_all(notes_dir, output_dir) do
|
|
285: {:ok, 0} ->
|
|
286: IO.puts("No .org files found in #{notes_dir}")
|
|
287: System.halt(0)
|
|
288:
|
|
289: {:ok, count} ->
|
|
290: IO.puts(" exported #{count} file(s)")
|
|
291:
|
|
292: {:error, failures} ->
|
|
293: IO.puts(:stderr, "\nFailed to export #{length(failures)} file(s):")
|
|
294:
|
|
295: Enum.each(failures, fn {f, {:error, reason}} ->
|
|
296: IO.puts(:stderr, " #{f}: #{inspect(reason)}")
|
|
297: end)
|
|
298:
|
|
299: System.halt(1)
|
|
300: end
|
|
301: end
|
|
302:
|
|
303: # ---------------------------------------------------------------------------
|
|
304: # Phase 3: Markdown transformation pipeline
|
|
305: # ---------------------------------------------------------------------------
|
|
306:
|
|
307: defp run_pipeline(content_dir, pipeline_opts) do
|
|
308: IO.puts("==> Running markdown pipeline")
|
|
309:
|
|
310: {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts)
|
|
311:
|
|
312: Enum.each(stats, fn {mod, count} ->
|
|
313: IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
|
|
314: end)
|
|
315: end
|
|
316:
|
|
317: # ---------------------------------------------------------------------------
|
|
318: # Phase 4: Generate default index.md if none was exported
|
|
319: # ---------------------------------------------------------------------------
|
|
320:
|
|
321: defp generate_index(content_dir) do
|
|
322: IO.puts("==> Generating index")
|
|
323: OrgGarden.Index.generate(content_dir)
|
|
324: end
|
|
325:
|
|
326: # ---------------------------------------------------------------------------
|
|
327: # Helpers
|
|
328: # ---------------------------------------------------------------------------
|
|
329:
|
|
330: defp require_quartz_env do
|
|
331: case System.get_env("QUARTZ_PATH") do
|
|
332: nil ->
|
|
333: abort("""
|
|
334: Error: QUARTZ_PATH environment variable not set.
|
|
335:
|
|
336: The 'serve' and 'build' commands require Quartz to be available.
|
|
337:
|
|
338: Use the wrapper scripts that set up the environment:
|
|
339: nix run .#notes -- <notes-dir> # for serve
|
|
340: nix run .#build -- <notes-dir> # for build
|
|
341:
|
|
342: Or set QUARTZ_PATH manually to point to a quartz-org-roam checkout
|
|
343: with node_modules installed.
|
|
344:
|
|
345: For export-only mode (no Quartz), use:
|
|
346: org-garden export <notes-dir> [--watch]
|
|
347: """)
|
|
348:
|
|
349: path ->
|
|
350: unless File.exists?(Path.join(path, "quartz/bootstrap-cli.mjs")) do
|
|
351: abort("Error: QUARTZ_PATH=#{path} does not contain quartz/bootstrap-cli.mjs")
|
|
352: end
|
|
353:
|
|
354: path
|
|
355: end
|
|
356: end
|
|
357:
|
|
358: defp build_pipeline_opts do
|
|
359: %{
|
|
360: zotero_url: System.get_env("ZOTERO_URL", "http://localhost:23119"),
|
|
361: bibtex_file: System.get_env("BIBTEX_FILE"),
|
|
362: citation_mode:
|
|
363: case System.get_env("CITATION_MODE", "warn") do
|
|
364: "silent" -> :silent
|
|
365: "strict" -> :strict
|
|
366: _ -> :warn
|
|
367: end
|
|
368: }
|
|
369: end
|
|
370:
|
|
371: defp abort(message) do
|
|
372: IO.puts(:stderr, message)
|
|
373: System.halt(1)
|
|
374: end
|
|
375: end
|
|
376:
|
|
|
|
(End of file - total 376 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:54
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/mix.exs</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.MixProject do
|
|
2: use Mix.Project
|
|
3:
|
|
4: def project do
|
|
5: [
|
|
6: app: :org_garden,
|
|
7: version: "0.1.0",
|
|
8: elixir: "~> 1.17",
|
|
9: start_permanent: Mix.env() == :prod,
|
|
10: deps: deps(),
|
|
11: escript: escript()
|
|
12: ]
|
|
13: end
|
|
14:
|
|
15: def application do
|
|
16: [
|
|
17: extra_applications: [:logger],
|
|
18: mod: {OrgGarden.Application, []}
|
|
19: ]
|
|
20: end
|
|
21:
|
|
22: defp escript do
|
|
23: [main_module: OrgGarden.CLI]
|
|
24: end
|
|
25:
|
|
26: defp deps do
|
|
27: [
|
|
28: {:finch, "~> 0.19"},
|
|
29: {:req, "~> 0.5"},
|
|
30: {:jason, "~> 1.4"},
|
|
31: {:file_system, "~> 1.0"}
|
|
32: ]
|
|
33: end
|
|
34: end
|
|
35:
|
|
|
|
(End of file - total 35 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:57
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/watcher.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.Watcher do
|
|
2: @moduledoc """
|
|
3: File-watching GenServer that detects `.org` file changes and triggers
|
|
4: incremental export + transform for only the affected files.
|
|
5:
|
|
6: Uses the `file_system` package (inotify on Linux, fsevents on macOS)
|
|
7: to watch the notes directory. Events are debounced per-file (500ms)
|
|
8: to coalesce rapid writes (e.g., Emacs auto-save).
|
|
9:
|
|
10: ## Lifecycle
|
|
11:
|
|
12: Started dynamically by `OrgGarden.CLI` after the initial batch export.
|
|
13: Transforms are initialized once at startup and reused across all
|
|
14: incremental rebuilds to avoid repeated Zotero probes and BibTeX loads.
|
|
15:
|
|
16: ## Usage
|
|
17:
|
|
18: OrgGarden.Watcher.start_link(
|
|
19: notes_dir: "/path/to/notes",
|
|
20: output_dir: "/path/to/output",
|
|
21: content_dir: "/path/to/output/content",
|
|
22: pipeline_opts: %{zotero_url: "...", ...},
|
|
23: transforms: [OrgGarden.Transforms.Citations]
|
|
24: )
|
|
25: """
|
|
26:
|
|
27: use GenServer
|
|
28:
|
|
29: require Logger
|
|
30:
|
|
31: @debounce_ms 500
|
|
32:
|
|
33: # -------------------------------------------------------------------
|
|
34: # Client API
|
|
35: # -------------------------------------------------------------------
|
|
36:
|
|
37: @doc """
|
|
38: Start the watcher as a linked process.
|
|
39:
|
|
40: ## Options
|
|
41:
|
|
42: * `:notes_dir` — directory to watch for `.org` changes (required)
|
|
43: * `:output_dir` — ox-hugo base dir (required)
|
|
44: * `:content_dir` — directory where `.md` files are written (required)
|
|
45: * `:pipeline_opts` — opts map passed to transforms (required)
|
|
46: * `:transforms` — list of transform modules (default: `[OrgGarden.Transforms.Citations]`)
|
|
47: """
|
|
48: def start_link(opts) do
|
|
49: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
50: end
|
|
51:
|
|
52: # -------------------------------------------------------------------
|
|
53: # GenServer callbacks
|
|
54: # -------------------------------------------------------------------
|
|
55:
|
|
56: @impl true
|
|
57: def init(opts) do
|
|
58: notes_dir = Keyword.fetch!(opts, :notes_dir)
|
|
59: output_dir = Keyword.fetch!(opts, :output_dir)
|
|
60: content_dir = Keyword.fetch!(opts, :content_dir)
|
|
61: pipeline_opts = Keyword.fetch!(opts, :pipeline_opts)
|
|
62: transforms = Keyword.get(opts, :transforms, [OrgGarden.Transforms.Citations])
|
|
63:
|
|
64: # Initialize transforms once — reused for all incremental rebuilds
|
|
65: initialized_transforms = OrgGarden.init_transforms(transforms, pipeline_opts)
|
|
66:
|
|
67: # Start the file system watcher
|
|
68: {:ok, watcher_pid} = FileSystem.start_link(dirs: [notes_dir], recursive: true)
|
|
69: FileSystem.subscribe(watcher_pid)
|
|
70:
|
|
71: Logger.info("Watcher: monitoring #{notes_dir} for .org changes")
|
|
72:
|
|
73: {:ok,
|
|
74: %{
|
|
75: notes_dir: notes_dir,
|
|
76: output_dir: output_dir,
|
|
77: content_dir: content_dir,
|
|
78: pipeline_opts: pipeline_opts,
|
|
79: watcher_pid: watcher_pid,
|
|
80: initialized_transforms: initialized_transforms,
|
|
81: pending: %{}
|
|
82: }}
|
|
83: end
|
|
84:
|
|
85: @impl true
|
|
86: def handle_info({:file_event, _pid, {path, events}}, state) do
|
|
87: path = to_string(path)
|
|
88:
|
|
89: if org_file?(path) and not temporary_file?(path) do
|
|
90: event_type = classify_events(events)
|
|
91: Logger.debug("Watcher: #{event_type} event for #{path}")
|
|
92: {:noreply, schedule_debounce(path, event_type, state)}
|
|
93: else
|
|
94: {:noreply, state}
|
|
95: end
|
|
96: end
|
|
97:
|
|
98: @impl true
|
|
99: def handle_info({:file_event, _pid, :stop}, state) do
|
|
100: Logger.warning("Watcher: file system monitor stopped unexpectedly")
|
|
101: {:stop, :watcher_stopped, state}
|
|
102: end
|
|
103:
|
|
104: @impl true
|
|
105: def handle_info({:debounced, path, event_type}, state) do
|
|
106: state = %{state | pending: Map.delete(state.pending, path)}
|
|
107:
|
|
108: case event_type do
|
|
109: :deleted ->
|
|
110: handle_delete(path, state)
|
|
111:
|
|
112: _created_or_modified ->
|
|
113: handle_change(path, state)
|
|
114: end
|
|
115:
|
|
116: {:noreply, state}
|
|
117: end
|
|
118:
|
|
119: @impl true
|
|
120: def terminate(_reason, state) do
|
|
121: OrgGarden.teardown_transforms(state.initialized_transforms)
|
|
122: :ok
|
|
123: end
|
|
124:
|
|
125: # -------------------------------------------------------------------
|
|
126: # Event handling
|
|
127: # -------------------------------------------------------------------
|
|
128:
|
|
129: defp handle_change(orgfile, state) do
|
|
130: %{
|
|
131: notes_dir: notes_dir,
|
|
132: output_dir: output_dir,
|
|
133: content_dir: content_dir,
|
|
134: pipeline_opts: pipeline_opts,
|
|
135: initialized_transforms: initialized_transforms
|
|
136: } = state
|
|
137:
|
|
138: md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
|
139: IO.puts("==> Changed: #{Path.relative_to(orgfile, notes_dir)}")
|
|
140:
|
|
141: case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do
|
|
142: {:ok, _} ->
|
|
143: IO.puts(" exported: #{Path.relative_to(md_path, content_dir)}")
|
|
144:
|
|
145: {:ok, stats} = OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts)
|
|
146:
|
|
147: Enum.each(stats, fn {mod, count} ->
|
|
148: if count > 0, do: IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
|
|
149: end)
|
|
150:
|
|
151: regenerate_index(content_dir)
|
|
152: IO.puts("==> Done")
|
|
153:
|
|
154: {:error, reason} ->
|
|
155: Logger.error("Watcher: export failed for #{orgfile}: #{inspect(reason)}")
|
|
156: end
|
|
157: end
|
|
158:
|
|
159: defp handle_delete(orgfile, state) do
|
|
160: %{notes_dir: notes_dir, content_dir: content_dir} = state
|
|
161:
|
|
162: md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
|
163: IO.puts("==> Deleted: #{Path.relative_to(orgfile, notes_dir)}")
|
|
164:
|
|
165: if File.exists?(md_path) do
|
|
166: File.rm!(md_path)
|
|
167: IO.puts(" removed: #{Path.relative_to(md_path, content_dir)}")
|
|
168:
|
|
169: # Clean up empty parent directories left behind
|
|
170: cleanup_empty_dirs(Path.dirname(md_path), content_dir)
|
|
171: end
|
|
172:
|
|
173: regenerate_index(content_dir)
|
|
174: IO.puts("==> Done")
|
|
175: end
|
|
176:
|
|
177: # -------------------------------------------------------------------
|
|
178: # Index generation
|
|
179: # -------------------------------------------------------------------
|
|
180:
|
|
181: defp regenerate_index(content_dir) do
|
|
182: OrgGarden.Index.regenerate(content_dir)
|
|
183: end
|
|
184:
|
|
185: # -------------------------------------------------------------------
|
|
186: # Helpers
|
|
187: # -------------------------------------------------------------------
|
|
188:
|
|
189: defp schedule_debounce(path, event_type, state) do
|
|
190: # Cancel any existing timer for this path
|
|
191: case Map.get(state.pending, path) do
|
|
192: nil -> :ok
|
|
193: old_ref -> Process.cancel_timer(old_ref)
|
|
194: end
|
|
195:
|
|
196: ref = Process.send_after(self(), {:debounced, path, event_type}, @debounce_ms)
|
|
197: %{state | pending: Map.put(state.pending, path, ref)}
|
|
198: end
|
|
199:
|
|
200: defp org_file?(path), do: String.ends_with?(path, ".org")
|
|
201:
|
|
202: defp temporary_file?(path) do
|
|
203: basename = Path.basename(path)
|
|
204: # Emacs creates temp files like .#file.org and #file.org#
|
|
205: String.starts_with?(basename, ".#") or
|
|
206: (String.starts_with?(basename, "#") and String.ends_with?(basename, "#"))
|
|
207: end
|
|
208:
|
|
209: defp classify_events(events) do
|
|
210: cond do
|
|
211: :removed in events or :deleted in events -> :deleted
|
|
212: :created in events -> :created
|
|
213: :modified in events or :changed in events -> :modified
|
|
214: # renamed can mean created or deleted depending on context;
|
|
215: # if the file exists it was renamed into the watched dir
|
|
216: :renamed in events -> :modified
|
|
217: true -> :modified
|
|
218: end
|
|
219: end
|
|
220:
|
|
221: defp cleanup_empty_dirs(dir, stop_at) do
|
|
222: dir = Path.expand(dir)
|
|
223: stop_at = Path.expand(stop_at)
|
|
224:
|
|
225: if dir != stop_at and File.dir?(dir) do
|
|
226: case File.ls!(dir) do
|
|
227: [] ->
|
|
228: File.rmdir!(dir)
|
|
229: cleanup_empty_dirs(Path.dirname(dir), stop_at)
|
|
230:
|
|
231: _ ->
|
|
232: :ok
|
|
233: end
|
|
234: end
|
|
235: end
|
|
236: end
|
|
237:
|
|
|
|
(End of file - total 237 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:58
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/quartz.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.Quartz do
|
|
2: @moduledoc """
|
|
3: Manages Quartz Node.js process as an Erlang Port.
|
|
4:
|
|
5: Required environment:
|
|
6: - QUARTZ_PATH: path to quartz repo (with node_modules)
|
|
7: - NODE_PATH: path to node executable (default: "node")
|
|
8:
|
|
9: Starts Quartz in serve mode (`npx quartz build --serve`) and forwards
|
|
10: all stdout/stderr output to the Logger with a `[quartz]` prefix.
|
|
11:
|
|
12: If Quartz exits, this GenServer will stop, which triggers the supervisor
|
|
13: to restart the entire supervision tree (strategy: :one_for_all).
|
|
14: """
|
|
15: use GenServer
|
|
16:
|
|
17: require Logger
|
|
18:
|
|
19: defstruct [:port, :quartz_path, :content_dir, :http_port, :ws_port]
|
|
20:
|
|
21: # -------------------------------------------------------------------
|
|
22: # Client API
|
|
23: # -------------------------------------------------------------------
|
|
24:
|
|
25: @doc """
|
|
26: Start the Quartz process as a linked GenServer.
|
|
27:
|
|
28: ## Options
|
|
29:
|
|
30: * `:content_dir` — directory where markdown files are located (required)
|
|
31: * `:port` — HTTP server port (default: 8080)
|
|
32: * `:ws_port` — WebSocket hot reload port (default: 3001)
|
|
33: """
|
|
34: def start_link(opts) do
|
|
35: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
36: end
|
|
37:
|
|
38: # -------------------------------------------------------------------
|
|
39: # GenServer callbacks
|
|
40: # -------------------------------------------------------------------
|
|
41:
|
|
42: @impl true
|
|
43: def init(opts) do
|
|
44: quartz_path =
|
|
45: System.get_env("QUARTZ_PATH") ||
|
|
46: raise "QUARTZ_PATH environment variable not set"
|
|
47:
|
|
48: node_path = System.get_env("NODE_PATH", "node")
|
|
49:
|
|
50: content_dir = Keyword.fetch!(opts, :content_dir)
|
|
51: http_port = Keyword.get(opts, :port, 8080)
|
|
52: ws_port = Keyword.get(opts, :ws_port, 3001)
|
|
53:
|
|
54: cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs")
|
|
55:
|
|
56: unless File.exists?(cli_path) do
|
|
57: raise "Quartz CLI not found at #{cli_path}. Check QUARTZ_PATH."
|
|
58: end
|
|
59:
|
|
60: args = [
|
|
61: cli_path,
|
|
62: "build",
|
|
63: "--serve",
|
|
64: "--directory", content_dir,
|
|
65: "--port", to_string(http_port),
|
|
66: "--wsPort", to_string(ws_port)
|
|
67: ]
|
|
68:
|
|
69: Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}")
|
|
70: Logger.info("[quartz] Working directory: #{quartz_path}")
|
|
71:
|
|
72: port =
|
|
73: Port.open({:spawn_executable, node_path}, [
|
|
74: :binary,
|
|
75: :exit_status,
|
|
76: :stderr_to_stdout,
|
|
77: args: args,
|
|
78: cd: quartz_path,
|
|
79: env: [{~c"NODE_NO_WARNINGS", ~c"1"}]
|
|
80: ])
|
|
81:
|
|
82: state = %__MODULE__{
|
|
83: port: port,
|
|
84: quartz_path: quartz_path,
|
|
85: content_dir: content_dir,
|
|
86: http_port: http_port,
|
|
87: ws_port: ws_port
|
|
88: }
|
|
89:
|
|
90: {:ok, state}
|
|
91: end
|
|
92:
|
|
93: @impl true
|
|
94: def handle_info({port, {:data, data}}, %{port: port} = state) do
|
|
95: data
|
|
96: |> String.split("\n", trim: true)
|
|
97: |> Enum.each(&Logger.info("[quartz] #{&1}"))
|
|
98:
|
|
99: {:noreply, state}
|
|
100: end
|
|
101:
|
|
102: @impl true
|
|
103: def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
|
|
104: Logger.error("[quartz] Process exited with status #{status}")
|
|
105: {:stop, {:quartz_exit, status}, state}
|
|
106: end
|
|
107:
|
|
108: @impl true
|
|
109: def terminate(_reason, %{port: port}) when is_port(port) do
|
|
110: # Attempt graceful shutdown
|
|
111: Port.close(port)
|
|
112: :ok
|
|
113: rescue
|
|
114: _ -> :ok
|
|
115: end
|
|
116:
|
|
117: def terminate(_reason, _state), do: :ok
|
|
118: end
|
|
119:
|
|
|
|
(End of file - total 119 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 16:43:58
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/flake.nix</path>
|
|
<type>file</type>
|
|
<content>1: {
|
|
2: description = "Org-garden — org-roam to website publishing pipeline";
|
|
3:
|
|
4: inputs = {
|
|
5: nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
6: flake-utils.url = "github:numtide/flake-utils";
|
|
7: };
|
|
8:
|
|
9: outputs = { self, nixpkgs, flake-utils }:
|
|
10: flake-utils.lib.eachDefaultSystem (system:
|
|
11: let
|
|
12: pkgs = import nixpkgs { inherit system; };
|
|
13: fs = pkgs.lib.fileset;
|
|
14:
|
|
15: # =========================================================================
|
|
16: # Emacs with ox-hugo
|
|
17: # =========================================================================
|
|
18: # Needed at runtime by the escript (export calls `emacs --batch` with ox-hugo)
|
|
19: emacsWithOxHugo = (pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages
|
|
20: (epkgs: [ epkgs.ox-hugo ]);
|
|
21:
|
|
22: # =========================================================================
|
|
23: # Elixir Pipeline
|
|
24: # =========================================================================
|
|
25:
|
|
26: # Pre-fetched Hex/Mix dependencies
|
|
27: mixDeps = pkgs.beamPackages.fetchMixDeps {
|
|
28: pname = "org-garden-mix-deps";
|
|
29: version = "0.1.0";
|
|
30: src = fs.toSource {
|
|
31: root = ./.;
|
|
32: fileset = fs.unions [
|
|
33: ./mix.exs
|
|
34: ./mix.lock
|
|
35: ];
|
|
36: };
|
|
37: sha256 = "sha256-si7JAomY1HZ33m6ihUJP5i6PO39CE1clYvuMtn0CbPU=";
|
|
38: };
|
|
39:
|
|
40: # Compiled org-garden escript
|
|
41: orgGardenEscript = pkgs.beamPackages.mixRelease {
|
|
42: pname = "org-garden";
|
|
43: version = "0.1.0";
|
|
44: src = fs.toSource {
|
|
45: root = ./.;
|
|
46: fileset = fs.unions [
|
|
47: ./mix.exs
|
|
48: ./mix.lock
|
|
49: ./lib
|
|
50: ];
|
|
51: };
|
|
52: escriptBinName = "org_garden";
|
|
53: mixFodDeps = mixDeps;
|
|
54: stripDebug = true;
|
|
55: };
|
|
56:
|
|
57: # =========================================================================
|
|
58: # Quartz (fetched from upstream, patched)
|
|
59: # =========================================================================
|
|
60:
|
|
61: # Pin to specific upstream commit
|
|
62: quartzVersion = "4.5.2";
|
|
63: quartzRev = "ec00a40aefca73596ab76e3ebe3a8e1129b43688";
|
|
64:
|
|
65: # Fetch upstream Quartz source
|
|
66: quartzSrc = pkgs.fetchFromGitHub {
|
|
67: owner = "jackyzha0";
|
|
68: repo = "quartz";
|
|
69: rev = quartzRev;
|
|
70: hash = "sha256-HdtQB5+SRWiypOvAJuJa3Nodl4JHehp2Mz6Rj5gOG0w=";
|
|
71: };
|
|
72:
|
|
73: # Apply our patches to Quartz
|
|
74: quartzPatched = pkgs.runCommand "quartz-patched-${quartzVersion}" {
|
|
75: src = quartzSrc;
|
|
76: } ''
|
|
77: cp -r $src $out
|
|
78: chmod -R u+w $out
|
|
79: cd $out
|
|
80: patch -p1 < ${./patches/01-glob-gitignore.patch}
|
|
81: patch -p1 < ${./patches/02-build-gitignore.patch}
|
|
82: patch -p1 < ${./patches/03-static-hugo.patch}
|
|
83: patch -p1 < ${./patches/04-oxhugofm-figure.patch}
|
|
84: '';
|
|
85:
|
|
86: # Pre-fetch Quartz npm dependencies
|
|
87: quartzDeps = pkgs.buildNpmPackage {
|
|
88: pname = "org-garden-quartz-deps";
|
|
89: version = quartzVersion;
|
|
90: src = quartzPatched;
|
|
91: npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4=";
|
|
92: dontBuild = true;
|
|
93: installPhase = ''
|
|
94: mkdir -p $out
|
|
95: cp -r node_modules $out/node_modules
|
|
96: '';
|
|
97: };
|
|
98:
|
|
99: # =========================================================================
|
|
100: # Combined Application
|
|
101: # =========================================================================
|
|
102:
|
|
103: # Wrapped org-garden with Quartz bundled
|
|
104: orgGardenApp = pkgs.writeShellApplication {
|
|
105: name = "org-garden";
|
|
106: runtimeInputs = [ emacsWithOxHugo pkgs.inotify-tools pkgs.nodejs_22 ];
|
|
107: text = ''
|
|
108: # Set up Quartz working directory
|
|
109: QUARTZ_WORK=$(mktemp -d)
|
|
110: trap 'rm -rf "$QUARTZ_WORK"' EXIT
|
|
111:
|
|
112: # Copy patched Quartz source
|
|
113: cp -r ${quartzPatched}/. "$QUARTZ_WORK/"
|
|
114: chmod -R u+w "$QUARTZ_WORK"
|
|
115:
|
|
116: # Copy default config files
|
|
117: cp ${./quartz-config/quartz.config.ts} "$QUARTZ_WORK/"
|
|
118: cp ${./quartz-config/quartz.layout.ts} "$QUARTZ_WORK/"
|
|
119: cp ${./quartz-config/globals.d.ts} "$QUARTZ_WORK/"
|
|
120: cp ${./quartz-config/index.d.ts} "$QUARTZ_WORK/"
|
|
121:
|
|
122: # Link pre-built node_modules
|
|
123: ln -s ${quartzDeps}/node_modules "$QUARTZ_WORK/node_modules"
|
|
124:
|
|
125: export QUARTZ_PATH="$QUARTZ_WORK"
|
|
126: export NODE_PATH="${pkgs.nodejs_22}/bin/node"
|
|
127:
|
|
128: exec ${orgGardenEscript}/bin/org_garden "$@"
|
|
129: '';
|
|
130: };
|
|
131:
|
|
132: in
|
|
133: {
|
|
134: packages.default = orgGardenApp;
|
|
135: packages.escript = orgGardenEscript;
|
|
136: packages.quartz-patched = quartzPatched;
|
|
137:
|
|
138: devShells.default = pkgs.mkShell {
|
|
139: buildInputs = [
|
|
140: pkgs.elixir
|
|
141: pkgs.inotify-tools
|
|
142: emacsWithOxHugo
|
|
143: pkgs.nodejs_22
|
|
144: ];
|
|
145: };
|
|
146: });
|
|
147: }
|
|
148:
|
|
|
|
(End of file - total 148 lines)
|
|
</content>
|
|
```
|
|
## Agent (2026-02-21 16:44:01)
|
|
|
|
Now I have a complete picture of the codebase. Let me create a comprehensive implementation plan.
|
|
|
|
---
|
|
|
|
# Implementation Plan: Converting org-garden to a Production Service
|
|
|
|
## Overview
|
|
|
|
The goal is to transform org-garden from an escript CLI into a proper long-running Elixir service that can be deployed as a NixOS systemd service with proper supervision, configuration, health checks, and observability.
|
|
|
|
---
|
|
|
|
## Phase 1: Supervision Tree Integration
|
|
|
|
**Goal:** Ensure all processes are properly supervised and the application can run without `Process.sleep(:infinity)`.
|
|
|
|
### 1.1 Refactor Application Module
|
|
|
|
**File:** `lib/org_garden/application.ex`
|
|
|
|
- Add `OrgGarden.Supervisor` as a child of the Application supervisor
|
|
- Use a `DynamicSupervisor` to allow starting/stopping service modes dynamically
|
|
- Add a `Registry` for named process lookup (enables multiple instances if needed)
|
|
|
|
```
|
|
OrgGarden.AppSupervisor (:one_for_one)
|
|
├── Finch (HTTP client pool)
|
|
├── Registry (process registry)
|
|
└── DynamicSupervisor (for serve/export workers)
|
|
├── OrgGarden.Supervisor (when serve mode)
|
|
│ ├── Watcher
|
|
│ └── Quartz
|
|
└── OrgGarden.Watcher (when export --watch mode)
|
|
```
|
|
|
|
### 1.2 Create Server Module
|
|
|
|
**New file:** `lib/org_garden/server.ex`
|
|
|
|
- Encapsulate the "serve" mode startup logic
|
|
- Provide `start/1`, `stop/0` API
|
|
- Handles initial export, then starts supervised Watcher + Quartz
|
|
- Replaces `Process.sleep(:infinity)` with proper blocking via GenServer
|
|
|
|
### 1.3 Update CLI
|
|
|
|
**File:** `lib/org_garden/cli.ex`
|
|
|
|
- Remove `Process.sleep(:infinity)` calls
|
|
- Use the new `OrgGarden.Server.start/1` which blocks properly
|
|
- Handle SIGTERM via `:init.stop/0` or System signals
|
|
|
|
**Estimated effort:** Medium (2-3 hours)
|
|
|
|
---
|
|
|
|
## Phase 2: Configuration System
|
|
|
|
**Goal:** Replace scattered `System.get_env/1` calls with proper Elixir configuration.
|
|
|
|
### 2.1 Create Config Files
|
|
|
|
**New files:**
|
|
- `config/config.exs` — Base configuration with defaults
|
|
- `config/runtime.exs` — Runtime config from environment variables (Release-style)
|
|
|
|
### 2.2 Create Config Module
|
|
|
|
**New file:** `lib/org_garden/config.ex`
|
|
|
|
- Centralized configuration access via `OrgGarden.Config.get/1`
|
|
- Validation at startup (fail fast if required config missing)
|
|
- Type coercion (e.g., string ports to integers)
|
|
|
|
### 2.3 Update All Modules
|
|
|
|
Replace direct `System.get_env/1` calls in:
|
|
- `lib/org_garden/cli.ex`
|
|
- `lib/org_garden/quartz.ex`
|
|
- `lib/org_garden/transforms/citations.ex`
|
|
|
|
**Estimated effort:** Low-Medium (1-2 hours)
|
|
|
|
---
|
|
|
|
## Phase 3: Health Checks
|
|
|
|
**Goal:** Add HTTP health endpoint for systemd/kubernetes health probes.
|
|
|
|
### 3.1 Add HTTP Dependencies
|
|
|
|
**File:** `mix.exs`
|
|
|
|
Add minimal HTTP server:
|
|
```elixir
|
|
{:bandit, "~> 1.0"},
|
|
{:plug, "~> 1.15"}
|
|
```
|
|
|
|
### 3.2 Create Health Module
|
|
|
|
**New file:** `lib/org_garden/health.ex`
|
|
|
|
Endpoints:
|
|
- `GET /health/live` — Process alive (always 200)
|
|
- `GET /health/ready` — Dependencies ready (check Quartz port, Watcher subscribed)
|
|
- `GET /health` — Combined status JSON
|
|
|
|
### 3.3 Add to Supervision Tree
|
|
|
|
Start `Bandit` under `OrgGarden.AppSupervisor` on a configurable port (default: 9090).
|
|
|
|
**Estimated effort:** Medium (2 hours)
|
|
|
|
---
|
|
|
|
## Phase 4: Graceful Shutdown
|
|
|
|
**Goal:** Proper cleanup when service stops.
|
|
|
|
### 4.1 Add Signal Handling
|
|
|
|
**File:** `lib/org_garden/application.ex`
|
|
|
|
- Trap exits with `Process.flag(:trap_exit, true)`
|
|
- Handle `:sigterm` to initiate graceful shutdown
|
|
|
|
### 4.2 Improve Quartz Termination
|
|
|
|
**File:** `lib/org_garden/quartz.ex`
|
|
|
|
- Send SIGTERM to Node.js process instead of just `Port.close/1`
|
|
- Wait for graceful exit with timeout
|
|
- Force kill if timeout exceeded
|
|
|
|
### 4.3 Add Drain Period
|
|
|
|
**File:** `lib/org_garden/watcher.ex`
|
|
|
|
- On shutdown, flush pending debounce timers
|
|
- Wait for in-flight file processing to complete
|
|
- Configurable drain timeout
|
|
|
|
**Estimated effort:** Medium (2 hours)
|
|
|
|
---
|
|
|
|
## Phase 5: Observability
|
|
|
|
**Goal:** Add telemetry events and structured logging.
|
|
|
|
### 5.1 Add Telemetry Events
|
|
|
|
**New file:** `lib/org_garden/telemetry.ex`
|
|
|
|
Events:
|
|
- `[:org_garden, :export, :start | :stop | :exception]`
|
|
- `[:org_garden, :transform, :start | :stop]`
|
|
- `[:org_garden, :watcher, :file_changed]`
|
|
- `[:org_garden, :quartz, :request]`
|
|
|
|
### 5.2 Structured Logging
|
|
|
|
**File:** `lib/org_garden/application.ex`
|
|
|
|
Configure Logger with metadata:
|
|
```elixir
|
|
config :logger, :console,
|
|
format: "$time $metadata[$level] $message\n",
|
|
metadata: [:file, :module, :request_id]
|
|
```
|
|
|
|
### 5.3 Replace IO.puts with Logger
|
|
|
|
**Files:** `cli.ex`, `watcher.ex`, `quartz.ex`
|
|
|
|
- Replace all `IO.puts/1` with `Logger.info/1`
|
|
- Add metadata where appropriate
|
|
|
|
**Estimated effort:** Medium (2-3 hours)
|
|
|
|
---
|
|
|
|
## Phase 6: Mix Release Support
|
|
|
|
**Goal:** Build as a proper OTP release instead of escript.
|
|
|
|
### 6.1 Add Release Configuration
|
|
|
|
**File:** `mix.exs`
|
|
|
|
```elixir
|
|
def project do
|
|
[
|
|
...
|
|
releases: [
|
|
org_garden: [
|
|
include_executables_for: [:unix],
|
|
applications: [runtime_tools: :permanent]
|
|
]
|
|
]
|
|
]
|
|
end
|
|
```
|
|
|
|
### 6.2 Create Release Scripts
|
|
|
|
**New files:**
|
|
- `rel/env.sh.eex` — Environment setup
|
|
- `rel/vm.args.eex` — BEAM VM arguments
|
|
|
|
### 6.3 Update flake.nix
|
|
|
|
Replace `mixRelease` escript build with proper release:
|
|
```nix
|
|
orgGardenRelease = pkgs.beamPackages.mixRelease {
|
|
...
|
|
buildPhase = "mix release --overwrite";
|
|
};
|
|
```
|
|
|
|
**Estimated effort:** Medium (2 hours)
|
|
|
|
---
|
|
|
|
## Phase 7: NixOS Module
|
|
|
|
**Goal:** Provide a NixOS module for easy deployment.
|
|
|
|
### 7.1 Create NixOS Module
|
|
|
|
**New file:** `nix/module.nix`
|
|
|
|
```nix
|
|
{ config, lib, pkgs, ... }:
|
|
{
|
|
options.services.org-garden = {
|
|
enable = lib.mkEnableOption "org-garden service";
|
|
|
|
notesDir = lib.mkOption {
|
|
type = lib.types.path;
|
|
description = "Path to org-roam notes directory";
|
|
};
|
|
|
|
outputDir = lib.mkOption {
|
|
type = lib.types.path;
|
|
default = "/var/lib/org-garden";
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 8080;
|
|
};
|
|
|
|
healthPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 9090;
|
|
};
|
|
|
|
# ... more options
|
|
};
|
|
|
|
config = lib.mkIf config.services.org-garden.enable {
|
|
systemd.services.org-garden = {
|
|
description = "Org-Garden Publishing Service";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
|
|
serviceConfig = {
|
|
Type = "exec";
|
|
ExecStart = "${orgGardenApp}/bin/org-garden serve ...";
|
|
Restart = "on-failure";
|
|
RestartSec = 5;
|
|
|
|
# Hardening
|
|
DynamicUser = true;
|
|
StateDirectory = "org-garden";
|
|
ProtectSystem = "strict";
|
|
ProtectHome = true;
|
|
NoNewPrivileges = true;
|
|
};
|
|
|
|
environment = {
|
|
NOTES_DIR = config.services.org-garden.notesDir;
|
|
# ...
|
|
};
|
|
};
|
|
};
|
|
}
|
|
```
|
|
|
|
### 7.2 Add to Flake Outputs
|
|
|
|
**File:** `flake.nix`
|
|
|
|
```nix
|
|
outputs = {
|
|
...
|
|
nixosModules.default = import ./nix/module.nix;
|
|
};
|
|
```
|
|
|
|
**Estimated effort:** Medium (2-3 hours)
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
| Phase | Dependency | Effort | Priority |
|
|
|-------|------------|--------|----------|
|
|
| 1. Supervision Tree | None | Medium | Critical |
|
|
| 2. Configuration | None | Low-Medium | High |
|
|
| 4. Graceful Shutdown | Phase 1 | Medium | High |
|
|
| 3. Health Checks | Phase 1, 2 | Medium | Medium |
|
|
| 5. Observability | Phase 2 | Medium | Medium |
|
|
| 6. Mix Release | Phase 1-5 | Medium | Medium |
|
|
| 7. NixOS Module | Phase 6 | Medium | Low |
|
|
|
|
**Recommended order:** 1 → 2 → 4 → 3 → 5 → 6 → 7
|
|
|
|
**Total estimated effort:** 14-18 hours
|
|
|
|
---
|
|
|
|
## Questions Before Implementation
|
|
|
|
1. **Health check port**: Should it be separate from the Quartz HTTP port, or should we add routes to Quartz itself?
|
|
|
|
2. **Multiple instances**: Do you need to run multiple org-garden instances (different notes dirs) on the same machine? This affects Registry design.
|
|
|
|
3. **Metrics backend**: Do you want to add Prometheus metrics export, or just telemetry events for now?
|
|
|
|
4. **Backwards compatibility**: Should the escript CLI still work, or can we fully migrate to Mix Release?
|
|
|
|
5. **NixOS module scope**: Should it support both `serve` and `build` modes, or just `serve` as a long-running service?
|
|
|
|
## User (2026-02-21 16:45:45)
|
|
|
|
In the case of the CLI script, a mix release approach will allow us to run it standalone also?
|
|
|
|
## Agent (2026-02-21 16:45:47)
|
|
|
|
Yes, a Mix Release approach still allows standalone CLI execution. Here's how:
|
|
|
|
## Mix Release vs Escript Comparison
|
|
|
|
| Feature | Escript | Mix Release |
|
|
|---------|---------|-------------|
|
|
| Single file binary | Yes | No (directory with files) |
|
|
| Standalone execution | Yes | Yes (via `bin/org_garden` script) |
|
|
| Signal handling (SIGTERM) | Poor | Good (built into BEAM) |
|
|
| Runtime configuration | Limited | Full (`releases.exs`) |
|
|
| Hot code upgrades | No | Optional |
|
|
| Start as daemon | Manual | Built-in (`--daemon`) |
|
|
| Remote shell | No | Yes (`remote`) |
|
|
| Distribution | Single file copy | Tarball or Nix package |
|
|
|
|
## How CLI Works with Mix Release
|
|
|
|
A Mix Release generates a `bin/org_garden` script that supports multiple commands:
|
|
|
|
```bash
|
|
# Run CLI commands (like current escript)
|
|
./bin/org_garden eval "OrgGarden.CLI.main(['serve', '/path/to/notes'])"
|
|
|
|
# Or with a custom entry script
|
|
./bin/org_garden serve /path/to/notes
|
|
|
|
# Also supports daemon mode
|
|
./bin/org_garden daemon # Start as background service
|
|
./bin/org_garden remote # Attach remote shell
|
|
./bin/org_garden stop # Graceful stop with SIGTERM
|
|
./bin/org_garden pid # Get PID
|
|
```
|
|
|
|
## Recommended Approach: Support Both Modes
|
|
|
|
We can structure the release to support:
|
|
|
|
1. **CLI mode** (one-shot commands like `build`, `export`)
|
|
2. **Service mode** (long-running `serve` with proper supervision)
|
|
|
|
```elixir
|
|
# rel/overlays/bin/org-garden (custom entry script)
|
|
#!/bin/sh
|
|
case "$1" in
|
|
serve)
|
|
# Start as a supervised service
|
|
exec "$RELEASE_ROOT/bin/org_garden" start "$@"
|
|
;;
|
|
build|export)
|
|
# Run as one-shot command
|
|
exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main(System.argv())"
|
|
;;
|
|
*)
|
|
# Pass through to release commands (daemon, remote, stop, etc.)
|
|
exec "$RELEASE_ROOT/bin/org_garden" "$@"
|
|
;;
|
|
esac
|
|
```
|
|
|
|
This gives you:
|
|
- `org-garden serve /notes` — Starts supervised service (blocks, handles SIGTERM)
|
|
- `org-garden build /notes` — One-shot build, exits when done
|
|
- `org-garden export /notes` — One-shot export
|
|
- `org-garden daemon` — Start as background daemon
|
|
- `org-garden stop` — Graceful shutdown
|
|
- `org-garden remote` — Attach IEx shell to running instance
|
|
|
|
## Trade-off: Distribution
|
|
|
|
The main downside vs escript is distribution:
|
|
- **Escript**: Single `org_garden` file (~5MB), copy anywhere with Erlang installed
|
|
- **Release**: Directory or tarball (~30MB), but includes ERTS (no Erlang needed on target)
|
|
|
|
For Nix deployment, this is a non-issue since Nix handles the packaging. The release becomes a single Nix package.
|
|
|
|
---
|
|
|
|
**Question:** Do you want to:
|
|
|
|
1. **Replace escript entirely** with Mix Release (recommended for service use)
|
|
2. **Keep both** — escript for simple distribution, release for service deployment
|
|
3. **Hybrid** — Mix Release with bundled ERTS for fully standalone deployment (no Erlang/Nix needed on target)
|
|
|
|
## User (2026-02-21 16:49:13)
|
|
|
|
How does elixir projects usually handle services that also offers CLI tools?
|
|
|
|
## Agent (2026-02-21 16:49:15)
|
|
|
|
Great question. Let me research how established Elixir projects handle this pattern.
|
|
|
|
### Tool Call [completed]: webfetch
|
|
|
|
**Tool:** fetch
|
|
**Timestamp:** 2026-02-21 16:49:17
|
|
|
|
```
|
|
mix release — Mix v1.19.5
|
|
|
|
[](https://elixir-lang.org/docs.html)
|
|
|
|
[Mix](https://elixir-lang.org/docs.html)
|
|
|
|
v1.19.5
|
|
|
|
Search documentation of Mix
|
|
|
|
Default
|
|
|
|
Default In-browser search
|
|
|
|
Settings
|
|
|
|
# mix release (Mix v1.19.5)
|
|
|
|
[View Source](https://github.com/elixir-lang/elixir/blob/v1.19.5/lib/mix/lib/mix/tasks/release.ex#L5 "View Source")
|
|
|
|
Assembles a self-contained release for the current project:
|
|
|
|
```
|
|
$ MIX_ENV=prod mix release
|
|
$ MIX_ENV=prod mix release NAME
|
|
```
|
|
|
|
Once a release is assembled, it can be packaged and deployed to a target, as long as the target runs on the same operating system (OS) distribution and version as the machine running the [`mix release`](Mix.Tasks.Release.html) command. Windows releases also require Microsoft Visual C++ Runtime.
|
|
|
|
A release can be configured in your `mix.exs` file under the `:releases` key inside `def project`:
|
|
|
|
```
|
|
def project do
|
|
[
|
|
releases: [
|
|
demo: [
|
|
include_executables_for: [:unix],
|
|
applications: [runtime_tools: :permanent]
|
|
],
|
|
|
|
...
|
|
]
|
|
]
|
|
end
|
|
```
|
|
|
|
You can specify multiple releases where the key is the release name and the value is a keyword list with the release configuration. Releasing a certain name is done with:
|
|
|
|
```
|
|
$ MIX_ENV=prod mix release demo
|
|
```
|
|
|
|
If the given name does not exist, an error is raised.
|
|
|
|
If [`mix release`](Mix.Tasks.Release.html) is invoked, without specifying a release name, and there are multiple releases configured, an error will be raised unless you set `default_release: NAME` at the root of your project configuration.
|
|
|
|
If [`mix release`](Mix.Tasks.Release.html) is invoked and there are no releases configured, a release is assembled using the application name and default values.
|
|
|
|
## [](#module-why-releases)Why releases?
|
|
|
|
Releases allow developers to precompile and package all of their code and the runtime into a single unit. The benefits of releases are:
|
|
|
|
- Code preloading. The VM has two mechanisms for loading code: interactive and embedded. By default, it runs in the interactive mode which dynamically loads modules when they are used for the first time. The first time your application calls [`Enum.map/2`](https://hexdocs.pm/elixir/Enum.html#map/2), the VM will find the [`Enum`](https://hexdocs.pm/elixir/Enum.html) module and load it. There's a downside: when you start a new server in production, it may need to load many other modules, causing the first requests to have an unusual spike in response time. With releases, the system preloads all modules and guarantees your system is ready to handle requests after booting.
|
|
|
|
- Configuration and customization. Releases give developers fine grained control over system configuration and the VM flags used to start the system.
|
|
|
|
- Self-contained. A release does not require the source code to be included in your production artifacts. All of the code is precompiled and packaged. Releases do not even require Erlang or Elixir in your servers, as it includes the Erlang VM and its runtime by default. Furthermore, both Erlang and Elixir standard libraries are stripped to bring only the parts you are actually using.
|
|
|
|
- Multiple releases. You can assemble different releases with different configuration per application or even with different applications altogether.
|
|
|
|
- Management scripts. Releases come with scripts to start, restart, connect to the running system remotely, execute RPC calls, run as daemon, run as a Windows service, and more.
|
|
|
|
|
|
## [](#module-running-the-release)Running the release
|
|
|
|
Once a release is assembled, you can start it by calling `bin/RELEASE_NAME start` inside the release. In production, you would do:
|
|
|
|
```
|
|
$ MIX_ENV=prod mix release
|
|
$ _build/prod/rel/my_app/bin/my_app start
|
|
```
|
|
|
|
`bin/my_app start` will start the system connected to the current standard input/output, where logs are also written to by default. This is the preferred way to run the system. Many tools, such as `systemd`, platforms as a service, such as Heroku, and many containers platforms, such as Docker, are capable of processing the standard input/output and redirecting the log contents elsewhere. Those tools and platforms also take care of restarting the system in case it crashes.
|
|
|
|
You can also execute one-off commands, run the release as a daemon on Unix-like system, or install it as a service on Windows. We will take a look at those next. You can also list all available commands by invoking `bin/RELEASE_NAME`.
|
|
|
|
### [](#module-one-off-commands-eval-and-rpc)One-off commands (eval and rpc)
|
|
|
|
If you want to invoke specific modules and functions in your release, you can do so in two ways: using `eval` or `rpc`.
|
|
|
|
```
|
|
$ bin/RELEASE_NAME eval "IO.puts(:hello)"
|
|
$ bin/RELEASE_NAME rpc "IO.puts(:hello)"
|
|
```
|
|
|
|
The `eval` command starts its own instance of the VM but without starting any of the applications in the release and without starting distribution. For example, if you need to do some prep work before running the actual system, like migrating your database, `eval` can be a good fit. Just keep in mind any application you may use during eval has to be explicitly started.
|
|
|
|
You can start an application by calling [`Application.ensure_all_started/1`](https://hexdocs.pm/elixir/Application.html#ensure_all_started/1). From Elixir v1.16, it is guaranteed the applications have been at least loaded. In earlier versions, if you needed to load applications but not start them, you also needed to call [`Application.load/1`](https://hexdocs.pm/elixir/Application.html#load/1).
|
|
|
|
Another way to run commands is with `rpc`, which will connect to the system currently running and instruct it to execute the given expression. This means you need to guarantee the system was already started and be careful with the instructions you are executing. You can also use `remote` to connect a remote IEx session to the system.
|
|
|
|
#### Helper module
|
|
|
|
As you operate your system, you may find yourself running some piece of code as a one-off command quite often. You may consider creating a module to group these tasks:
|
|
|
|
```
|
|
# lib/my_app/release_tasks.ex
|
|
defmodule MyApp.ReleaseTasks do
|
|
def eval_purge_stale_data() do
|
|
# Eval commands needs to start the app before
|
|
# Or Application.load(:my_app) if you can't start it
|
|
Application.ensure_all_started(:my_app)
|
|
|
|
# Code that purges stale data
|
|
...
|
|
end
|
|
|
|
def rpc_print_connected_users() do
|
|
# Code that print users connected to the current running system
|
|
...
|
|
end
|
|
end
|
|
```
|
|
|
|
In the example above, we prefixed the function names with the command name used to execute them, but that is entirely optional.
|
|
|
|
And to run them:
|
|
|
|
```
|
|
$ bin/RELEASE_NAME eval "MyApp.ReleaseTasks.eval_purge_stale_data()"
|
|
$ bin/RELEASE_NAME rpc "MyApp.ReleaseTasks.rpc_print_connected_users()"
|
|
```
|
|
|
|
### [](#module-daemon-mode-unix-like)Daemon mode (Unix-like)
|
|
|
|
You can run the release in daemon mode with the command:
|
|
|
|
```
|
|
$ bin/RELEASE_NAME daemon
|
|
```
|
|
|
|
In daemon mode, the system is started on the background via [`run_erl`](https://www.erlang.org/doc/apps/erts/run_erl_cmd.html). You may also want to enable [`:heart`](https://www.erlang.org/doc/apps/kernel/heart.html) in daemon mode so it automatically restarts the system in case of crashes. See the generated `releases/RELEASE_VSN/env.sh` file.
|
|
|
|
The daemon will write all of its standard output to the `tmp/log/` directory in the release root. You can watch the log file by doing `tail -f tmp/log/erlang.log.1` or similar. Once files get too large, the index suffix will be incremented. A developer can also attach to the standard input of the daemon by invoking `to_erl tmp/pipe/` from the release root. However, note that attaching to the system should be done with extreme care, since the usual commands for exiting an Elixir system, such as hitting Ctrl+C twice or Ctrl+\\, will actually shut down the daemon. Therefore, using `bin/RELEASE_NAME remote` should be preferred, even in daemon mode.
|
|
|
|
You can customize the tmp directory used both for logging and for piping in daemon mode by setting the `RELEASE_TMP` environment variable. See the "Customization" section.
|
|
|
|
### [](#module-services-mode-windows)Services mode (Windows)
|
|
|
|
While daemons are not available on Windows, it is possible to install a released system as a service on Windows with the help of [`erlsrv`](https://www.erlang.org/doc/apps/erts/erlsrv_cmd.html). This can be done by running:
|
|
|
|
```
|
|
$ bin/RELEASE_NAME install
|
|
```
|
|
|
|
Once installed, the service must be explicitly managed via the `erlsrv` executable, which is included in the `erts-VSN/bin` directory. The service is not started automatically after installing.
|
|
|
|
For example, if you have a release named `demo`, you can install the service and then start it from the release root as follows:
|
|
|
|
```
|
|
$ bin/demo install
|
|
$ erts-VSN/bin/erlsrv.exe start demo_demo
|
|
```
|
|
|
|
The name of the service is `demo_demo` because the name is built by concatenating the node name with the release name. Since Elixir automatically uses the same name for both, the service will be referenced as `demo_demo`.
|
|
|
|
The `install` command must be executed as an administrator.
|
|
|
|
### [](#module-bin-release_name-commands)`bin/RELEASE_NAME` commands
|
|
|
|
The following commands are supported by `bin/RELEASE_NAME`:
|
|
|
|
```
|
|
start Starts the system
|
|
start_iex Starts the system with IEx attached
|
|
daemon Starts the system as a daemon (Unix-like only)
|
|
daemon_iex Starts the system as a daemon with IEx attached (Unix-like only)
|
|
install Installs this system as a Windows service (Windows only)
|
|
eval "EXPR" Executes the given expression on a new, non-booted system
|
|
rpc "EXPR" Executes the given expression remotely on the running system
|
|
remote Connects to the running system via a remote shell
|
|
restart Restarts the running system via a remote command
|
|
stop Stops the running system via a remote command
|
|
pid Prints the operating system PID of the running system via a remote command
|
|
version Prints the release name and version to be booted
|
|
```
|
|
|
|
## [](#module-deployments)Deployments
|
|
|
|
### [](#module-requirements)Requirements
|
|
|
|
A release is built on a **host**, a machine which contains Erlang, Elixir, and any other dependencies needed to compile your application. A release is then deployed to a **target**, potentially the same machine as the host, but usually separate, and often there are many targets (either multiple instances, or the release is deployed to heterogeneous environments).
|
|
|
|
To deploy straight from a host to a separate target, the following must be the same between the host and the target:
|
|
|
|
- Target architecture (for example, x86\_64 or ARM)
|
|
- Target vendor + operating system (for example, Windows, Linux, or Darwin/macOS)
|
|
- Target ABI (for example, musl or gnu)
|
|
|
|
This is often represented in the form of target triples, for example, `x86_64-unknown-linux-gnu`, `x86_64-unknown-linux-musl`, `x86_64-apple-darwin`. If you are building on a MacBook (`x86_64-apple-darwin`) and trying to deploy to a typical Ubuntu machine (`x86_64-unknown-linux-gnu`), the release will not work. Instead you should build the release on a `x86_64-unknown-linux-gnu` host.
|
|
|
|
Typically, different versions of Erlang VM and Elixir are available for different targets via package managers, precompiled artifacts, and similar. However, to deploy from a host to a separate target, you must also guarantee that any dependency with NIFs (Natively-Implemented Functions) are compiled for the same triplet. As we will see, this can be done in different ways, such as releasing on the target itself, or by using virtual machines or containers, usually as part of your release pipeline.
|
|
|
|
In addition to matching the target triple, it is also important that the target has all of the system packages that your application will need at runtime. A common one is the need for OpenSSL when building an application that uses `:crypto` or `:ssl`, which is dynamically linked to the Erlang VM. Project dependencies containing NIFs (natively-implemented functions) may also dynamically link to system libraries, so check those accordingly.
|
|
|
|
Of course, some operating systems and package managers can differ between versions, so if your goal is to have full compatibility between host and target, it is best to ensure the operating system and system package manager have the same versions on host and target. This may even be a requirement in some systems, especially so with package managers that try to create fully reproducible environments (Nix, Guix).
|
|
|
|
### [](#module-using-matching-host-and-target)Using matching host and target
|
|
|
|
There are a couple of ways to guarantee that a release is built on a host with the same properties as the target. A simple option is to fetch the source, compile the code and assemble the release on the target itself. It would be something like this:
|
|
|
|
```
|
|
$ git clone remote://path/to/my_app.git my_app_source
|
|
$ cd my_app_source
|
|
$ mix deps.get --only prod
|
|
$ MIX_ENV=prod mix release
|
|
$ _build/prod/rel/my_app/bin/my_app start
|
|
```
|
|
|
|
If you prefer, you can also compile the release to a separate directory, so you can erase all source after the release is assembled:
|
|
|
|
```
|
|
$ git clone remote://path/to/my_app.git my_app_source
|
|
$ cd my_app_source
|
|
$ mix deps.get --only prod
|
|
$ MIX_ENV=prod mix release --path ../my_app_release
|
|
$ cd ../my_app_release
|
|
$ rm -rf ../my_app_source
|
|
$ bin/my_app start
|
|
```
|
|
|
|
However, this option can be expensive if you have multiple production nodes or if the release assembling process is a long one, as each node needs to individually assemble the release.
|
|
|
|
You can automate this process in a couple different ways. One option is to make it part of your Continuous Integration (CI) / Continuous Deployment (CD) pipeline. When you have a CI/CD pipeline, it is common that the machines in your CI/CD pipeline run on the exact same target triple as your production servers (if they don't, they should). In this case, you can assemble the release at the end of your CI/CD pipeline by calling `MIX_ENV=prod mix release` and push the artifact to S3 or any other network storage. To perform the deployment, your production machines can fetch the deployment from the network storage and run `bin/my_app start`.
|
|
|
|
### [](#module-using-images)Using images
|
|
|
|
Another mechanism to automate deployments is to use images, such as Amazon Machine Images, or container platforms, such as Docker. For instance, you can use Docker to run locally a system with the exact same target triple as your production servers. Inside the container, you can invoke `MIX_ENV=prod mix release` and build a complete image and/or container with the operating system, all dependencies as well as the releases.
|
|
|
|
However, when building such images on your machine, those technologies use emulation which may not interplay well with Erlang VM's JIT (just-in time) compiler. To address this, you can set this environment variable on your build stage:
|
|
|
|
```
|
|
ENV ERL_AFLAGS "+JMsingle true"
|
|
```
|
|
|
|
## [](#module-shutting-down)Shutting down
|
|
|
|
Once a system is deployed, shutting down the system can be done by sending SIGINT/SIGTERM to the system, which is what most containers, platforms and tools do, or by explicitly invoking `bin/RELEASE_NAME stop`. Once the system receives the shutdown request, each application and their respective supervision trees will stop, one by one, in the opposite order that they were started.
|
|
|
|
## [](#module-customization)Customization
|
|
|
|
There are a couple ways in which developers can customize the generated artifacts inside a release.
|
|
|
|
### [](#module-options)Options
|
|
|
|
The following options can be set inside your `mix.exs` on each release definition:
|
|
|
|
- `:applications` - a keyword list with application names as keys and their mode as value. By default `:applications` includes the current application and all applications the current application depends on, recursively. You can include new applications or change the mode of existing ones by listing them here.
|
|
|
|
The order of the applications given will be preserved as much as possible, with only `:kernel`, `:stdlib`, `:sasl`, and `:elixir` listed before the given application list. The supported values are:
|
|
|
|
- `:permanent` (default) - the application is started and the node shuts down if the application terminates, regardless of reason
|
|
- `:transient` - the application is started and the node shuts down if the application terminates abnormally
|
|
- `:temporary` - the application is started and the node does not shut down if the application terminates
|
|
- `:load` - the application is only loaded
|
|
- `:none` - the application is part of the release but it is neither loaded nor started
|
|
|
|
If you change the mode of an application, the mode will apply to all its child applications. However, if an application has two parents, the mode of the parent with highest priority wins (where `:permanent` has the highest priority, according to the list above).
|
|
|
|
- `:strip_beams` - controls if BEAM files should have their debug information, documentation chunks, and other non-essential metadata removed. Defaults to `true`. May be set to `false` to disable stripping. Also accepts `[keep: ["Docs", "Dbgi"]]` to keep certain chunks that are usually stripped. You can also set the `:compress` option to true to enable individual compression of BEAM files, although it is typically preferred to compress the whole release instead.
|
|
|
|
- `:cookie` - a string representing the Erlang Distribution cookie. If this option is not set, a random cookie is written to the `releases/COOKIE` file when the first release is assembled. At runtime, we will first attempt to fetch the cookie from the `RELEASE_COOKIE` environment variable and then we'll read the `releases/COOKIE` file.
|
|
|
|
If you are setting this option manually, we recommend the cookie option to be a long and randomly generated string, such as: `Base.encode32(:crypto.strong_rand_bytes(40))`. We also recommend restricting the characters in the cookie to only alphanumeric characters and underscore.
|
|
|
|
- `:validate_compile_env` - by default a release will match all runtime configuration against any configuration that was marked at compile time in your application of its dependencies via the [`Application.compile_env/3`](https://hexdocs.pm/elixir/Application.html#compile_env/3) function. If there is a mismatch between those, it means your system is misconfigured and unable to boot. You can disable this check by setting this option to false.
|
|
|
|
- `:path` - the path the release should be installed to. Defaults to `"_build/MIX_ENV/rel/RELEASE_NAME"`.
|
|
|
|
- `:version` - the release version as a string or `{:from_app, app_name}`. Defaults to the current application version. The `{:from_app, app_name}` format can be used to easily reference the application version from another application. This is particularly useful in umbrella applications.
|
|
|
|
- `:quiet` - a boolean that controls if releases should write steps to the standard output. Defaults to `false`.
|
|
|
|
- `:include_erts` - a boolean, string, or anonymous function of arity zero. If a boolean, it indicates whether the Erlang Runtime System (ERTS), which includes the Erlang VM, should be included in the release. The default is `true`, which is also the recommended value. If a string, it represents the path to an existing ERTS installation. If an anonymous function of arity zero, it's a function that returns any of the above (boolean or string).
|
|
|
|
You may also set this option to `false` if you desire to use the ERTS version installed on the target. Note, however, that the ERTS version on the target must have **the exact version** as the ERTS version used when the release is assembled. Setting it to `false` also disables hot code upgrades. Therefore, `:include_erts` should be set to `false` with caution and only if you are assembling the release on the same server that runs it.
|
|
|
|
- `:include_executables_for` - a list of atoms detailing for which Operating Systems executable files should be generated for. By default, it is set to `[:unix, :windows]`. You can customize those as follows:
|
|
|
|
```
|
|
releases: [
|
|
demo: [
|
|
include_executables_for: [:unix] # Or [:windows] or []
|
|
]
|
|
]
|
|
```
|
|
|
|
- `:rel_templates_path` - the path to find template files that are copied to the release, such as `vm.args.eex`, `remote.vm.args.eex`, `env.sh.eex` (or `env.bat.eex`), and `overlays`. Defaults to `"rel"` in the project root.
|
|
|
|
- `:overlays` - a list of directories with extra files to be copied as is to the release. The "overlays" directory at `:rel_templates_path` is always included in this list by default (typically at `"rel/overlays"`). See the "Overlays" section for more information.
|
|
|
|
- `:steps` - a list of steps to execute when assembling the release. See the "Steps" section for more information.
|
|
|
|
- `:skip_mode_validation_for` - a list of application names (atoms) specifying applications to skip strict validation of "unsafe" modes. An "unsafe" case is when a parent application mode is `:permanent` but one of the applications it depends on is set to `:load`. Use this with care, as a release with invalid modes may no longer boot without additional tweaks. Defaults to `[]`.
|
|
|
|
|
|
Note each release definition can be given as an anonymous function. This is useful if some release attributes are expensive to compute:
|
|
|
|
```
|
|
releases: [
|
|
demo: fn ->
|
|
[version: @version <> "+" <> git_ref()]
|
|
end
|
|
]
|
|
```
|
|
|
|
Besides the options above, it is possible to customize the generated release with custom files, by tweaking the release steps or by running custom options and commands on boot. We will detail both approaches next.
|
|
|
|
### [](#module-overlays)Overlays
|
|
|
|
Often it is necessary to copy extra files to the release root after the release is assembled. This can be easily done by placing such files in the `rel/overlays` directory. Any file in there is copied as is to the release root. For example, if you have placed a `rel/overlays/Dockerfile` file, the "Dockerfile" will be copied as is to the release root.
|
|
|
|
If you want to specify extra overlay directories, you can do so with the `:overlays` option. If you need to copy files dynamically, see the "Steps" section.
|
|
|
|
### [](#module-steps)Steps
|
|
|
|
It is possible to add one or more steps before and after the release is assembled. This can be done with the `:steps` option:
|
|
|
|
```
|
|
releases: [
|
|
demo: [
|
|
steps: [&set_configs/1, :assemble, ©_extra_files/1]
|
|
]
|
|
]
|
|
```
|
|
|
|
The `:steps` option must be a list and it must always include the atom `:assemble`, which does most of the release assembling. You can pass anonymous functions before and after the `:assemble` to customize your release assembling pipeline. Those anonymous functions will receive a [`Mix.Release`](Mix.Release.html) struct and must return the same or an updated [`Mix.Release`](Mix.Release.html) struct. It is also possible to build a tarball of the release by passing the `:tar` step anywhere after `:assemble`. If the release `:path` is not configured, the tarball is created in `_build/MIX_ENV/RELEASE_NAME-RELEASE_VSN.tar.gz` Otherwise it is created inside the configured `:path`.
|
|
|
|
See [`Mix.Release`](Mix.Release.html) for more documentation on the struct and which fields can be modified. Note that the `:steps` field itself can be modified and it is updated every time a step is called. Therefore, if you need to execute a command before and after assembling the release, you only need to declare the first steps in your pipeline and then inject the last step into the release struct. The steps field can also be used to verify if the step was set before or after assembling the release.
|
|
|
|
### [](#module-vm-args-and-env-sh-env-bat)vm.args and env.sh (env.bat)
|
|
|
|
Developers may want to customize the VM flags and environment variables given when the release starts. The simplest way to customize those files is by running [`mix release.init`](Mix.Tasks.Release.Init.html). The Mix task will copy custom `rel/vm.args.eex`, `rel/remote.vm.args.eex`, `rel/env.sh.eex`, and `rel/env.bat.eex` files to your project root. You can modify those files and they will be evaluated every time you perform a new release. Those files are regular EEx templates and they have a single assign, called `@release`, with the [`Mix.Release`](Mix.Release.html) struct.
|
|
|
|
The `vm.args` and `remote.vm.args` files may contain any of the VM flags accepted by the [`erl` command](https://www.erlang.org/doc/man/erl.html).
|
|
|
|
The `env.sh` and `env.bat` is used to set environment variables. In there, you can set vars such as `RELEASE_NODE`, `RELEASE_COOKIE`, and `RELEASE_TMP` to customize your node name, cookie and tmp directory respectively. Whenever `env.sh` or `env.bat` is invoked, the variables `RELEASE_ROOT`, `RELEASE_NAME`, `RELEASE_VSN`, and `RELEASE_COMMAND` have already been set, so you can rely on them. See the section on environment variables for more information.
|
|
|
|
Furthermore, while the `vm.args` files are static, you can use `env.sh` and `env.bat` to dynamically set VM options. For example, if you want to make sure the Erlang Distribution listens only on a given port known at runtime, you can set the following:
|
|
|
|
```
|
|
case $RELEASE_COMMAND in
|
|
start*|daemon*)
|
|
ELIXIR_ERL_OPTIONS="-kernel inet_dist_listen_min $BEAM_PORT inet_dist_listen_max $BEAM_PORT"
|
|
export ELIXIR_ERL_OPTIONS
|
|
;;
|
|
*)
|
|
;;
|
|
esac
|
|
```
|
|
|
|
Note we only set the port on start/daemon commands. If you also limit the port on other commands, such as `rpc`, then you will be unable to establish a remote connection as the port will already be in use by the node.
|
|
|
|
On Windows, your `env.bat` would look like this:
|
|
|
|
```
|
|
IF NOT %RELEASE_COMMAND:start=%==%RELEASE_COMMAND% (
|
|
set ELIXIR_ERL_OPTIONS="-kernel inet_dist_listen_min %BEAM_PORT% inet_dist_listen_max %BEAM_PORT%"
|
|
)
|
|
```
|
|
|
|
Inside `env.sh` and `env.bat` files you can access command-line arguments given to release commands. For example, given this `env.sh.eex`:
|
|
|
|
```
|
|
echo $@
|
|
```
|
|
|
|
or this `env.bat.eex`:
|
|
|
|
```
|
|
echo %*
|
|
```
|
|
|
|
starting the release with `bin/myapp start --foo bar baz` will print `start --foo bar baz`.
|
|
|
|
### [](#module-epmd-less-deployment)`epmd`\-less deployment
|
|
|
|
When a distributed Erlang/Elixir node starts, it runs a separate daemon called EPMD (Erlang Port Mapper Daemon) and registers the node name within EPMD. It is possible to skip this additional Operating System process by setting the following flags in your vm.args files:
|
|
|
|
```
|
|
# In vm.args.eex
|
|
-start_epmd false -erl_epmd_port 6789
|
|
|
|
# In remote.vm.args.eex
|
|
-start_epmd false -erl_epmd_port 6789 -dist_listen false
|
|
```
|
|
|
|
You can pick any port of your choice.
|
|
|
|
## [](#module-application-configuration)Application configuration
|
|
|
|
Mix provides two mechanisms for configuring the application environment of your application and your dependencies: build-time and runtime. On this section, we will learn how those mechanisms apply to releases. An introduction to this topic can be found in the "Configuration" section of the [`Mix`](Mix.html) module.
|
|
|
|
### [](#module-build-time-configuration)Build-time configuration
|
|
|
|
Whenever you invoke a `mix` command, Mix loads the configuration in `config/config.exs`, if said file exists. We say that this configuration is a build-time configuration as it is evaluated whenever you compile your code or whenever you assemble the release.
|
|
|
|
In other words, if your configuration does something like:
|
|
|
|
```
|
|
import Config
|
|
config :my_app, :secret_key, System.fetch_env!("MY_APP_SECRET_KEY")
|
|
```
|
|
|
|
The `:secret_key` key under `:my_app` will be computed on the host machine, whenever the release is built. Therefore if the machine assembling the release not have access to all environment variables used to run your code, loading the configuration will fail as the environment variable is missing. Luckily, Mix also provides runtime configuration, which should be preferred and we will see next.
|
|
|
|
### [](#module-runtime-configuration)Runtime configuration
|
|
|
|
To enable runtime configuration in your release, create a file named `config/runtime.exs`:
|
|
|
|
```
|
|
import Config
|
|
config :my_app, :secret_key, System.fetch_env!("MY_APP_SECRET_KEY")
|
|
```
|
|
|
|
This file will be executed whenever your Mix project or your release starts.
|
|
|
|
Your `config/runtime.exs` file needs to follow three important rules:
|
|
|
|
- It MUST `import Config` at the top instead of the deprecated `use Mix.Config`
|
|
- It MUST NOT import any other configuration file via `import_config`
|
|
- It MUST NOT access [`Mix`](Mix.html) in any way, as [`Mix`](Mix.html) is a build tool and it is not available inside releases
|
|
|
|
If a `config/runtime.exs` exists, it will be copied to your release and executed early in the boot process, when only Elixir and Erlang's main applications have been started.
|
|
|
|
You can change the path to the runtime configuration file by setting `:runtime_config_path` inside each release configuration. This path is resolved at build time as the given configuration file is always copied to inside the release:
|
|
|
|
```
|
|
releases: [
|
|
demo: [
|
|
runtime_config_path: ...
|
|
]
|
|
]
|
|
```
|
|
|
|
By setting `:runtime_config_path` to `false` it can be used to prevent a runtime configuration file to be included in the release.
|
|
|
|
### [](#module-config-providers)Config providers
|
|
|
|
Releases also supports custom mechanisms, called config providers, to load any sort of runtime configuration to the system while it boots. For instance, if you need to access a vault or load configuration from a JSON file, it can be achieved with config providers. The runtime configuration outlined in the previous section is handled by the [`Config.Reader`](https://hexdocs.pm/elixir/Config.Reader.html) provider. See the [`Config.Provider`](https://hexdocs.pm/elixir/Config.Provider.html) module for more information and more examples.
|
|
|
|
The following options can be set inside your releases key in your `mix.exs` to control how config providers work:
|
|
|
|
- `:reboot_system_after_config` - reboot the system after configuration so you can configure system applications, such as `:kernel` and `:stdlib`, in your `config/runtime.exs`. Generally speaking, it is best to configure `:kernel` and `:stdlib` using the `vm.args` file but this option is available for those who need more complex configuration. When set to `true`, the release will first boot in interactive mode to compute a config file and write it to the "tmp" directory. Then it reboots in the configured `RELEASE_MODE`. You can configure the "tmp" directory by setting the `RELEASE_TMP` environment variable, either explicitly or inside your `releases/RELEASE_VSN/env.sh` (or `env.bat` on Windows). Defaults to `true` if using the deprecated `config/releases.exs`, `false` otherwise. Be careful of which libraries you load when setting this option to true, if a library is loaded early during configuration and it includes native code, it may not actually be able to restart cleanly.
|
|
|
|
- `:prune_runtime_sys_config_after_boot` - if `:reboot_system_after_config` is set, every time your system boots, the release will write a config file to your tmp directory. These configuration files are generally small. But if you are concerned with disk space or if you have other restrictions, you can ask the system to remove said config files after boot. The downside is that you will no longer be able to restart the system internally (neither via [`System.restart/0`](https://hexdocs.pm/elixir/System.html#restart/0) nor `bin/RELEASE_NAME restart`). If you need a restart, you will have to terminate the Operating System process and start a new one. Defaults to `false`.
|
|
|
|
- `:start_distribution_during_config` - if `:reboot_system_after_config` is set, releases only start the Erlang VM distribution features after the config files are evaluated. You can set it to `true` if you need distribution during configuration. Defaults to `false`.
|
|
|
|
- `:config_providers` - a list of tuples with custom config providers. See [`Config.Provider`](https://hexdocs.pm/elixir/Config.Provider.html) for more information. Defaults to `[]`.
|
|
|
|
|
|
### [](#module-customization-and-configuration-summary)Customization and configuration summary
|
|
|
|
Generally speaking, the following files are available for customizing and configuring the running system:
|
|
|
|
- `config/config.exs` (and `config/prod.exs`) - provides build-time application configuration, which are executed when the release is assembled
|
|
|
|
- `config/runtime.exs` - provides runtime application configuration. It is executed every time your Mix project or your release boots and is further extensible via config providers. If you want to detect you are inside a release, you can check for release specific environment variables, such as `RELEASE_NODE` or `RELEASE_MODE`
|
|
|
|
- `rel/vm.args.eex` and `rel/remote.vm.args.eex` - template files that are copied into every release and provides static configuration of the Erlang Virtual Machine and other runtime flags. `vm.args` runs on `start`, `daemon`, and `eval` commands. `remote.vm.args` configures the VM for `remote` and `rpc` commands
|
|
|
|
- `rel/env.sh.eex` and `rel/env.bat.eex` - template files that are copied into every release and are executed on every command to set up environment variables, including specific ones to the VM, and the general environment
|
|
|
|
|
|
## [](#module-directory-structure)Directory structure
|
|
|
|
A release is organized as follows:
|
|
|
|
```
|
|
bin/
|
|
RELEASE_NAME
|
|
erts-ERTS_VSN/
|
|
lib/
|
|
APP_NAME-APP_VSN/
|
|
ebin/
|
|
include/
|
|
priv/
|
|
releases/
|
|
RELEASE_VSN/
|
|
consolidated/
|
|
elixir
|
|
elixir.bat
|
|
env.bat
|
|
env.sh
|
|
iex
|
|
iex.bat
|
|
remote.vm.args
|
|
runtime.exs
|
|
start.boot
|
|
start.script
|
|
start_clean.boot
|
|
start_clean.script
|
|
sys.config
|
|
vm.args
|
|
COOKIE
|
|
start_erl.data
|
|
tmp/
|
|
```
|
|
|
|
We document this structure for completeness. In practice, developers should not modify any of those files after the release is assembled. Instead use env scripts, custom config provider, overlays, and all other mechanisms described here to configure how your release works.
|
|
|
|
## [](#module-environment-variables)Environment variables
|
|
|
|
The system sets different environment variables. The following variables are set early on and can only be read by `env.sh` and `env.bat`:
|
|
|
|
- `RELEASE_ROOT` - points to the root of the release. If the system includes ERTS, then it is the same as [`:code.root_dir/0`](https://www.erlang.org/doc/apps/kernel/code.html#root_dir/0). This variable is always computed and it cannot be set to a custom value
|
|
|
|
- `RELEASE_COMMAND` - the command given to the release, such as `"start"`, `"remote"`, `"eval"`, and so on. This is typically accessed inside `env.sh` and `env.bat` to set different environment variables under different conditions. Note, however, that `RELEASE_COMMAND` has not been validated by the time `env.sh` and `env.bat` are called, so it may be empty or contain invalid values. This variable is always computed and it cannot be set to a custom value
|
|
|
|
- `RELEASE_NAME` - the name of the release. It can be set to a custom value when invoking the release
|
|
|
|
- `RELEASE_VSN` - the version of the release, otherwise the latest version is used. It can be set to a custom value when invoking the release. The custom value must be an existing release version in the `releases/` directory
|
|
|
|
- `RELEASE_PROG` - the command line executable used to start the release
|
|
|
|
|
|
The following variables can be set before you invoke the release or inside `env.sh` and `env.bat`:
|
|
|
|
- `RELEASE_COOKIE` - the release cookie. By default uses the value in `releases/COOKIE`. It can be set to a custom value
|
|
|
|
- `RELEASE_NODE` - the release node name, in the format `name` or optionally `name@host` if running in distributed mode. It can be set to a custom value. The name part must be made only of letters, digits, underscores, and hyphens
|
|
|
|
- `RELEASE_SYS_CONFIG` - the location of the sys.config file. It can be set to a custom path and it must not include the `.config` extension
|
|
|
|
- `RELEASE_VM_ARGS` - the location of the vm.args file. It can be set to a custom path
|
|
|
|
- `RELEASE_REMOTE_VM_ARGS` - the location of the remote.vm.args file. It can be set to a custom path
|
|
|
|
- `RELEASE_TMP` - the directory in the release to write temporary files to. It can be set to a custom directory. It defaults to `$RELEASE_ROOT/tmp`
|
|
|
|
- `RELEASE_MODE` - if the release should load code on demand (interactive) or preload it (embedded). Defaults to "embedded", which increases boot time but it means the runtime will respond faster as it doesn't have to load code. Choose interactive if you need to decrease boot time and reduce memory usage on boot. It applies only to start/daemon/install commands
|
|
|
|
- `RELEASE_DISTRIBUTION` - how do we want to run the distribution. May be `name` (long names), `sname` (short names) or `none` (distribution is not started automatically). Defaults to `sname`. When connecting nodes across hosts, you typically want to set this to `name` (required to use IPs as host names)
|
|
|
|
- `RELEASE_BOOT_SCRIPT` - the name of the boot script to use when starting the release. This script is used when running commands such as `start` and `daemon`. The boot script is expected to be located at the path `releases/RELEASE_VSN/RELEASE_BOOT_SCRIPT.boot`. Defaults to `start`
|
|
|
|
- `RELEASE_BOOT_SCRIPT_CLEAN` - the name of the boot script used when starting the release clean, without your application or its dependencies. This script is used by commands such as `eval`, `rpc`, and `remote`. The boot script is expected to be located at the path `releases/RELEASE_VSN/RELEASE_BOOT_SCRIPT_CLEAN.boot`. Defaults to `start_clean`
|
|
|
|
|
|
## [](#module-umbrellas)Umbrellas
|
|
|
|
Releases are well integrated with umbrella projects, allowing you to release one or more subsets of your umbrella children. The only difference between performing a release in the umbrella project compared to a regular application is that umbrellas require you to explicitly list your release and the starting point for each release. For example, imagine this umbrella applications:
|
|
|
|
```
|
|
my_app_umbrella/
|
|
apps/
|
|
my_app_core/
|
|
my_app_event_processing/
|
|
my_app_web/
|
|
```
|
|
|
|
where both `my_app_event_processing` and `my_app_web` depend on `my_app_core` but they do not depend on each other.
|
|
|
|
Inside your umbrella, you can define multiple releases:
|
|
|
|
```
|
|
releases: [
|
|
web_and_event_processing: [
|
|
applications: [
|
|
my_app_event_processing: :permanent,
|
|
my_app_web: :permanent
|
|
]
|
|
],
|
|
|
|
web_only: [
|
|
applications: [my_app_web: :permanent]
|
|
],
|
|
|
|
event_processing_only: [
|
|
applications: [my_app_event_processing: :permanent]
|
|
]
|
|
]
|
|
```
|
|
|
|
Note you don't need to define all applications in `:applications`, only the entry points. Also remember that the recommended mode for all applications in the system is `:permanent`.
|
|
|
|
Finally, keep in mind it is not required for you to assemble the release from the umbrella root. You can also assemble the release from each child application individually. Doing it from the root, however, allows you to include two applications that do not depend on each other as part of the same release.
|
|
|
|
## [](#module-hot-code-upgrades)Hot Code Upgrades
|
|
|
|
Erlang and Elixir are sometimes known for the capability of upgrading a node that is running in production without shutting down that node. However, this feature is not supported out of the box by Elixir releases.
|
|
|
|
The reason we don't provide hot code upgrades is because they are very complicated to perform in practice, as they require careful coding of your processes and applications as well as extensive testing. Given most teams can use other techniques that are language agnostic to upgrade their systems, such as Blue/Green deployments, Canary deployments, Rolling deployments, and others, hot upgrades are rarely a viable option. Let's understand why.
|
|
|
|
In a hot code upgrade, you want to update a node from version A to version B. To do so, the first step is to write recipes for every application that changed between those two releases, telling exactly how the application changed between versions, those recipes are called `.appup` files. While some of the steps in building `.appup` files can be automated, not all of them can. Furthermore, each process in the application needs to be explicitly coded with hot code upgrades in mind. Let's see an example. Imagine your application has a counter process as a GenServer:
|
|
|
|
```
|
|
defmodule Counter do
|
|
use GenServer
|
|
|
|
def start_link(_) do
|
|
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
|
|
end
|
|
|
|
def bump do
|
|
GenServer.call(__MODULE__, :bump)
|
|
end
|
|
|
|
## Callbacks
|
|
|
|
def init(:ok) do
|
|
{:ok, 0}
|
|
end
|
|
|
|
def handle_call(:bump, counter) do
|
|
{:reply, :ok, counter + 1}
|
|
end
|
|
end
|
|
```
|
|
|
|
You add this process as part of your supervision tree and ship version 0.1.0 of your system. Now let's imagine that on version 0.2.0 you added two changes: instead of `bump/0`, that always increments the counter by one, you introduce `bump/1` that passes the exact value to bump the counter. You also change the state, because you want to store the maximum bump value:
|
|
|
|
```
|
|
defmodule Counter do
|
|
use GenServer
|
|
|
|
def start_link(_) do
|
|
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
|
|
end
|
|
|
|
def bump(by) do
|
|
GenServer.call(__MODULE__, {:bump, by})
|
|
end
|
|
|
|
## Callbacks
|
|
|
|
def init(:ok) do
|
|
{:ok, {0, 0}}
|
|
end
|
|
|
|
def handle_call({:bump, by}, {counter, max}) do
|
|
{:reply, :ok, {counter + by, max(max, by)}}
|
|
end
|
|
end
|
|
```
|
|
|
|
If you were to perform a hot code upgrade in such an application, it would crash, because in the initial version the state was just a counter but in the new version the state is a tuple. Furthermore, you changed the format of the `call` message from `:bump` to `{:bump, by}` and the process may have both old and new messages temporarily mixed, so we need to handle both. The final version would be:
|
|
|
|
```
|
|
defmodule Counter do
|
|
use GenServer
|
|
|
|
def start_link(_) do
|
|
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
|
|
end
|
|
|
|
def bump(by) do
|
|
GenServer.call(__MODULE__, {:bump, by})
|
|
end
|
|
|
|
## Callbacks
|
|
|
|
def init(:ok) do
|
|
{:ok, {0, 0}}
|
|
end
|
|
|
|
def handle_call(:bump, {counter, max}) do
|
|
{:reply, :ok, {counter + 1, max(max, 1)}}
|
|
end
|
|
|
|
def handle_call({:bump, by}, {counter, max}) do
|
|
{:reply, :ok, {counter + by, max(max, by)}}
|
|
end
|
|
|
|
def code_change(_, counter, _) do
|
|
{:ok, {counter, 0}}
|
|
end
|
|
end
|
|
```
|
|
|
|
Now you can proceed to list this process in the `.appup` file and hot code upgrade it. This is one of the many steps necessary to perform hot code upgrades and it must be taken into account by every process and application being upgraded in the system. The [`.appup` cookbook](https://www.erlang.org/doc/design_principles/appup_cookbook.html) provides a good reference and more examples.
|
|
|
|
Once `.appup`s are created, the next step is to create a `.relup` file with all instructions necessary to update the release itself. Erlang documentation does provide a chapter on [Creating and upgrading a target system](https://www.erlang.org/doc/system_principles/create_target.html). [Learn You Some Erlang has a chapter on hot code upgrades](https://learnyousomeerlang.com/relups).
|
|
|
|
Overall, there are many steps, complexities and assumptions made during hot code upgrades, which is ultimately why they are not provided by Elixir out of the box. However, hot code upgrades can still be achieved by teams who desire to implement those steps on top of [`mix release`](Mix.Tasks.Release.html) in their projects or as separate libraries.
|
|
|
|
## [](#module-command-line-options)Command line options
|
|
|
|
- `--force` - forces recompilation
|
|
- `--no-archives-check` - does not check archive
|
|
- `--no-deps-check` - does not check dependencies
|
|
- `--no-elixir-version-check` - does not check Elixir version
|
|
- `--no-compile` - does not compile before assembling the release
|
|
- `--overwrite` - overwrite existing files instead of prompting the user for action
|
|
- `--path` - the path of the release
|
|
- `--quiet` - does not write progress to the standard output
|
|
- `--version` - the version of the release
|
|
|
|
Go to package docs [Download ePub version](Mix.epub "ePub version")
|
|
|
|
Built using [ExDoc](https://github.com/elixir-lang/ex_doc "ExDoc") (v0.39.3) for the [Elixir programming language](https://elixir-lang.org "Elixir")
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: task
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 16:50:38
|
|
|
|
```
|
|
task_id: ses_37f1bd674ffeO53rrE00gDEhCE (for resuming to continue this task if needed)
|
|
|
|
<task_result>
|
|
I now have enough information to provide a comprehensive summary. Let me compile the findings:
|
|
|
|
---
|
|
|
|
# Elixir CLI + Service Patterns: Research Summary
|
|
|
|
## Overview
|
|
|
|
After researching Livebook, Phoenix, Mix, ExUnit, Oban, Burrito, and Bakeware, here are the common patterns used in the Elixir ecosystem for applications that need to function as both CLI tools and long-running services.
|
|
|
|
---
|
|
|
|
## 1. CLI Entry Point Patterns
|
|
|
|
### Pattern A: Escript with Main Module (Livebook)
|
|
|
|
Livebook uses **escript** with a custom `main/1` function that dispatches to subcommands:
|
|
|
|
```elixir
|
|
# lib/livebook_cli.ex
|
|
defmodule LivebookCLI do
|
|
def main(args) do
|
|
{:ok, _} = Application.ensure_all_started(:elixir)
|
|
:ok = Application.load(:livebook)
|
|
|
|
case args do
|
|
["server" | args] -> LivebookCLI.Server.call(args)
|
|
["deploy" | args] -> LivebookCLI.Deploy.call(args)
|
|
[arg] when arg in ["--help", "-h"] -> display_help()
|
|
[arg] when arg in ["--version", "-v"] -> display_version()
|
|
_ -> display_usage()
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**Key features:**
|
|
- Task behaviour pattern with `call/1` and `usage/0` callbacks
|
|
- Extracts `priv` directory from escript archive at runtime
|
|
- Starts the full OTP application with `Process.sleep(:infinity)` for server mode
|
|
|
|
```elixir
|
|
# mix.exs escript configuration
|
|
defp escript do
|
|
[
|
|
main_module: LivebookCLI,
|
|
app: nil,
|
|
emu_args: "-epmd_module Elixir.Livebook.EPMD"
|
|
]
|
|
end
|
|
```
|
|
|
|
### Pattern B: Mix Tasks (Phoenix)
|
|
|
|
Phoenix uses **Mix tasks** for CLI operations that run within a project:
|
|
|
|
```elixir
|
|
# lib/mix/tasks/phx.server.ex
|
|
defmodule Mix.Tasks.Phx.Server do
|
|
use Mix.Task
|
|
|
|
@impl true
|
|
def run(args) do
|
|
Application.put_env(:phoenix, :serve_endpoints, true, persistent: true)
|
|
Mix.Tasks.Run.run(run_args() ++ args)
|
|
end
|
|
|
|
defp run_args do
|
|
if iex_running?(), do: [], else: ["--no-halt"]
|
|
end
|
|
end
|
|
```
|
|
|
|
**Key features:**
|
|
- Uses `use Mix.Task` for automatic task registration
|
|
- Leverages `@shortdoc` and `@moduledoc` for help
|
|
- `@requirements` attribute for dependencies (e.g., `["app.config"]`)
|
|
- Wraps `Mix.Tasks.Run` with appropriate flags
|
|
|
|
### Pattern C: Mix CLI Module (Mix itself)
|
|
|
|
Mix has a dedicated CLI module that parses arguments and dispatches:
|
|
|
|
```elixir
|
|
# lib/mix/lib/mix/cli.ex
|
|
defmodule Mix.CLI do
|
|
def main(args \\ System.argv()) do
|
|
Mix.start()
|
|
|
|
case check_for_shortcuts(args) do
|
|
:help -> display_help()
|
|
:version -> display_version()
|
|
nil -> proceed(args)
|
|
end
|
|
end
|
|
|
|
defp proceed(args) do
|
|
load_mix_exs(args)
|
|
{task, args} = get_task(args)
|
|
run_task(task, args)
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Distribution Methods
|
|
|
|
### Method 1: Escript (Livebook primary method)
|
|
|
|
**Pros:**
|
|
- Single file distribution
|
|
- No external dependencies beyond Erlang/Elixir installation
|
|
- Fast startup for CLI tools
|
|
|
|
**Cons:**
|
|
- Requires Erlang/Elixir installed on target machine
|
|
- Cannot include native dependencies easily
|
|
|
|
```elixir
|
|
# mix.exs
|
|
def project do
|
|
[
|
|
escript: [
|
|
main_module: MyApp.CLI,
|
|
app: nil, # Don't start app automatically
|
|
include_priv_for: [:my_app]
|
|
]
|
|
]
|
|
end
|
|
```
|
|
|
|
### Method 2: Mix Releases (Phoenix, Livebook production)
|
|
|
|
**Pros:**
|
|
- Bundles ERTS (Erlang runtime)
|
|
- Supports hot upgrades
|
|
- Multiple boot scripts
|
|
- Configuration providers
|
|
|
|
**Cons:**
|
|
- Larger file size
|
|
- Platform-specific builds
|
|
|
|
```elixir
|
|
# mix.exs
|
|
defp releases do
|
|
[
|
|
my_app: [
|
|
applications: [my_app: :permanent],
|
|
include_executables_for: [:unix, :windows],
|
|
steps: [:assemble, &custom_step/1]
|
|
]
|
|
]
|
|
end
|
|
```
|
|
|
|
**Release scripts support multiple commands:**
|
|
- `bin/my_app start` - Start as daemon
|
|
- `bin/my_app start_iex` - Start with IEx
|
|
- `bin/my_app eval "expr"` - Evaluate expression
|
|
- `bin/my_app rpc "expr"` - Remote call to running node
|
|
- `bin/my_app remote` - Connect to running node
|
|
|
|
### Method 3: Burrito (Standalone binaries)
|
|
|
|
For completely standalone distribution without any runtime requirements:
|
|
|
|
```elixir
|
|
# mix.exs
|
|
def releases do
|
|
[
|
|
my_cli: [
|
|
steps: [:assemble, &Burrito.wrap/1],
|
|
burrito: [
|
|
targets: [
|
|
macos: [os: :darwin, cpu: :x86_64],
|
|
linux: [os: :linux, cpu: :x86_64],
|
|
windows: [os: :windows, cpu: :x86_64]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
end
|
|
```
|
|
|
|
**Key features:**
|
|
- Cross-compilation from Linux/macOS
|
|
- Self-extracting archives with Zig wrapper
|
|
- Embeds ERTS for each platform
|
|
- Access args via `Burrito.Util.Args.argv()`
|
|
- Maintenance commands: `--bw-info`, `--bw-install`
|
|
|
|
### Method 4: Bakeware (Deprecated, use Burrito)
|
|
|
|
Similar to Burrito but older. Uses `Bakeware.Script` behaviour:
|
|
|
|
```elixir
|
|
defmodule MyApp.Main do
|
|
use Bakeware.Script
|
|
|
|
@impl Bakeware.Script
|
|
def main(args) do
|
|
IO.puts("Hello!")
|
|
0 # Exit code
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Service vs CLI Distinction Patterns
|
|
|
|
### Pattern 1: Command Dispatch (Livebook)
|
|
|
|
```elixir
|
|
defmodule LivebookCLI.Server do
|
|
@behaviour LivebookCLI.Task
|
|
|
|
def call(args) do
|
|
config_entries = parse_and_configure(args)
|
|
|
|
case Application.ensure_all_started(:livebook) do
|
|
{:ok, _} ->
|
|
open_browser_if_requested(args)
|
|
Process.sleep(:infinity) # Keep running as service
|
|
{:error, error} ->
|
|
raise "Failed to start"
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### Pattern 2: Environment Configuration (Phoenix)
|
|
|
|
```elixir
|
|
# config/runtime.exs
|
|
if System.get_env("PHX_SERVER") do
|
|
config :my_app, MyAppWeb.Endpoint, server: true
|
|
end
|
|
```
|
|
|
|
Combined with:
|
|
```elixir
|
|
# Application.start
|
|
def start(_type, _args) do
|
|
children = [
|
|
MyAppWeb.Endpoint # Only starts server if configured
|
|
]
|
|
Supervisor.start_link(children, strategy: :one_for_one)
|
|
end
|
|
```
|
|
|
|
### Pattern 3: Application Module Callback
|
|
|
|
```elixir
|
|
def application do
|
|
[
|
|
mod: {MyApp.Application, []},
|
|
extra_applications: [:logger]
|
|
]
|
|
end
|
|
|
|
# For CLI tools that exit
|
|
defmodule MyApp.Application do
|
|
def start(_type, _args) do
|
|
result = MyApp.CLI.run(System.argv())
|
|
System.halt(result) # Exit with code
|
|
end
|
|
end
|
|
|
|
# For services that keep running
|
|
defmodule MyApp.Application do
|
|
def start(_type, _args) do
|
|
children = [MyApp.Server]
|
|
Supervisor.start_link(children, strategy: :one_for_one)
|
|
end
|
|
end
|
|
```
|
|
|
|
### Pattern 4: Conditional Supervision Trees (Oban)
|
|
|
|
Oban runs as part of your application's supervision tree:
|
|
|
|
```elixir
|
|
# lib/my_app/application.ex
|
|
def start(_type, _args) do
|
|
children = [
|
|
MyApp.Repo,
|
|
{Oban, Application.fetch_env!(:my_app, Oban)}
|
|
]
|
|
Supervisor.start_link(children, strategy: :one_for_one)
|
|
end
|
|
```
|
|
|
|
CLI operations use Mix tasks that connect to the running application.
|
|
|
|
---
|
|
|
|
## 4. Recommended Architecture for CLI + Service
|
|
|
|
Based on the research, here's the recommended pattern:
|
|
|
|
```
|
|
lib/
|
|
├── my_app/
|
|
│ ├── application.ex # OTP Application
|
|
│ └── ... # Business logic
|
|
├── my_app_cli.ex # CLI entry point (for escript)
|
|
├── my_app_cli/
|
|
│ ├── task.ex # Task behaviour
|
|
│ ├── server.ex # "server" subcommand
|
|
│ ├── eval.ex # "eval" subcommand
|
|
│ └── utils.ex # CLI utilities
|
|
└── mix/
|
|
└── tasks/
|
|
└── my_app/
|
|
└── server.ex # Mix task alternative
|
|
```
|
|
|
|
### CLI Entry Point
|
|
|
|
```elixir
|
|
defmodule MyAppCLI do
|
|
def main(args) do
|
|
{:ok, _} = Application.ensure_all_started(:elixir)
|
|
:ok = Application.load(:my_app)
|
|
|
|
case args do
|
|
["server" | rest] -> MyAppCLI.Server.call(rest)
|
|
["eval", expr | _] -> MyAppCLI.Eval.call(expr)
|
|
["version" | _] -> IO.puts(MyApp.version())
|
|
["--help" | _] -> IO.puts(usage())
|
|
["-h" | _] -> IO.puts(usage())
|
|
_ -> IO.puts(usage())
|
|
end
|
|
end
|
|
|
|
def usage do
|
|
"""
|
|
Usage: my_app <command> [options]
|
|
|
|
Commands:
|
|
server Start the application server
|
|
eval Evaluate an expression
|
|
version Print version
|
|
|
|
Use "my_app <command> --help" for command-specific help.
|
|
"""
|
|
end
|
|
end
|
|
```
|
|
|
|
### Task Behaviour
|
|
|
|
```elixir
|
|
defmodule MyAppCLI.Task do
|
|
@callback usage() :: String.t()
|
|
@callback call(args :: [String.t()]) :: :ok | {:error, term()}
|
|
end
|
|
```
|
|
|
|
### Server Command
|
|
|
|
```elixir
|
|
defmodule MyAppCLI.Server do
|
|
@behaviour MyAppCLI.Task
|
|
|
|
def usage, do: "Usage: my_app server [--port PORT] [--host HOST]"
|
|
|
|
def call(args) do
|
|
{opts, _, _} = OptionParser.parse(args,
|
|
strict: [port: :integer, host: :string, help: :boolean]
|
|
)
|
|
|
|
if opts[:help] do
|
|
IO.puts(usage())
|
|
else
|
|
configure(opts)
|
|
{:ok, _} = Application.ensure_all_started(:my_app)
|
|
Process.sleep(:infinity)
|
|
end
|
|
end
|
|
|
|
defp configure(opts) do
|
|
if port = opts[:port] do
|
|
Application.put_env(:my_app, :port, port, persistent: true)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Summary of Patterns
|
|
|
|
| Project | Primary Method | CLI Pattern | Service Pattern |
|
|
|---------|---------------|-------------|-----------------|
|
|
| **Livebook** | Escript + Release | Custom main/1 with subcommands | `Process.sleep(:infinity)` after app start |
|
|
| **Phoenix** | Mix Release | Mix Tasks (`use Mix.Task`) | `server: true` endpoint config |
|
|
| **Mix** | Bundled with Elixir | CLI module with task dispatch | N/A (build tool) |
|
|
| **ExUnit** | Library | `ExUnit.start()` with `System.at_exit` | N/A (test runner) |
|
|
| **Oban** | Library | Mix Tasks + Web UI | Supervision tree child |
|
|
| **Burrito** | Standalone binary | `Burrito.Util.Args.argv()` | Full OTP application |
|
|
|
|
### Key Takeaways
|
|
|
|
1. **Use Mix Tasks** for operations that run within a Mix project context
|
|
2. **Use Escript** for standalone CLI tools when Erlang is available
|
|
3. **Use Mix Releases** for production deployments of services
|
|
4. **Use Burrito** for zero-dependency standalone binaries
|
|
5. **Separate concerns**: CLI parsing in one module, business logic in another
|
|
6. **Use `Process.sleep(:infinity)`** to keep a CLI-started service running
|
|
7. **Use Application config** to toggle server/CLI behavior
|
|
8. **Define a Task behaviour** for consistent subcommand interface
|
|
</task_result>
|
|
```
|
|
## Agent (2026-02-21 16:50:40)
|
|
|
|
Based on my research, here's how Elixir projects typically handle the CLI + service pattern:
|
|
|
|
## Common Patterns in the Ecosystem
|
|
|
|
### 1. **Livebook Pattern** (Most relevant to org-garden)
|
|
|
|
Livebook uses an **escript for CLI** but starts the full OTP application for service mode:
|
|
|
|
```elixir
|
|
defmodule LivebookCLI do
|
|
def main(args) do
|
|
:ok = Application.load(:livebook)
|
|
|
|
case args do
|
|
["server" | rest] -> LivebookCLI.Server.call(rest) # Long-running
|
|
["deploy" | rest] -> LivebookCLI.Deploy.call(rest) # One-shot
|
|
_ -> display_usage()
|
|
end
|
|
end
|
|
end
|
|
|
|
defmodule LivebookCLI.Server do
|
|
def call(args) do
|
|
configure(args)
|
|
{:ok, _} = Application.ensure_all_started(:livebook)
|
|
Process.sleep(:infinity) # Keep running
|
|
end
|
|
end
|
|
```
|
|
|
|
### 2. **Mix Release with `eval`** (Production deployments)
|
|
|
|
A Mix Release provides both modes out of the box:
|
|
|
|
```bash
|
|
# One-shot commands (CLI)
|
|
bin/my_app eval "MyApp.CLI.build('/path')"
|
|
|
|
# Long-running service
|
|
bin/my_app start
|
|
bin/my_app daemon
|
|
```
|
|
|
|
### 3. **Custom Release Script** (Best of both worlds)
|
|
|
|
Projects often add a wrapper script in `rel/overlays/bin/`:
|
|
|
|
```bash
|
|
#!/bin/sh
|
|
# rel/overlays/bin/my-app-cli
|
|
case "$1" in
|
|
serve|daemon)
|
|
exec "$RELEASE_ROOT/bin/my_app" "$@"
|
|
;;
|
|
build|export)
|
|
exec "$RELEASE_ROOT/bin/my_app" eval "MyApp.CLI.main(System.argv())"
|
|
;;
|
|
*)
|
|
exec "$RELEASE_ROOT/bin/my_app" "$@"
|
|
;;
|
|
esac
|
|
```
|
|
|
|
---
|
|
|
|
## Recommended Approach for org-garden
|
|
|
|
Based on the research, I recommend **Mix Release** with a **custom CLI module**:
|
|
|
|
```
|
|
┌─────────────────┐
|
|
│ org-garden │
|
|
│ (shell script) │
|
|
└────────┬────────┘
|
|
│
|
|
┌──────────────┼──────────────┐
|
|
▼ ▼ ▼
|
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|
│ serve │ │ build │ │ export │
|
|
│ (start) │ │ (eval) │ │ (eval) │
|
|
└────┬────┘ └────┬────┘ └────┬────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
Full OTP App One-shot One-shot
|
|
with Supervisor exits when exits when
|
|
(blocks) done done
|
|
```
|
|
|
|
### Implementation Structure
|
|
|
|
```
|
|
lib/
|
|
├── org_garden/
|
|
│ ├── application.ex # OTP Application (service mode)
|
|
│ ├── server.ex # New: encapsulates serve mode startup
|
|
│ └── ...
|
|
├── org_garden_cli.ex # CLI dispatcher (kept for escript compat)
|
|
└── org_garden_cli/
|
|
├── commands.ex # Command implementations
|
|
└── parser.ex # Argument parsing
|
|
```
|
|
|
|
### Key Files
|
|
|
|
**1. `lib/org_garden/application.ex`** — Conditional startup:
|
|
```elixir
|
|
def start(_type, _args) do
|
|
children = [
|
|
{Finch, name: OrgGarden.Finch},
|
|
{Registry, keys: :unique, name: OrgGarden.Registry},
|
|
{DynamicSupervisor, name: OrgGarden.DynamicSupervisor}
|
|
]
|
|
|
|
# Service components started dynamically via OrgGarden.Server.start/1
|
|
Supervisor.start_link(children, strategy: :one_for_one)
|
|
end
|
|
```
|
|
|
|
**2. `lib/org_garden/server.ex`** — Encapsulates service mode:
|
|
```elixir
|
|
defmodule OrgGarden.Server do
|
|
def start(opts) do
|
|
# Initial export
|
|
run_initial_pipeline(opts)
|
|
|
|
# Start supervised watcher + quartz
|
|
DynamicSupervisor.start_child(
|
|
OrgGarden.DynamicSupervisor,
|
|
{OrgGarden.Supervisor, opts}
|
|
)
|
|
end
|
|
|
|
def stop do
|
|
# Graceful shutdown
|
|
end
|
|
end
|
|
```
|
|
|
|
**3. `rel/overlays/bin/org-garden`** — Unified entry point:
|
|
```bash
|
|
#!/bin/sh
|
|
SELF=$(readlink -f "$0")
|
|
RELEASE_ROOT=$(dirname "$(dirname "$SELF")")
|
|
|
|
case "$1" in
|
|
serve)
|
|
shift
|
|
exec "$RELEASE_ROOT/bin/org_garden" start -- serve "$@"
|
|
;;
|
|
build|export)
|
|
exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main(System.argv())" -- "$@"
|
|
;;
|
|
daemon|stop|remote|pid)
|
|
exec "$RELEASE_ROOT/bin/org_garden" "$@"
|
|
;;
|
|
*)
|
|
echo "Usage: org-garden <serve|build|export|daemon|stop|remote> [options]"
|
|
;;
|
|
esac
|
|
```
|
|
|
|
---
|
|
|
|
## Trade-offs Summary
|
|
|
|
| Approach | Distribution | Signal Handling | Runtime Config | Complexity |
|
|
|----------|-------------|-----------------|----------------|------------|
|
|
| **Escript only** | Single file | Poor | Limited | Low |
|
|
| **Release only** | Directory/tarball | Good | Full | Medium |
|
|
| **Both** | Two artifacts | Mixed | Full | Higher |
|
|
| **Release + overlay script** | Single package | Good | Full | Medium |
|
|
|
|
---
|
|
|
|
## Questions for You
|
|
|
|
1. **Do you want to keep escript compatibility** for simple `mix escript.build` distribution, or is Nix packaging sufficient?
|
|
|
|
2. **For the NixOS service**, should `serve` mode be the only supported mode (with `build`/`export` as one-shot systemd `ExecStartPre` or separate units)?
|
|
|
|
3. **Should we use Burrito** for fully standalone binaries (no Nix/Erlang needed on target), or is
|
|
|
|
Nix the primary distribution method?## User (2026-02-21 20:05:16)
|
|
|
|
The release only + overlay script is fine. If not complex, let's keep esript.build. Nix the primary distribution method. The NixOS service should only support serve, as it is a long-running service. If one-shot needed, the package is for that.
|
|
|
|
## Agent (2026-02-21 20:05:19)
|
|
|
|
Understood. Let me finalize the implementation plan based on your decisions:
|
|
|
|
- **Release + overlay script** as primary approach
|
|
- **Keep escript** for simple builds (if not complex)
|
|
- **Nix** as primary distribution
|
|
- **NixOS service** only supports `serve` mode
|
|
|
|
---
|
|
|
|
# Final Implementation Plan: org-garden Service Improvements
|
|
|
|
## Summary of Changes
|
|
|
|
| Phase | Description | Files Changed/Added |
|
|
|-------|-------------|---------------------|
|
|
| 1 | Supervision Tree Integration | `application.ex`, `server.ex` (new), `cli.ex` |
|
|
| 2 | Configuration System | `config/config.exs`, `config/runtime.exs`, `config.ex` (new) |
|
|
| 3 | Graceful Shutdown | `quartz.ex`, `watcher.ex`, `server.ex` |
|
|
| 4 | Health Checks | `health.ex` (new), `mix.exs` |
|
|
| 5 | Observability | `telemetry.ex` (new), updates to `watcher.ex`, `export.ex` |
|
|
| 6 | Mix Release | `mix.exs`, `rel/` directory |
|
|
| 7 | NixOS Module | `nix/module.nix`, `flake.nix` |
|
|
|
|
---
|
|
|
|
## Phase 1: Supervision Tree Integration
|
|
|
|
### 1.1 Refactor `application.ex`
|
|
|
|
**Current state:**
|
|
```elixir
|
|
children = [
|
|
{Finch, name: OrgGarden.Finch}
|
|
]
|
|
```
|
|
|
|
**New state:**
|
|
```elixir
|
|
children = [
|
|
{Finch, name: OrgGarden.Finch},
|
|
{Registry, keys: :unique, name: OrgGarden.Registry},
|
|
{DynamicSupervisor, name: OrgGarden.DynamicSupervisor, strategy: :one_for_one}
|
|
]
|
|
```
|
|
|
|
### 1.2 Create `lib/org_garden/server.ex`
|
|
|
|
New module that encapsulates the "serve" mode lifecycle:
|
|
|
|
```elixir
|
|
defmodule OrgGarden.Server do
|
|
@moduledoc """
|
|
Manages the serve mode lifecycle.
|
|
|
|
Handles initial export, starts the supervision tree,
|
|
and provides graceful shutdown.
|
|
"""
|
|
use GenServer
|
|
|
|
def start_link(opts)
|
|
def stop(timeout \\ 5000)
|
|
|
|
# Callbacks handle:
|
|
# - Initial pipeline run
|
|
# - Starting OrgGarden.Supervisor under DynamicSupervisor
|
|
# - Trapping exits for graceful shutdown
|
|
end
|
|
```
|
|
|
|
### 1.3 Update `cli.ex`
|
|
|
|
**Changes:**
|
|
- Replace `Process.sleep(:infinity)` with `OrgGarden.Server.start_link(opts)` + `receive do` block
|
|
- `handle_serve/1` starts `OrgGarden.Server` which blocks properly
|
|
- Export `--watch` mode starts Watcher under DynamicSupervisor
|
|
|
|
---
|
|
|
|
## Phase 2: Configuration System
|
|
|
|
### 2.1 Create `config/config.exs`
|
|
|
|
```elixir
|
|
import Config
|
|
|
|
config :org_garden,
|
|
# Defaults (overridden by runtime.exs)
|
|
zotero_url: "http://localhost:23119",
|
|
citation_mode: :warn,
|
|
health_port: 9090
|
|
|
|
config :logger, :console,
|
|
format: "$time $metadata[$level] $message\n",
|
|
metadata: [:module, :file]
|
|
```
|
|
|
|
### 2.2 Create `config/runtime.exs`
|
|
|
|
```elixir
|
|
import Config
|
|
|
|
if config_env() == :prod do
|
|
config :org_garden,
|
|
quartz_path: System.fetch_env!("QUARTZ_PATH"),
|
|
node_path: System.get_env("NODE_PATH", "node"),
|
|
notes_dir: System.get_env("NOTES_DIR"),
|
|
output_dir: System.get_env("OUTPUT_DIR"),
|
|
zotero_url: System.get_env("ZOTERO_URL", "http://localhost:23119"),
|
|
bibtex_file: System.get_env("BIBTEX_FILE"),
|
|
citation_mode: parse_citation_mode(System.get_env("CITATION_MODE", "warn")),
|
|
http_port: parse_int(System.get_env("PORT"), 8080),
|
|
ws_port: parse_int(System.get_env("WS_PORT"), 3001),
|
|
health_port: parse_int(System.get_env("HEALTH_PORT"), 9090)
|
|
end
|
|
```
|
|
|
|
### 2.3 Create `lib/org_garden/config.ex`
|
|
|
|
```elixir
|
|
defmodule OrgGarden.Config do
|
|
@moduledoc "Centralized configuration access with validation."
|
|
|
|
def get(key, default \\ nil)
|
|
def get!(key) # Raises if missing
|
|
def validate!() # Called at startup, fails fast
|
|
end
|
|
```
|
|
|
|
### 2.4 Update modules to use `OrgGarden.Config`
|
|
|
|
Replace `System.get_env/1` calls in:
|
|
- `cli.ex` — use Config for defaults, CLI args override
|
|
- `quartz.ex` — `Config.get!(:quartz_path)`
|
|
- `transforms/citations.ex` — `Config.get(:zotero_url)`
|
|
|
|
---
|
|
|
|
## Phase 3: Graceful Shutdown
|
|
|
|
### 3.1 Update `server.ex`
|
|
|
|
```elixir
|
|
def init(opts) do
|
|
Process.flag(:trap_exit, true)
|
|
# ... start children
|
|
end
|
|
|
|
def terminate(reason, state) do
|
|
Logger.info("Shutting down org-garden server...")
|
|
|
|
# 1. Stop accepting new file events
|
|
# 2. Wait for in-flight processing (with timeout)
|
|
# 3. Stop Quartz gracefully
|
|
|
|
:ok
|
|
end
|
|
```
|
|
|
|
### 3.2 Update `quartz.ex`
|
|
|
|
**Current:**
|
|
```elixir
|
|
def terminate(_reason, %{port: port}) do
|
|
Port.close(port)
|
|
end
|
|
```
|
|
|
|
**New:**
|
|
```elixir
|
|
def terminate(_reason, %{port: port, os_pid: os_pid}) do
|
|
# Send SIGTERM to Node.js process
|
|
System.cmd("kill", ["-TERM", to_string(os_pid)], stderr_to_stdout: true)
|
|
|
|
# Wait up to 5 seconds for graceful exit
|
|
receive do
|
|
{^port, {:exit_status, _}} -> :ok
|
|
after
|
|
5000 ->
|
|
System.cmd("kill", ["-KILL", to_string(os_pid)], stderr_to_stdout: true)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
```
|
|
|
|
Need to capture OS PID in `init/1`:
|
|
```elixir
|
|
{:os_pid, os_pid} = Port.info(port, :os_pid)
|
|
```
|
|
|
|
### 3.3 Update `watcher.ex`
|
|
|
|
Add drain period for pending debounce timers:
|
|
|
|
```elixir
|
|
def terminate(_reason, state) do
|
|
# Flush pending timers
|
|
Enum.each(state.pending, fn {path, ref} ->
|
|
Process.cancel_timer(ref)
|
|
# Optionally process immediately or log
|
|
end)
|
|
|
|
OrgGarden.teardown_transforms(state.initialized_transforms)
|
|
:ok
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Health Checks
|
|
|
|
### 4.1 Add dependencies to `mix.exs`
|
|
|
|
```elixir
|
|
defp deps do
|
|
[
|
|
# Existing
|
|
{:finch, "~> 0.19"},
|
|
{:req, "~> 0.5"},
|
|
{:jason, "~> 1.4"},
|
|
{:file_system, "~> 1.0"},
|
|
|
|
# New: minimal HTTP server for health checks
|
|
{:bandit, "~> 1.0"},
|
|
{:plug, "~> 1.15"}
|
|
]
|
|
end
|
|
```
|
|
|
|
### 4.2 Create `lib/org_garden/health.ex`
|
|
|
|
```elixir
|
|
defmodule OrgGarden.Health do
|
|
use Plug.Router
|
|
|
|
plug :match
|
|
plug :dispatch
|
|
|
|
get "/health/live" do
|
|
send_resp(conn, 200, "ok")
|
|
end
|
|
|
|
get "/health/ready" do
|
|
checks = %{
|
|
watcher: check_watcher(),
|
|
quartz: check_quartz()
|
|
}
|
|
|
|
if Enum.all?(checks, fn {_, v} -> v end) do
|
|
send_resp(conn, 200, Jason.encode!(%{status: "ready", checks: checks}))
|
|
else
|
|
send_resp(conn, 503, Jason.encode!(%{status: "not_ready", checks: checks}))
|
|
end
|
|
end
|
|
|
|
defp check_watcher, do: Process.whereis(OrgGarden.Watcher) != nil
|
|
defp check_quartz, do: Process.whereis(OrgGarden.Quartz) != nil
|
|
end
|
|
```
|
|
|
|
### 4.3 Add to supervision tree
|
|
|
|
In `application.ex`, start Bandit when configured:
|
|
|
|
```elixir
|
|
children = [
|
|
# ... existing
|
|
{Bandit, plug: OrgGarden.Health, port: Config.get(:health_port, 9090)}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Observability
|
|
|
|
### 5.1 Create `lib/org_garden/telemetry.ex`
|
|
|
|
```elixir
|
|
defmodule OrgGarden.Telemetry do
|
|
@moduledoc "Telemetry event definitions and helpers."
|
|
|
|
def export_start(metadata), do:
|
|
:telemetry.execute([:org_garden, :export, :start], %{}, metadata)
|
|
|
|
def export_stop(metadata, measurements), do:
|
|
:telemetry.execute([:org_garden, :export, :stop], measurements, metadata)
|
|
|
|
# Events:
|
|
# [:org_garden, :export, :start | :stop | :exception]
|
|
# [:org_garden, :transform, :start | :stop]
|
|
# [:org_garden, :watcher, :file_changed]
|
|
# [:org_garden, :quartz, :started | :stopped]
|
|
end
|
|
```
|
|
|
|
### 5.2 Add telemetry calls to modules
|
|
|
|
**In `export.ex`:**
|
|
```elixir
|
|
def export_file(orgfile, notes_dir, output_dir) do
|
|
start_time = System.monotonic_time()
|
|
Telemetry.export_start(%{file: orgfile})
|
|
|
|
result = do_export(orgfile, notes_dir, output_dir)
|
|
|
|
duration = System.monotonic_time() - start_time
|
|
Telemetry.export_stop(%{file: orgfile}, %{duration: duration})
|
|
|
|
result
|
|
end
|
|
```
|
|
|
|
### 5.3 Replace `IO.puts` with `Logger`
|
|
|
|
In `cli.ex`, `watcher.ex`:
|
|
- `IO.puts("==> ...")` → `Logger.info("...")`
|
|
- Keep `IO.puts` only for direct user output in CLI mode (non-service)
|
|
|
|
---
|
|
|
|
## Phase 6: Mix Release
|
|
|
|
### 6.1 Update `mix.exs`
|
|
|
|
```elixir
|
|
def project do
|
|
[
|
|
app: :org_garden,
|
|
version: "0.1.0",
|
|
elixir: "~> 1.17",
|
|
start_permanent: Mix.env() == :prod,
|
|
deps: deps(),
|
|
escript: escript(), # Keep for compatibility
|
|
releases: releases()
|
|
]
|
|
end
|
|
|
|
defp releases do
|
|
[
|
|
org_garden: [
|
|
include_executables_for: [:unix],
|
|
applications: [runtime_tools: :permanent],
|
|
steps: [:assemble, :tar]
|
|
]
|
|
]
|
|
end
|
|
```
|
|
|
|
### 6.2 Create `rel/env.sh.eex`
|
|
|
|
```bash
|
|
#!/bin/sh
|
|
export RELEASE_DISTRIBUTION=none
|
|
export RELEASE_NODE=org_garden
|
|
|
|
# Forward signals for graceful shutdown
|
|
export ERL_AFLAGS="-kernel shell_history enabled"
|
|
```
|
|
|
|
### 6.3 Create `rel/overlays/bin/org-garden`
|
|
|
|
```bash
|
|
#!/bin/sh
|
|
set -e
|
|
|
|
SELF=$(readlink -f "$0" 2>/dev/null || realpath "$0")
|
|
RELEASE_ROOT=$(dirname "$(dirname "$SELF")")
|
|
|
|
usage() {
|
|
echo "Usage: org-garden <command> [options]"
|
|
echo ""
|
|
echo "Commands:"
|
|
echo " serve <notes-dir> Start development server (long-running)"
|
|
echo " build <notes-dir> Build static site (one-shot)"
|
|
echo " export <notes-dir> Export org to markdown (one-shot)"
|
|
echo " daemon Start as background daemon"
|
|
echo " stop Stop running daemon"
|
|
echo " remote Connect to running instance"
|
|
echo ""
|
|
}
|
|
|
|
case "${1:-}" in
|
|
serve)
|
|
shift
|
|
exec "$RELEASE_ROOT/bin/org_garden" start -- "$@"
|
|
;;
|
|
build|export)
|
|
CMD="$1"
|
|
shift
|
|
exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main([\"$CMD\" | System.argv()])" -- "$@"
|
|
;;
|
|
daemon|stop|remote|pid|restart)
|
|
exec "$RELEASE_ROOT/bin/org_garden" "$@"
|
|
;;
|
|
-h|--help|help|"")
|
|
usage
|
|
;;
|
|
*)
|
|
echo "Unknown command: $1"
|
|
usage
|
|
exit 1
|
|
;;
|
|
esac
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 7: NixOS Module
|
|
|
|
### 7.1 Create `nix/module.nix`
|
|
|
|
```nix
|
|
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.services.org-garden;
|
|
pkg = config.services.org-garden.package;
|
|
in {
|
|
options.services.org-garden = {
|
|
enable = lib.mkEnableOption "org-garden publishing service";
|
|
|
|
package = lib.mkOption {
|
|
type = lib.types.package;
|
|
description = "The org-garden package to use";
|
|
};
|
|
|
|
notesDir = lib.mkOption {
|
|
type = lib.types.path;
|
|
description = "Path to org-roam notes directory";
|
|
};
|
|
|
|
outputDir = lib.mkOption {
|
|
type = lib.types.path;
|
|
default = "/var/lib/org-garden";
|
|
description = "Output directory for generated content";
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 8080;
|
|
description = "HTTP server port";
|
|
};
|
|
|
|
wsPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 3001;
|
|
description = "WebSocket hot reload port";
|
|
};
|
|
|
|
healthPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 9090;
|
|
description = "Health check endpoint port";
|
|
};
|
|
|
|
zoteroUrl = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "http://localhost:23119";
|
|
description = "Zotero Better BibTeX URL";
|
|
};
|
|
|
|
bibtexFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = null;
|
|
description = "Path to fallback BibTeX file";
|
|
};
|
|
|
|
citationMode = lib.mkOption {
|
|
type = lib.types.enum [ "silent" "warn" "strict" ];
|
|
default = "warn";
|
|
description = "Citation resolution failure mode";
|
|
};
|
|
|
|
user = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "org-garden";
|
|
description = "User to run the service as";
|
|
};
|
|
|
|
group = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "org-garden";
|
|
description = "Group to run the service as";
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
users.users.${cfg.user} = {
|
|
isSystemUser = true;
|
|
group = cfg.group;
|
|
home = cfg.outputDir;
|
|
};
|
|
|
|
users.groups.${cfg.group} = {};
|
|
|
|
systemd.services.org-garden = {
|
|
description = "Org-Garden Publishing Service";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
|
|
environment = {
|
|
NOTES_DIR = toString cfg.notesDir;
|
|
OUTPUT_DIR = cfg.outputDir;
|
|
PORT = toString cfg.port;
|
|
WS_PORT = toString cfg.wsPort;
|
|
HEALTH_PORT = toString cfg.healthPort;
|
|
ZOTERO_URL = cfg.zoteroUrl;
|
|
CITATION_MODE = cfg.citationMode;
|
|
} // lib.optionalAttrs (cfg.bibtexFile != null) {
|
|
BIBTEX_FILE = toString cfg.bibtexFile;
|
|
};
|
|
|
|
serviceConfig = {
|
|
Type = "exec";
|
|
ExecStart = "${pkg}/bin/org-garden serve";
|
|
Restart = "on-failure";
|
|
RestartSec = 5;
|
|
|
|
# Directories
|
|
StateDirectory = "org-garden";
|
|
WorkingDirectory = cfg.outputDir;
|
|
|
|
# Hardening
|
|
User = cfg.user;
|
|
Group = cfg.group;
|
|
NoNewPrivileges = true;
|
|
ProtectSystem = "strict";
|
|
ProtectHome = "read-only";
|
|
ReadWritePaths = [ cfg.outputDir ];
|
|
ReadOnlyPaths = [ cfg.notesDir ];
|
|
PrivateTmp = true;
|
|
};
|
|
};
|
|
};
|
|
}
|
|
```
|
|
|
|
### 7.2 Update `flake.nix`
|
|
|
|
Add to outputs:
|
|
```nix
|
|
nixosModules.default = import ./nix/module.nix;
|
|
```
|
|
|
|
Update `orgGardenApp` to build as release instead of escript (optional, can keep both).
|
|
|
|
---
|
|
|
|
## File Summary
|
|
|
|
### New Files
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `lib/org_garden/server.ex` | Service mode lifecycle management |
|
|
| `lib/org_garden/config.ex` | Centralized configuration |
|
|
| `lib/org_garden/health.ex` | Health check HTTP endpoints |
|
|
| `lib/org_garden/telemetry.ex` | Telemetry event helpers |
|
|
| `config/config.exs` | Build-time config defaults |
|
|
| `config/runtime.exs` | Runtime config from env vars |
|
|
| `rel/env.sh.eex` | Release environment setup |
|
|
| `rel/overlays/bin/org-garden` | Unified CLI entry point |
|
|
| `nix/module.nix` | NixOS service module |
|
|
|
|
### Modified Files
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `lib/org_garden/application.ex` | Add Registry, DynamicSupervisor, Health |
|
|
| `lib/org_garden/cli.ex` | Use Server module, Config |
|
|
| `lib/org_garden/supervisor.ex` | Minor: use Registry for naming |
|
|
| `lib/org_garden/quartz.ex` | Graceful shutdown, Config |
|
|
| `lib/org_garden/watcher.ex` | Drain period, telemetry, Logger |
|
|
| `lib/org_garden/export.ex` | Telemetry events |
|
|
| `mix.exs` | Add deps, releases config |
|
|
| `flake.nix` | Add nixosModules output |
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
1. **Phase 2: Configuration** — Foundation for all other phases
|
|
2. **Phase 1: Supervision Tree** — Core structural change
|
|
3. **Phase 3: Graceful Shutdown** — Builds on Phase 1
|
|
4. **Phase 5: Observability** — Independent, can be done in parallel
|
|
5. **Phase 4: Health Checks** — Depends on Phase 1 & 2
|
|
6. **Phase 6: Mix Release** — After code changes are stable
|
|
7. **Phase 7: NixOS Module** — Final, depends on release
|
|
|
|
---
|
|
|
|
## Questions
|
|
|
|
1. **Health check HTTP server**: Should it always run, or only in `serve` mode? (I'm thinking always, so systemd can probe even during startup)
|
|
|
|
2. **Telemetry consumers**: Should we add a simple Logger-based telemetry handler that logs timings, or leave telemetry events for future Prometheus/metrics integration?
|
|
|
|
3. **Should I proceed with implementation?**
|
|
|
|
## User (2026-02-21 20:09:01)
|
|
|
|
1. Always is fine. 2. Keep it simply for now. 3. Proceed.
|
|
|
|
## Agent (2026-02-21 20:09:03)
|
|
|
|
I'll start implementing the improvements. Let me begin with Phase 2 (Configuration) as it's the foundation, then proceed through the phases.
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:09:09
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: mkdir -p config
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:09:12
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:09:17
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,18 @@
|
|
+import Config
|
|
+
|
|
+# Default configuration values
|
|
+# These are overridden by config/runtime.exs in releases
|
|
+
|
|
+config :org_garden,
|
|
+ zotero_url: "http://localhost:23119",
|
|
+ citation_mode: :warn,
|
|
+ http_port: 8080,
|
|
+ ws_port: 3001,
|
|
+ health_port: 9090
|
|
+
|
|
+config :logger, :console,
|
|
+ format: "$time $metadata[$level] $message\n",
|
|
+ metadata: [:module]
|
|
+
|
|
+# Import environment specific config
|
|
+import_config "#{config_env()}.exs"
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:09:20
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,4 @@
|
|
+import Config
|
|
+
|
|
+# Development-specific configuration
|
|
+config :logger, level: :debug
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:09:23
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,6 @@
|
|
+import Config
|
|
+
|
|
+# Production-specific configuration
|
|
+# Most config comes from runtime.exs via environment variables
|
|
+
|
|
+config :logger, level: :info
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:09:26
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,4 @@
|
|
+import Config
|
|
+
|
|
+# Test-specific configuration
|
|
+config :logger, level: :warning
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:09:36
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:09:45
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,42 @@
|
|
+import Config
|
|
+
|
|
+# Runtime configuration from environment variables
|
|
+# This file is executed at runtime (not compile time)
|
|
+
|
|
+defmodule RuntimeConfig do
|
|
+ def parse_int(nil, default), do: default
|
|
+ def parse_int(val, _default) when is_integer(val), do: val
|
|
+
|
|
+ def parse_int(val, default) when is_binary(val) do
|
|
+ case Integer.parse(val) do
|
|
+ {int, ""} -> int
|
|
+ _ -> default
|
|
+ end
|
|
+ end
|
|
+
|
|
+ def parse_citation_mode("silent"), do: :silent
|
|
+ def parse_citation_mode("strict"), do: :strict
|
|
+ def parse_citation_mode(_), do: :warn
|
|
+end
|
|
+
|
|
+# Core paths
|
|
+if quartz_path = System.get_env("QUARTZ_PATH") do
|
|
+ config :org_garden, quartz_path: quartz_path
|
|
+end
|
|
+
|
|
+config :org_garden,
|
|
+ node_path: System.get_env("NODE_PATH", "node"),
|
|
+ notes_dir: System.get_env("NOTES_DIR"),
|
|
+ output_dir: System.get_env("OUTPUT_DIR")
|
|
+
|
|
+# Citation configuration
|
|
+config :org_garden,
|
|
+ zotero_url: System.get_env("ZOTERO_URL", "http://localhost:23119"),
|
|
+ bibtex_file: System.get_env("BIBTEX_FILE"),
|
|
+ citation_mode: RuntimeConfig.parse_citation_mode(System.get_env("CITATION_MODE"))
|
|
+
|
|
+# Server ports
|
|
+config :org_garden,
|
|
+ http_port: RuntimeConfig.parse_int(System.get_env("PORT"), 8080),
|
|
+ ws_port: RuntimeConfig.parse_int(System.get_env("WS_PORT"), 3001),
|
|
+ health_port: RuntimeConfig.parse_int(System.get_env("HEALTH_PORT"), 9090)
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:09:52
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:10:05
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,98 @@
|
|
+defmodule OrgGarden.Config do
|
|
+ @moduledoc """
|
|
+ Centralized configuration access with validation.
|
|
+
|
|
+ Provides a unified interface for accessing configuration values,
|
|
+ with support for defaults and required value validation.
|
|
+
|
|
+ ## Usage
|
|
+
|
|
+ OrgGarden.Config.get(:http_port)
|
|
+ #=> 8080
|
|
+
|
|
+ OrgGarden.Config.get!(:quartz_path)
|
|
+ #=> "/path/to/quartz" or raises if not set
|
|
+
|
|
+ OrgGarden.Config.pipeline_opts()
|
|
+ #=> %{zotero_url: "...", bibtex_file: nil, citation_mode: :warn}
|
|
+ """
|
|
+
|
|
+ @doc """
|
|
+ Get a configuration value with an optional default.
|
|
+ """
|
|
+ def get(key, default \\ nil) do
|
|
+ Application.get_env(:org_garden, key, default)
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Get a required configuration value. Raises if not set.
|
|
+ """
|
|
+ def get!(key) do
|
|
+ case Application.get_env(:org_garden, key) do
|
|
+ nil -> raise ArgumentError, "Missing required configuration: #{key}"
|
|
+ value -> value
|
|
+ end
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Build pipeline options map for transforms.
|
|
+ """
|
|
+ def pipeline_opts do
|
|
+ %{
|
|
+ zotero_url: get(:zotero_url, "http://localhost:23119"),
|
|
+ bibtex_file: get(:bibtex_file),
|
|
+ citation_mode: get(:citation_mode, :warn)
|
|
+ }
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Validate that all required configuration is present.
|
|
+ Returns :ok or {:error, reasons}.
|
|
+ """
|
|
+ def validate do
|
|
+ errors =
|
|
+ []
|
|
+ |> validate_quartz_path()
|
|
+ |> validate_citation_mode()
|
|
+
|
|
+ case errors do
|
|
+ [] -> :ok
|
|
+ errors -> {:error, errors}
|
|
+ end
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Validate configuration and raise on errors.
|
|
+ """
|
|
+ def validate! do
|
|
+ case validate() do
|
|
+ :ok -> :ok
|
|
+ {:error, errors} -> raise "Configuration errors: #{inspect(errors)}"
|
|
+ end
|
|
+ end
|
|
+
|
|
+ # Private validation helpers
|
|
+
|
|
+ defp validate_quartz_path(errors) do
|
|
+ case get(:quartz_path) do
|
|
+ nil ->
|
|
+ errors
|
|
+
|
|
+ path ->
|
|
+ cli_path = Path.join(path, "quartz/bootstrap-cli.mjs")
|
|
+
|
|
+ if File.exists?(cli_path) do
|
|
+ errors
|
|
+ else
|
|
+ [{:quartz_path, "bootstrap-cli.mjs not found at #{cli_path}"} | errors]
|
|
+ end
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp validate_citation_mode(errors) do
|
|
+ case get(:citation_mode) do
|
|
+ mode when mode in [:silent, :warn, :strict] -> errors
|
|
+ other -> [{:citation_mode, "Invalid citation mode: #{inspect(other)}"} | errors]
|
|
+ end
|
|
+ end
|
|
+end
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:10:13
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:10:14)
|
|
|
|
Now let me refactor `application.ex`:
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:10:21
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,39 @@
|
|
+defmodule OrgGarden.Application do
|
|
+ @moduledoc """
|
|
+ OTP Application for org-garden.
|
|
+
|
|
+ Starts core infrastructure services:
|
|
+ - Finch HTTP client pool
|
|
+ - Process registry for named lookups
|
|
+ - DynamicSupervisor for runtime-started services
|
|
+ - Health check HTTP server
|
|
+
|
|
+ Service-mode components (Watcher, Quartz) are started dynamically
|
|
+ via OrgGarden.Server when running in serve mode.
|
|
+ """
|
|
+ use Application
|
|
+
|
|
+ @impl true
|
|
+ def start(_type, _args) do
|
|
+ children = [
|
|
+ # HTTP client pool for Zotero/external requests
|
|
+ {Finch, name: OrgGarden.Finch},
|
|
+
|
|
+ # Process registry for named lookups
|
|
+ {Registry, keys: :unique, name: OrgGarden.Registry},
|
|
+
|
|
+ # Dynamic supervisor for serve mode components
|
|
+ {DynamicSupervisor, name: OrgGarden.DynamicSupervisor, strategy: :one_for_one},
|
|
+
|
|
+ # Health check HTTP server
|
|
+ {Bandit, plug: OrgGarden.Health, port: health_port(), scheme: :http}
|
|
+ ]
|
|
+
|
|
+ opts = [strategy: :one_for_one, name: OrgGarden.AppSupervisor]
|
|
+ Supervisor.start_link(children, opts)
|
|
+ end
|
|
+
|
|
+ defp health_port do
|
|
+ Application.get_env(:org_garden, :health_port, 9090)
|
|
+ end
|
|
+end
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:10:28
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:10:52
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,212 @@
|
|
+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
|
|
```
|
|
## Agent (2026-02-21 20:10:54)
|
|
|
|
Now let me create the Health module since application.ex depends on it, then update mix.exs with the new dependencies:
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:11:04
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,95 @@
|
|
+defmodule OrgGarden.Health do
|
|
+ @moduledoc """
|
|
+ Health check HTTP endpoints.
|
|
+
|
|
+ Provides liveness and readiness probes for systemd/kubernetes health checks.
|
|
+
|
|
+ ## Endpoints
|
|
+
|
|
+ * `GET /health/live` — Always returns 200 if the process is alive
|
|
+ * `GET /health/ready` — Returns 200 if all components are ready, 503 otherwise
|
|
+ * `GET /health` — Returns JSON status of all components
|
|
+ """
|
|
+
|
|
+ use Plug.Router
|
|
+
|
|
+ plug(:match)
|
|
+ plug(:dispatch)
|
|
+
|
|
+ @doc """
|
|
+ Liveness probe — is the process alive?
|
|
+ """
|
|
+ get "/health/live" do
|
|
+ send_resp(conn, 200, "ok")
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Readiness probe — are all components ready to serve?
|
|
+ """
|
|
+ get "/health/ready" do
|
|
+ checks = run_checks()
|
|
+ all_healthy = Enum.all?(checks, fn {_name, status} -> status == :ok end)
|
|
+
|
|
+ if all_healthy do
|
|
+ send_resp(conn, 200, "ready")
|
|
+ else
|
|
+ send_resp(conn, 503, "not ready")
|
|
+ end
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Full health status as JSON.
|
|
+ """
|
|
+ get "/health" do
|
|
+ checks = run_checks()
|
|
+ all_healthy = Enum.all?(checks, fn {_name, status} -> status == :ok end)
|
|
+
|
|
+ status =
|
|
+ if all_healthy do
|
|
+ "healthy"
|
|
+ else
|
|
+ "degraded"
|
|
+ end
|
|
+
|
|
+ body =
|
|
+ Jason.encode!(%{
|
|
+ status: status,
|
|
+ checks:
|
|
+ Map.new(checks, fn {name, status} ->
|
|
+ {name, Atom.to_string(status)}
|
|
+ end)
|
|
+ })
|
|
+
|
|
+ conn
|
|
+ |> put_resp_content_type("application/json")
|
|
+ |> send_resp(if(all_healthy, do: 200, else: 503), body)
|
|
+ end
|
|
+
|
|
+ match _ do
|
|
+ send_resp(conn, 404, "not found")
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Health checks
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ defp run_checks do
|
|
+ [
|
|
+ {:server, check_server()},
|
|
+ {:watcher, check_watcher()},
|
|
+ {:quartz, check_quartz()}
|
|
+ ]
|
|
+ end
|
|
+
|
|
+ defp check_server do
|
|
+ if Process.whereis(OrgGarden.Server), do: :ok, else: :not_running
|
|
+ end
|
|
+
|
|
+ defp check_watcher do
|
|
+ if Process.whereis(OrgGarden.Watcher), do: :ok, else: :not_running
|
|
+ end
|
|
+
|
|
+ defp check_quartz do
|
|
+ if Process.whereis(OrgGarden.Quartz), do: :ok, else: :not_running
|
|
+ end
|
|
+end
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 20:11:07
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/mix.exs</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.MixProject do
|
|
2: use Mix.Project
|
|
3:
|
|
4: def project do
|
|
5: [
|
|
6: app: :org_garden,
|
|
7: version: "0.1.0",
|
|
8: elixir: "~> 1.17",
|
|
9: start_permanent: Mix.env() == :prod,
|
|
10: deps: deps(),
|
|
11: escript: escript()
|
|
12: ]
|
|
13: end
|
|
14:
|
|
15: def application do
|
|
16: [
|
|
17: extra_applications: [:logger],
|
|
18: mod: {OrgGarden.Application, []}
|
|
19: ]
|
|
20: end
|
|
21:
|
|
22: defp escript do
|
|
23: [main_module: OrgGarden.CLI]
|
|
24: end
|
|
25:
|
|
26: defp deps do
|
|
27: [
|
|
28: {:finch, "~> 0.19"},
|
|
29: {:req, "~> 0.5"},
|
|
30: {:jason, "~> 1.4"},
|
|
31: {:file_system, "~> 1.0"}
|
|
32: ]
|
|
33: end
|
|
34: end
|
|
35:
|
|
|
|
(End of file - total 35 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:11:14
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,50 @@
|
|
+defmodule OrgGarden.MixProject do
|
|
+ use Mix.Project
|
|
+
|
|
+ def project do
|
|
+ [
|
|
+ app: :org_garden,
|
|
+ version: "0.1.0",
|
|
+ elixir: "~> 1.17",
|
|
+ start_permanent: Mix.env() == :prod,
|
|
+ deps: deps(),
|
|
+ escript: escript(),
|
|
+ releases: releases()
|
|
+ ]
|
|
+ end
|
|
+
|
|
+ def application do
|
|
+ [
|
|
+ extra_applications: [:logger],
|
|
+ mod: {OrgGarden.Application, []}
|
|
+ ]
|
|
+ end
|
|
+
|
|
+ defp escript do
|
|
+ [main_module: OrgGarden.CLI]
|
|
+ end
|
|
+
|
|
+ defp releases do
|
|
+ [
|
|
+ org_garden: [
|
|
+ include_executables_for: [:unix],
|
|
+ applications: [runtime_tools: :permanent],
|
|
+ steps: [:assemble, :tar]
|
|
+ ]
|
|
+ ]
|
|
+ end
|
|
+
|
|
+ defp deps do
|
|
+ [
|
|
+ {:finch, "~> 0.19"},
|
|
+ {:req, "~> 0.5"},
|
|
+ {:jason, "~> 1.4"},
|
|
+ {:file_system, "~> 1.0"},
|
|
+ # Health check HTTP server
|
|
+ {:bandit, "~> 1.0"},
|
|
+ {:plug, "~> 1.15"},
|
|
+ # Telemetry
|
|
+ {:telemetry, "~> 1.0"}
|
|
+ ]
|
|
+ end
|
|
+end
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:11:25
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:11:27)
|
|
|
|
Now let me update cli.ex to use the new Server module:
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:12:13
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,382 @@
|
|
+defmodule OrgGarden.CLI do
|
|
+ @moduledoc """
|
|
+ Escript entry point for the org-garden pipeline.
|
|
+
|
|
+ ## Commands
|
|
+
|
|
+ org-garden serve <notes-dir> [--port 8080] [--ws-port 3001]
|
|
+ org-garden build <notes-dir> [--output <path>]
|
|
+ org-garden export <notes-dir> [--watch]
|
|
+
|
|
+ ### serve
|
|
+ Development server with watch + live reload. Starts both the org→md
|
|
+ watcher and Quartz in serve mode.
|
|
+
|
|
+ ### build
|
|
+ One-shot build for CI/production. Exports org files, runs transforms,
|
|
+ then builds static site with Quartz.
|
|
+
|
|
+ ### export
|
|
+ Just export org→md (current pipeline behavior). Use --watch for
|
|
+ incremental re-export on file changes.
|
|
+
|
|
+ ## Arguments
|
|
+
|
|
+ notes-dir Path to the directory containing `.org` notes (required).
|
|
+ Also accepts the `NOTES_DIR` env var.
|
|
+
|
|
+ ## Options
|
|
+
|
|
+ --output <path> Output root directory (used as ox-hugo base dir).
|
|
+ Defaults to the `OUTPUT_DIR` env var, or the current
|
|
+ working directory.
|
|
+ --content-dir <p> Output directory for exported Markdown. Defaults to
|
|
+ `<output>/content`.
|
|
+ --port <n> HTTP server port (default: 8080). Only for `serve`.
|
|
+ --ws-port <n> WebSocket hot reload port (default: 3001). Only for `serve`.
|
|
+ --watch After initial batch, watch notes-dir for changes and
|
|
+ incrementally re-export affected files. Only for `export`.
|
|
+
|
|
+ ## Environment Variables
|
|
+
|
|
+ BIBTEX_FILE Path to a `.bib` file used as citation fallback.
|
|
+ ZOTERO_URL Zotero Better BibTeX base URL (default: http://localhost:23119).
|
|
+ CITATION_MODE silent | warn (default) | strict.
|
|
+ QUARTZ_PATH Path to quartz directory (required for serve/build).
|
|
+ NODE_PATH Node.js executable (default: node).
|
|
+ """
|
|
+
|
|
+ require Logger
|
|
+
|
|
+ alias OrgGarden.Config
|
|
+
|
|
+ @transforms [OrgGarden.Transforms.Citations]
|
|
+
|
|
+ def main(argv) do
|
|
+ Application.ensure_all_started(:org_garden)
|
|
+
|
|
+ case argv do
|
|
+ ["serve" | rest] -> handle_serve(rest)
|
|
+ ["build" | rest] -> handle_build(rest)
|
|
+ ["export" | rest] -> handle_export(rest)
|
|
+ # Legacy: treat bare args as export command for backward compatibility
|
|
+ [_ | _] -> handle_export(argv)
|
|
+ _ -> abort("Usage: org-garden <serve|build|export> <notes-dir> [options]")
|
|
+ end
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Command: serve
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ def handle_serve(argv) do
|
|
+ require_quartz_env()
|
|
+ {notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv)
|
|
+
|
|
+ IO.puts("==> Starting development server...")
|
|
+
|
|
+ case OrgGarden.Server.start_link(
|
|
+ notes_dir: notes_dir,
|
|
+ output_dir: output_dir,
|
|
+ content_dir: content_dir,
|
|
+ port: opts[:port],
|
|
+ ws_port: opts[:ws_port]
|
|
+ ) do
|
|
+ {:ok, pid} ->
|
|
+ IO.puts("==> Server running at http://localhost:#{opts[:port] || Config.get(:http_port, 8080)}")
|
|
+ IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)")
|
|
+
|
|
+ # Wait for server to exit
|
|
+ ref = Process.monitor(pid)
|
|
+
|
|
+ receive do
|
|
+ {:DOWN, ^ref, :process, ^pid, reason} ->
|
|
+ case reason do
|
|
+ :normal -> :ok
|
|
+ :shutdown -> :ok
|
|
+ {:shutdown, _} -> :ok
|
|
+ _ -> abort("Server crashed: #{inspect(reason)}")
|
|
+ end
|
|
+ end
|
|
+
|
|
+ {:error, reason} ->
|
|
+ abort("Failed to start server: #{inspect(reason)}")
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp parse_serve_args(argv) do
|
|
+ {opts, positional, _invalid} =
|
|
+ OptionParser.parse(argv,
|
|
+ strict: [
|
|
+ output: :string,
|
|
+ content_dir: :string,
|
|
+ port: :integer,
|
|
+ ws_port: :integer
|
|
+ ]
|
|
+ )
|
|
+
|
|
+ notes_dir = extract_notes_dir(positional, "serve")
|
|
+ output_dir = extract_output_dir(opts)
|
|
+ content_dir = extract_content_dir(opts, output_dir)
|
|
+
|
|
+ {notes_dir, output_dir, content_dir, opts}
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Command: build
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ def handle_build(argv) do
|
|
+ quartz_path = require_quartz_env()
|
|
+ {notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv)
|
|
+ pipeline_opts = Config.pipeline_opts()
|
|
+
|
|
+ # Full batch export
|
|
+ wipe(content_dir)
|
|
+ export_all(notes_dir, output_dir)
|
|
+ run_pipeline(content_dir, pipeline_opts)
|
|
+ generate_index(content_dir)
|
|
+
|
|
+ node_path = Config.get(:node_path, "node")
|
|
+
|
|
+ IO.puts("==> Building static site with Quartz...")
|
|
+
|
|
+ {output, status} =
|
|
+ System.cmd(
|
|
+ node_path,
|
|
+ [
|
|
+ Path.join(quartz_path, "quartz/bootstrap-cli.mjs"),
|
|
+ "build",
|
|
+ "--directory",
|
|
+ content_dir,
|
|
+ "--output",
|
|
+ Path.join(output_dir, "public")
|
|
+ ],
|
|
+ cd: quartz_path,
|
|
+ stderr_to_stdout: true
|
|
+ )
|
|
+
|
|
+ IO.puts(output)
|
|
+
|
|
+ if status != 0 do
|
|
+ abort("Quartz build failed with status #{status}")
|
|
+ end
|
|
+
|
|
+ IO.puts("==> Build complete. Output: #{Path.join(output_dir, "public")}")
|
|
+ end
|
|
+
|
|
+ defp parse_build_args(argv) do
|
|
+ {opts, positional, _invalid} =
|
|
+ OptionParser.parse(argv,
|
|
+ strict: [output: :string, content_dir: :string]
|
|
+ )
|
|
+
|
|
+ notes_dir = extract_notes_dir(positional, "build")
|
|
+ output_dir = extract_output_dir(opts)
|
|
+ content_dir = extract_content_dir(opts, output_dir)
|
|
+
|
|
+ {notes_dir, output_dir, content_dir, opts}
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Command: export (original pipeline behavior)
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ def handle_export(argv) do
|
|
+ {notes_dir, output_dir, content_dir, watch?} = parse_export_args(argv)
|
|
+ pipeline_opts = Config.pipeline_opts()
|
|
+
|
|
+ # Phase 1-4: full batch export
|
|
+ wipe(content_dir)
|
|
+ export_all(notes_dir, output_dir)
|
|
+ run_pipeline(content_dir, pipeline_opts)
|
|
+ generate_index(content_dir)
|
|
+
|
|
+ md_count =
|
|
+ content_dir
|
|
+ |> Path.join("**/*.md")
|
|
+ |> Path.wildcard()
|
|
+ |> length()
|
|
+
|
|
+ IO.puts("==> Done. #{md_count} markdown files in #{content_dir}")
|
|
+
|
|
+ # Phase 5: optional watch mode
|
|
+ if watch? do
|
|
+ IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)")
|
|
+
|
|
+ {:ok, pid} =
|
|
+ DynamicSupervisor.start_child(
|
|
+ OrgGarden.DynamicSupervisor,
|
|
+ {OrgGarden.Watcher,
|
|
+ notes_dir: notes_dir,
|
|
+ output_dir: output_dir,
|
|
+ content_dir: content_dir,
|
|
+ pipeline_opts: pipeline_opts,
|
|
+ transforms: @transforms}
|
|
+ )
|
|
+
|
|
+ # Wait for watcher to exit
|
|
+ ref = Process.monitor(pid)
|
|
+
|
|
+ receive do
|
|
+ {:DOWN, ^ref, :process, ^pid, reason} ->
|
|
+ case reason do
|
|
+ :normal -> :ok
|
|
+ :shutdown -> :ok
|
|
+ {:shutdown, _} -> :ok
|
|
+ _ -> abort("Watcher crashed: #{inspect(reason)}")
|
|
+ end
|
|
+ end
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp parse_export_args(argv) do
|
|
+ {opts, positional, _invalid} =
|
|
+ OptionParser.parse(argv,
|
|
+ strict: [output: :string, content_dir: :string, watch: :boolean]
|
|
+ )
|
|
+
|
|
+ notes_dir = extract_notes_dir(positional, "export")
|
|
+ output_dir = extract_output_dir(opts)
|
|
+ content_dir = extract_content_dir(opts, output_dir)
|
|
+ watch? = Keyword.get(opts, :watch, false)
|
|
+
|
|
+ {notes_dir, output_dir, content_dir, watch?}
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Shared argument extraction
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ defp extract_notes_dir(positional, command) do
|
|
+ notes_dir =
|
|
+ case positional do
|
|
+ [dir | _] ->
|
|
+ dir
|
|
+
|
|
+ [] ->
|
|
+ Config.get(:notes_dir) ||
|
|
+ abort("Usage: org-garden #{command} <notes-dir> [options]")
|
|
+ end
|
|
+
|
|
+ notes_dir = Path.expand(notes_dir)
|
|
+
|
|
+ unless File.dir?(notes_dir) do
|
|
+ abort("Error: notes directory does not exist: #{notes_dir}")
|
|
+ end
|
|
+
|
|
+ notes_dir
|
|
+ end
|
|
+
|
|
+ defp extract_output_dir(opts) do
|
|
+ (opts[:output] || Config.get(:output_dir) || File.cwd!())
|
|
+ |> Path.expand()
|
|
+ end
|
|
+
|
|
+ defp extract_content_dir(opts, output_dir) do
|
|
+ (opts[:content_dir] || Path.join(output_dir, "content"))
|
|
+ |> Path.expand()
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Phase 1: Wipe content/
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ defp wipe(content_dir) do
|
|
+ IO.puts("==> 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
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Phase 2: Export org files via Emacs + ox-hugo
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ defp export_all(notes_dir, output_dir) do
|
|
+ IO.puts("==> Exporting org files from #{notes_dir}")
|
|
+
|
|
+ case OrgGarden.Export.export_all(notes_dir, output_dir) do
|
|
+ {:ok, 0} ->
|
|
+ IO.puts("No .org files found in #{notes_dir}")
|
|
+ System.halt(0)
|
|
+
|
|
+ {:ok, count} ->
|
|
+ IO.puts(" exported #{count} file(s)")
|
|
+
|
|
+ {:error, failures} ->
|
|
+ IO.puts(:stderr, "\nFailed to export #{length(failures)} file(s):")
|
|
+
|
|
+ Enum.each(failures, fn {f, {:error, reason}} ->
|
|
+ IO.puts(:stderr, " #{f}: #{inspect(reason)}")
|
|
+ end)
|
|
+
|
|
+ System.halt(1)
|
|
+ end
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Phase 3: Markdown transformation pipeline
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ defp run_pipeline(content_dir, pipeline_opts) do
|
|
+ IO.puts("==> Running markdown pipeline")
|
|
+
|
|
+ {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts)
|
|
+
|
|
+ Enum.each(stats, fn {mod, count} ->
|
|
+ IO.puts(" #{inspect(mod)}: #{count} file(s) modified")
|
|
+ end)
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Phase 4: Generate default index.md if none was exported
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ defp generate_index(content_dir) do
|
|
+ IO.puts("==> Generating index")
|
|
+ OrgGarden.Index.generate(content_dir)
|
|
+ end
|
|
+
|
|
+ # ---------------------------------------------------------------------------
|
|
+ # Helpers
|
|
+ # ---------------------------------------------------------------------------
|
|
+
|
|
+ defp require_quartz_env do
|
|
+ case Config.get(:quartz_path) do
|
|
+ nil ->
|
|
+ abort("""
|
|
+ Error: QUARTZ_PATH environment variable not set.
|
|
+
|
|
+ The 'serve' and 'build' commands require Quartz to be available.
|
|
+
|
|
+ Use the wrapper scripts that set up the environment:
|
|
+ nix run .#notes -- <notes-dir> # for serve
|
|
+ nix run .#build -- <notes-dir> # for build
|
|
+
|
|
+ Or set QUARTZ_PATH manually to point to a quartz-org-roam checkout
|
|
+ with node_modules installed.
|
|
+
|
|
+ For export-only mode (no Quartz), use:
|
|
+ org-garden export <notes-dir> [--watch]
|
|
+ """)
|
|
+
|
|
+ path ->
|
|
+ unless File.exists?(Path.join(path, "quartz/bootstrap-cli.mjs")) do
|
|
+ abort("Error: QUARTZ_PATH=#{path} does not contain quartz/bootstrap-cli.mjs")
|
|
+ end
|
|
+
|
|
+ path
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp abort(message) do
|
|
+ IO.puts(:stderr, message)
|
|
+ System.halt(1)
|
|
+ end
|
|
+end
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:12:23
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "pending",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:12:26)
|
|
|
|
Now let's update quartz.ex for graceful shutdown:
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:12:48
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,183 @@
|
|
+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
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:12:58
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:13:00)
|
|
|
|
Now let's update watcher.ex with drain period and telemetry support:
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:13:35
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,293 @@
|
|
+defmodule OrgGarden.Watcher do
|
|
+ @moduledoc """
|
|
+ File-watching GenServer that detects `.org` file changes and triggers
|
|
+ incremental export + transform for only the affected files.
|
|
+
|
|
+ Uses the `file_system` package (inotify on Linux, fsevents on macOS)
|
|
+ to watch the notes directory. Events are debounced per-file (500ms)
|
|
+ to coalesce rapid writes (e.g., Emacs auto-save).
|
|
+
|
|
+ ## Lifecycle
|
|
+
|
|
+ Started dynamically by `OrgGarden.CLI` after the initial batch export.
|
|
+ Transforms are initialized once at startup and reused across all
|
|
+ incremental rebuilds to avoid repeated Zotero probes and BibTeX loads.
|
|
+
|
|
+ ## Usage
|
|
+
|
|
+ OrgGarden.Watcher.start_link(
|
|
+ notes_dir: "/path/to/notes",
|
|
+ output_dir: "/path/to/output",
|
|
+ content_dir: "/path/to/output/content",
|
|
+ pipeline_opts: %{zotero_url: "...", ...},
|
|
+ transforms: [OrgGarden.Transforms.Citations]
|
|
+ )
|
|
+ """
|
|
+
|
|
+ use GenServer
|
|
+
|
|
+ require Logger
|
|
+
|
|
+ @debounce_ms 500
|
|
+ @drain_timeout 5_000
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Client API
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ @doc """
|
|
+ Start the watcher as a linked process.
|
|
+
|
|
+ ## Options
|
|
+
|
|
+ * `:notes_dir` — directory to watch for `.org` changes (required)
|
|
+ * `:output_dir` — ox-hugo base dir (required)
|
|
+ * `:content_dir` — directory where `.md` files are written (required)
|
|
+ * `:pipeline_opts` — opts map passed to transforms (required)
|
|
+ * `:transforms` — list of transform modules (default: `[OrgGarden.Transforms.Citations]`)
|
|
+ """
|
|
+ def start_link(opts) do
|
|
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Check if the watcher 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.fetch!(opts, :content_dir)
|
|
+ pipeline_opts = Keyword.fetch!(opts, :pipeline_opts)
|
|
+ transforms = Keyword.get(opts, :transforms, [OrgGarden.Transforms.Citations])
|
|
+
|
|
+ # Initialize transforms once — reused for all incremental rebuilds
|
|
+ initialized_transforms = OrgGarden.init_transforms(transforms, pipeline_opts)
|
|
+
|
|
+ # Start the file system watcher
|
|
+ {:ok, watcher_pid} = FileSystem.start_link(dirs: [notes_dir], recursive: true)
|
|
+ FileSystem.subscribe(watcher_pid)
|
|
+
|
|
+ Logger.info("Watcher: monitoring #{notes_dir} for .org changes")
|
|
+
|
|
+ {:ok,
|
|
+ %{
|
|
+ notes_dir: notes_dir,
|
|
+ output_dir: output_dir,
|
|
+ content_dir: content_dir,
|
|
+ pipeline_opts: pipeline_opts,
|
|
+ watcher_pid: watcher_pid,
|
|
+ initialized_transforms: initialized_transforms,
|
|
+ pending: %{},
|
|
+ processing: false
|
|
+ }}
|
|
+ end
|
|
+
|
|
+ @impl true
|
|
+ def handle_info({:file_event, _pid, {path, events}}, state) do
|
|
+ path = to_string(path)
|
|
+
|
|
+ if org_file?(path) and not temporary_file?(path) do
|
|
+ event_type = classify_events(events)
|
|
+ Logger.debug("Watcher: #{event_type} event for #{path}")
|
|
+ {:noreply, schedule_debounce(path, event_type, state)}
|
|
+ else
|
|
+ {:noreply, state}
|
|
+ end
|
|
+ end
|
|
+
|
|
+ @impl true
|
|
+ def handle_info({:file_event, _pid, :stop}, state) do
|
|
+ Logger.warning("Watcher: file system monitor stopped unexpectedly")
|
|
+ {:stop, :watcher_stopped, state}
|
|
+ end
|
|
+
|
|
+ @impl true
|
|
+ def handle_info({:debounced, path, event_type}, state) do
|
|
+ state = %{state | pending: Map.delete(state.pending, path), processing: true}
|
|
+
|
|
+ case event_type do
|
|
+ :deleted ->
|
|
+ handle_delete(path, state)
|
|
+
|
|
+ _created_or_modified ->
|
|
+ handle_change(path, state)
|
|
+ end
|
|
+
|
|
+ emit_telemetry(:file_processed, %{path: path, event: event_type})
|
|
+
|
|
+ {:noreply, %{state | processing: false}}
|
|
+ end
|
|
+
|
|
+ @impl true
|
|
+ def terminate(reason, state) do
|
|
+ Logger.info("Watcher: shutting down (#{inspect(reason)})")
|
|
+
|
|
+ # Drain pending timers
|
|
+ drain_pending(state.pending)
|
|
+
|
|
+ # Wait for any in-flight processing
|
|
+ if state.processing do
|
|
+ Logger.info("Watcher: waiting for in-flight processing...")
|
|
+ Process.sleep(100)
|
|
+ end
|
|
+
|
|
+ # Teardown transforms
|
|
+ OrgGarden.teardown_transforms(state.initialized_transforms)
|
|
+
|
|
+ :ok
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Event handling
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ defp handle_change(orgfile, state) do
|
|
+ %{
|
|
+ notes_dir: notes_dir,
|
|
+ output_dir: output_dir,
|
|
+ content_dir: content_dir,
|
|
+ pipeline_opts: pipeline_opts,
|
|
+ initialized_transforms: initialized_transforms
|
|
+ } = state
|
|
+
|
|
+ md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
|
+ Logger.info("Changed: #{Path.relative_to(orgfile, notes_dir)}")
|
|
+
|
|
+ start_time = System.monotonic_time(:millisecond)
|
|
+
|
|
+ case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do
|
|
+ {:ok, _} ->
|
|
+ Logger.info(" exported: #{Path.relative_to(md_path, content_dir)}")
|
|
+
|
|
+ {:ok, stats} =
|
|
+ OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts)
|
|
+
|
|
+ Enum.each(stats, fn {mod, count} ->
|
|
+ if count > 0, do: Logger.info(" #{inspect(mod)}: #{count} file(s) modified")
|
|
+ end)
|
|
+
|
|
+ regenerate_index(content_dir)
|
|
+
|
|
+ duration = System.monotonic_time(:millisecond) - start_time
|
|
+ Logger.info(" done in #{duration}ms")
|
|
+
|
|
+ {:error, reason} ->
|
|
+ Logger.error("Watcher: export failed for #{orgfile}: #{inspect(reason)}")
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp handle_delete(orgfile, state) do
|
|
+ %{notes_dir: notes_dir, content_dir: content_dir} = state
|
|
+
|
|
+ md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir)
|
|
+ Logger.info("Deleted: #{Path.relative_to(orgfile, notes_dir)}")
|
|
+
|
|
+ if File.exists?(md_path) do
|
|
+ File.rm!(md_path)
|
|
+ Logger.info(" removed: #{Path.relative_to(md_path, content_dir)}")
|
|
+
|
|
+ # Clean up empty parent directories left behind
|
|
+ cleanup_empty_dirs(Path.dirname(md_path), content_dir)
|
|
+ end
|
|
+
|
|
+ regenerate_index(content_dir)
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Index generation
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ defp regenerate_index(content_dir) do
|
|
+ OrgGarden.Index.regenerate(content_dir)
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Helpers
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ defp schedule_debounce(path, event_type, state) do
|
|
+ # Cancel any existing timer for this path
|
|
+ case Map.get(state.pending, path) do
|
|
+ nil -> :ok
|
|
+ old_ref -> Process.cancel_timer(old_ref)
|
|
+ end
|
|
+
|
|
+ ref = Process.send_after(self(), {:debounced, path, event_type}, @debounce_ms)
|
|
+ %{state | pending: Map.put(state.pending, path, ref)}
|
|
+ end
|
|
+
|
|
+ defp drain_pending(pending) when map_size(pending) == 0, do: :ok
|
|
+
|
|
+ defp drain_pending(pending) do
|
|
+ Logger.info("Watcher: draining #{map_size(pending)} pending event(s)")
|
|
+
|
|
+ # Cancel all timers and log what we're dropping
|
|
+ Enum.each(pending, fn {path, ref} ->
|
|
+ Process.cancel_timer(ref)
|
|
+ Logger.debug("Watcher: dropped pending event for #{path}")
|
|
+ end)
|
|
+
|
|
+ # Give a moment for any final messages to arrive
|
|
+ receive do
|
|
+ {:debounced, _, _} -> :ok
|
|
+ after
|
|
+ @drain_timeout -> :ok
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp org_file?(path), do: String.ends_with?(path, ".org")
|
|
+
|
|
+ defp temporary_file?(path) do
|
|
+ basename = Path.basename(path)
|
|
+ # Emacs creates temp files like .#file.org and #file.org#
|
|
+ String.starts_with?(basename, ".#") or
|
|
+ (String.starts_with?(basename, "#") and String.ends_with?(basename, "#"))
|
|
+ end
|
|
+
|
|
+ defp classify_events(events) do
|
|
+ cond do
|
|
+ :removed in events or :deleted in events -> :deleted
|
|
+ :created in events -> :created
|
|
+ :modified in events or :changed in events -> :modified
|
|
+ # renamed can mean created or deleted depending on context;
|
|
+ # if the file exists it was renamed into the watched dir
|
|
+ :renamed in events -> :modified
|
|
+ true -> :modified
|
|
+ end
|
|
+ end
|
|
+
|
|
+ defp cleanup_empty_dirs(dir, stop_at) do
|
|
+ dir = Path.expand(dir)
|
|
+ stop_at = Path.expand(stop_at)
|
|
+
|
|
+ if dir != stop_at and File.dir?(dir) do
|
|
+ case File.ls!(dir) do
|
|
+ [] ->
|
|
+ File.rmdir!(dir)
|
|
+ cleanup_empty_dirs(Path.dirname(dir), stop_at)
|
|
+
|
|
+ _ ->
|
|
+ :ok
|
|
+ end
|
|
+ end
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Telemetry
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ defp emit_telemetry(event, metadata) do
|
|
+ :telemetry.execute([:org_garden, :watcher, event], %{}, metadata)
|
|
+ end
|
|
+end
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:13:45
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "in_progress",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:14:00
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,109 @@
|
|
+defmodule OrgGarden.Telemetry do
|
|
+ @moduledoc """
|
|
+ Telemetry event definitions and logging handler.
|
|
+
|
|
+ ## Events
|
|
+
|
|
+ The following telemetry events are emitted:
|
|
+
|
|
+ * `[:org_garden, :export, :start]` — Export of a single file started
|
|
+ - Metadata: `%{file: path}`
|
|
+
|
|
+ * `[:org_garden, :export, :stop]` — Export of a single file completed
|
|
+ - Measurements: `%{duration: native_time}`
|
|
+ - Metadata: `%{file: path}`
|
|
+
|
|
+ * `[:org_garden, :export, :exception]` — Export failed
|
|
+ - Measurements: `%{duration: native_time}`
|
|
+ - Metadata: `%{file: path, kind: kind, reason: reason}`
|
|
+
|
|
+ * `[:org_garden, :watcher, :file_processed]` — File change processed
|
|
+ - Metadata: `%{path: path, event: :created | :modified | :deleted}`
|
|
+
|
|
+ * `[:org_garden, :server, :start]` — Server started
|
|
+ - Metadata: `%{port: port}`
|
|
+
|
|
+ * `[:org_garden, :server, :stop]` — Server stopped
|
|
+ - Metadata: `%{reason: reason}`
|
|
+
|
|
+ ## Usage
|
|
+
|
|
+ Attach a handler to log events:
|
|
+
|
|
+ OrgGarden.Telemetry.attach_logger()
|
|
+
|
|
+ Or use `:telemetry.attach/4` for custom handling.
|
|
+ """
|
|
+
|
|
+ require Logger
|
|
+
|
|
+ @doc """
|
|
+ Attach a simple logging handler for telemetry events.
|
|
+ """
|
|
+ def attach_logger do
|
|
+ events = [
|
|
+ [:org_garden, :export, :stop],
|
|
+ [:org_garden, :export, :exception],
|
|
+ [:org_garden, :watcher, :file_processed],
|
|
+ [:org_garden, :server, :start],
|
|
+ [:org_garden, :server, :stop]
|
|
+ ]
|
|
+
|
|
+ :telemetry.attach_many(
|
|
+ "org-garden-logger",
|
|
+ events,
|
|
+ &handle_event/4,
|
|
+ nil
|
|
+ )
|
|
+ end
|
|
+
|
|
+ @doc """
|
|
+ Detach the logging handler.
|
|
+ """
|
|
+ def detach_logger do
|
|
+ :telemetry.detach("org-garden-logger")
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Event handlers
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ defp handle_event([:org_garden, :export, :stop], measurements, metadata, _config) do
|
|
+ duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
|
|
+ Logger.debug("Export completed: #{metadata.file} (#{duration_ms}ms)")
|
|
+ end
|
|
+
|
|
+ defp handle_event([:org_garden, :export, :exception], _measurements, metadata, _config) do
|
|
+ Logger.error("Export failed: #{metadata.file} - #{inspect(metadata.reason)}")
|
|
+ end
|
|
+
|
|
+ defp handle_event([:org_garden, :watcher, :file_processed], _measurements, metadata, _config) do
|
|
+ Logger.debug("Watcher processed: #{metadata.event} #{metadata.path}")
|
|
+ end
|
|
+
|
|
+ defp handle_event([:org_garden, :server, :start], _measurements, metadata, _config) do
|
|
+ Logger.info("Server started on port #{metadata.port}")
|
|
+ end
|
|
+
|
|
+ defp handle_event([:org_garden, :server, :stop], _measurements, metadata, _config) do
|
|
+ Logger.info("Server stopped: #{inspect(metadata.reason)}")
|
|
+ end
|
|
+
|
|
+ # -------------------------------------------------------------------
|
|
+ # Convenience functions for emitting events
|
|
+ # -------------------------------------------------------------------
|
|
+
|
|
+ @doc """
|
|
+ Wrap a function with export telemetry events.
|
|
+ """
|
|
+ def span_export(file, fun) when is_function(fun, 0) do
|
|
+ :telemetry.span(
|
|
+ [:org_garden, :export],
|
|
+ %{file: file},
|
|
+ fn ->
|
|
+ result = fun.()
|
|
+ {result, %{file: file}}
|
|
+ end
|
|
+ )
|
|
+ end
|
|
+end
|
|
```
|
|
## Agent (2026-02-21 20:14:03)
|
|
|
|
Now let's read and update export.ex to add telemetry:
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 20:14:04
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/export.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.Export do
|
|
2: @moduledoc """
|
|
3: Org-to-Markdown export via Emacs batch + ox-hugo.
|
|
4:
|
|
5: Provides both single-file and batch export, plus a helper to compute
|
|
6: the expected `.md` output path for a given `.org` source file.
|
|
7: """
|
|
8:
|
|
9: require Logger
|
|
10:
|
|
11: @doc """
|
|
12: Export a single `.org` file to Markdown via `emacs --batch` + ox-hugo.
|
|
13:
|
|
14: Returns `{:ok, exit_code}` with the emacs exit code (0 = success),
|
|
15: or `{:error, reason}` if the command could not be executed.
|
|
16: """
|
|
17: @spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
|
|
18: def export_file(orgfile, notes_dir, output_dir) do
|
|
19: section =
|
|
20: orgfile
|
|
21: |> Path.dirname()
|
|
22: |> Path.relative_to(notes_dir)
|
|
23:
|
|
24: # ox-hugo requires static/ to exist for image asset copying
|
|
25: File.mkdir_p!(Path.join(output_dir, "static"))
|
|
26:
|
|
27: {output, exit_code} =
|
|
28: System.cmd(
|
|
29: "emacs",
|
|
30: [
|
|
31: "--batch",
|
|
32: "--eval", "(require 'ox-hugo)",
|
|
33: "--eval", """
|
|
34: (org-cite-register-processor 'passthrough
|
|
35: :export-citation
|
|
36: (lambda (citation _style _backend _info)
|
|
37: (let ((keys (mapcar (lambda (ref)
|
|
38: (concat "@" (org-element-property :key ref)))
|
|
39: (org-cite-get-references citation))))
|
|
40: (format "[cite:%s]" (string-join keys ";")))))
|
|
41: """,
|
|
42: "--eval", "(setq org-cite-export-processors '((t passthrough)))",
|
|
43: "--eval", ~s[(setq org-hugo-base-dir "#{output_dir}")],
|
|
44: "--eval", ~s[(setq org-hugo-default-section-directory "#{section}")],
|
|
45: "--visit", orgfile,
|
|
46: "--funcall", "org-hugo-export-to-md"
|
|
47: ],
|
|
48: stderr_to_stdout: true
|
|
49: )
|
|
50:
|
|
51: filtered =
|
|
52: output
|
|
53: |> String.split("\n")
|
|
54: |> Enum.reject(&String.match?(&1, ~r/^Loading|^ad-handle|^For information/))
|
|
55: |> Enum.join("\n")
|
|
56:
|
|
57: if filtered != "", do: Logger.info("emacs: #{filtered}")
|
|
58:
|
|
59: if exit_code == 0 do
|
|
60: {:ok, exit_code}
|
|
61: else
|
|
62: {:error, {:emacs_exit, exit_code, filtered}}
|
|
63: end
|
|
64: rescue
|
|
65: e -> {:error, e}
|
|
66: end
|
|
67:
|
|
68: @doc """
|
|
69: Export all `.org` files found under `notes_dir`.
|
|
70:
|
|
71: Returns `{:ok, count}` where `count` is the number of successfully
|
|
72: exported files, or `{:error, failures}` if any files failed.
|
|
73: """
|
|
74: @spec export_all(String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, list()}
|
|
75: def export_all(notes_dir, output_dir) do
|
|
76: org_files =
|
|
77: Path.join(notes_dir, "**/*.org")
|
|
78: |> Path.wildcard()
|
|
79:
|
|
80: if org_files == [] do
|
|
81: Logger.warning("No .org files found in #{notes_dir}")
|
|
82: {:ok, 0}
|
|
83: else
|
|
84: Logger.info("Exporting #{length(org_files)} org file(s) from #{notes_dir}")
|
|
85:
|
|
86: results =
|
|
87: Enum.map(org_files, fn orgfile ->
|
|
88: IO.puts(" exporting: #{orgfile}")
|
|
89: {orgfile, export_file(orgfile, notes_dir, output_dir)}
|
|
90: end)
|
|
91:
|
|
92: failures =
|
|
93: Enum.filter(results, fn
|
|
94: {_, {:ok, _}} -> false
|
|
95: {_, {:error, _}} -> true
|
|
96: end)
|
|
97:
|
|
98: if failures == [] do
|
|
99: {:ok, length(results)}
|
|
100: else
|
|
101: {:error, failures}
|
|
102: end
|
|
103: end
|
|
104: end
|
|
105:
|
|
106: @doc """
|
|
107: Compute the expected `.md` path for a given `.org` file.
|
|
108:
|
|
109: Uses the same section-mapping logic as ox-hugo: the relative directory
|
|
110: of the `.org` file within `notes_dir` becomes the section directory
|
|
111: under `content_dir`.
|
|
112:
|
|
113: ## Examples
|
|
114:
|
|
115: iex> OrgGarden.Export.expected_md_path("/notes/bus/emt.org", "/notes", "/out/content")
|
|
116: "/out/content/bus/emt.md"
|
|
117:
|
|
118: iex> OrgGarden.Export.expected_md_path("/notes/top-level.org", "/notes", "/out/content")
|
|
119: "/out/content/top-level.md"
|
|
120: """
|
|
121: @spec expected_md_path(String.t(), String.t(), String.t()) :: String.t()
|
|
122: def expected_md_path(orgfile, notes_dir, content_dir) do
|
|
123: section =
|
|
124: orgfile
|
|
125: |> Path.dirname()
|
|
126: |> Path.relative_to(notes_dir)
|
|
127:
|
|
128: basename = Path.basename(orgfile, ".org") <> ".md"
|
|
129:
|
|
130: case section do
|
|
131: "." -> Path.join(content_dir, basename)
|
|
132: _ -> Path.join([content_dir, section, basename])
|
|
133: end
|
|
134: end
|
|
135: end
|
|
136:
|
|
|
|
(End of file - total 136 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:14:18
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1,5 +1,11 @@
|
|
@spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
|
|
def export_file(orgfile, notes_dir, output_dir) do
|
|
+ OrgGarden.Telemetry.span_export(orgfile, fn ->
|
|
+ do_export_file(orgfile, notes_dir, output_dir)
|
|
+ end)
|
|
+ end
|
|
+
|
|
+ defp do_export_file(orgfile, notes_dir, output_dir) do
|
|
section =
|
|
orgfile
|
|
|> Path.dirname()
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:14:28
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "in_progress",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "pending",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:14:31)
|
|
|
|
Now let's create the release files:
|
|
|
|
### Tool Call [completed]: mkdir -p rel/overlays/bin
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:14:33
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:14:39
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,16 @@
|
|
+#!/bin/sh
|
|
+
|
|
+# Release environment configuration
|
|
+# This file is evaluated before the release starts
|
|
+
|
|
+# Disable Erlang distribution (not needed for this application)
|
|
+export RELEASE_DISTRIBUTION=none
|
|
+
|
|
+# Enable shell history in IEx
|
|
+export ERL_AFLAGS="-kernel shell_history enabled"
|
|
+
|
|
+# Set release node name
|
|
+export RELEASE_NODE=org_garden
|
|
+
|
|
+# Log to console by default
|
|
+export ELIXIR_ERL_OPTIONS="-noshell"
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:14:53
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,74 @@
|
|
+#!/bin/sh
|
|
+set -e
|
|
+
|
|
+# org-garden CLI wrapper
|
|
+# Routes commands to the appropriate release entry point
|
|
+
|
|
+SELF=$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")
|
|
+RELEASE_ROOT=$(cd "$(dirname "$SELF")/.." && pwd)
|
|
+
|
|
+usage() {
|
|
+ cat <<EOF
|
|
+Usage: org-garden <command> [options]
|
|
+
|
|
+Commands:
|
|
+ serve <notes-dir> Start development server (long-running)
|
|
+ build <notes-dir> Build static site (one-shot)
|
|
+ export <notes-dir> Export org to markdown (one-shot)
|
|
+
|
|
+Release commands:
|
|
+ daemon Start as background daemon
|
|
+ daemon_iex Start daemon with IEx attached
|
|
+ stop Stop running daemon
|
|
+ restart Restart running daemon
|
|
+ remote Connect to running instance
|
|
+ pid Print PID of running instance
|
|
+ version Print release version
|
|
+
|
|
+Options:
|
|
+ --port <n> HTTP server port (default: 8080)
|
|
+ --ws-port <n> WebSocket port (default: 3001)
|
|
+ --output <path> Output directory
|
|
+ --watch Watch mode for export command
|
|
+
|
|
+Environment:
|
|
+ NOTES_DIR Default notes directory
|
|
+ OUTPUT_DIR Default output directory
|
|
+ QUARTZ_PATH Path to Quartz installation
|
|
+ ZOTERO_URL Zotero Better BibTeX URL
|
|
+ BIBTEX_FILE Fallback BibTeX file
|
|
+ CITATION_MODE silent | warn | strict
|
|
+EOF
|
|
+}
|
|
+
|
|
+case "${1:-}" in
|
|
+ serve)
|
|
+ shift
|
|
+ exec "$RELEASE_ROOT/bin/org_garden" start -- "$@"
|
|
+ ;;
|
|
+ build)
|
|
+ shift
|
|
+ exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main([\"build\" | System.argv()])" -- "$@"
|
|
+ ;;
|
|
+ export)
|
|
+ shift
|
|
+ exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main([\"export\" | System.argv()])" -- "$@"
|
|
+ ;;
|
|
+ daemon|daemon_iex|stop|restart|remote|pid|version|start|start_iex|eval|rpc)
|
|
+ exec "$RELEASE_ROOT/bin/org_garden" "$@"
|
|
+ ;;
|
|
+ -h|--help|help)
|
|
+ usage
|
|
+ exit 0
|
|
+ ;;
|
|
+ "")
|
|
+ usage
|
|
+ exit 1
|
|
+ ;;
|
|
+ *)
|
|
+ echo "Unknown command: $1" >&2
|
|
+ echo "" >&2
|
|
+ usage >&2
|
|
+ exit 1
|
|
+ ;;
|
|
+esac
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: chmod +x rel/overlays/bin/org-garden
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:14:56
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:15:05
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "in_progress",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "pending",
|
|
"priority": "low"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:15:07)
|
|
|
|
Now let's create the NixOS module:
|
|
|
|
### Tool Call [completed]: mkdir -p nix
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:15:08
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:15:23
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,148 @@
|
|
+{ config, lib, pkgs, ... }:
|
|
+
|
|
+let
|
|
+ cfg = config.services.org-garden;
|
|
+in
|
|
+{
|
|
+ options.services.org-garden = {
|
|
+ enable = lib.mkEnableOption "org-garden publishing service";
|
|
+
|
|
+ package = lib.mkOption {
|
|
+ type = lib.types.package;
|
|
+ description = "The org-garden package to use.";
|
|
+ };
|
|
+
|
|
+ notesDir = lib.mkOption {
|
|
+ type = lib.types.path;
|
|
+ description = "Path to org-roam notes directory.";
|
|
+ };
|
|
+
|
|
+ outputDir = lib.mkOption {
|
|
+ type = lib.types.path;
|
|
+ default = "/var/lib/org-garden";
|
|
+ description = "Output directory for generated content.";
|
|
+ };
|
|
+
|
|
+ port = lib.mkOption {
|
|
+ type = lib.types.port;
|
|
+ default = 8080;
|
|
+ description = "HTTP server port.";
|
|
+ };
|
|
+
|
|
+ wsPort = lib.mkOption {
|
|
+ type = lib.types.port;
|
|
+ default = 3001;
|
|
+ description = "WebSocket hot reload port.";
|
|
+ };
|
|
+
|
|
+ healthPort = lib.mkOption {
|
|
+ type = lib.types.port;
|
|
+ default = 9090;
|
|
+ description = "Health check endpoint port.";
|
|
+ };
|
|
+
|
|
+ zoteroUrl = lib.mkOption {
|
|
+ type = lib.types.str;
|
|
+ default = "http://localhost:23119";
|
|
+ description = "Zotero Better BibTeX URL.";
|
|
+ };
|
|
+
|
|
+ bibtexFile = lib.mkOption {
|
|
+ type = lib.types.nullOr lib.types.path;
|
|
+ default = null;
|
|
+ description = "Path to fallback BibTeX file.";
|
|
+ };
|
|
+
|
|
+ citationMode = lib.mkOption {
|
|
+ type = lib.types.enum [ "silent" "warn" "strict" ];
|
|
+ default = "warn";
|
|
+ description = "Citation resolution failure mode.";
|
|
+ };
|
|
+
|
|
+ user = lib.mkOption {
|
|
+ type = lib.types.str;
|
|
+ default = "org-garden";
|
|
+ description = "User to run the service as.";
|
|
+ };
|
|
+
|
|
+ group = lib.mkOption {
|
|
+ type = lib.types.str;
|
|
+ default = "org-garden";
|
|
+ description = "Group to run the service as.";
|
|
+ };
|
|
+
|
|
+ openFirewall = lib.mkOption {
|
|
+ type = lib.types.bool;
|
|
+ default = false;
|
|
+ description = "Whether to open the firewall for the HTTP port.";
|
|
+ };
|
|
+ };
|
|
+
|
|
+ config = lib.mkIf cfg.enable {
|
|
+ users.users.${cfg.user} = {
|
|
+ isSystemUser = true;
|
|
+ group = cfg.group;
|
|
+ home = cfg.outputDir;
|
|
+ createHome = true;
|
|
+ };
|
|
+
|
|
+ users.groups.${cfg.group} = { };
|
|
+
|
|
+ systemd.services.org-garden = {
|
|
+ description = "Org-Garden Publishing Service";
|
|
+ documentation = [ "https://github.com/ignacio.ballesteros/org-garden" ];
|
|
+ wantedBy = [ "multi-user.target" ];
|
|
+ after = [ "network.target" ];
|
|
+
|
|
+ environment = {
|
|
+ NOTES_DIR = toString cfg.notesDir;
|
|
+ OUTPUT_DIR = cfg.outputDir;
|
|
+ PORT = toString cfg.port;
|
|
+ WS_PORT = toString cfg.wsPort;
|
|
+ HEALTH_PORT = toString cfg.healthPort;
|
|
+ ZOTERO_URL = cfg.zoteroUrl;
|
|
+ CITATION_MODE = cfg.citationMode;
|
|
+ } // lib.optionalAttrs (cfg.bibtexFile != null) {
|
|
+ BIBTEX_FILE = toString cfg.bibtexFile;
|
|
+ };
|
|
+
|
|
+ serviceConfig = {
|
|
+ Type = "exec";
|
|
+ ExecStart = "${cfg.package}/bin/org-garden serve";
|
|
+ Restart = "on-failure";
|
|
+ RestartSec = 5;
|
|
+
|
|
+ # Directories
|
|
+ StateDirectory = "org-garden";
|
|
+ WorkingDirectory = cfg.outputDir;
|
|
+
|
|
+ # User/Group
|
|
+ User = cfg.user;
|
|
+ Group = cfg.group;
|
|
+
|
|
+ # Hardening
|
|
+ NoNewPrivileges = true;
|
|
+ ProtectSystem = "strict";
|
|
+ ProtectHome = "read-only";
|
|
+ ReadWritePaths = [ cfg.outputDir ];
|
|
+ ReadOnlyPaths = [ cfg.notesDir ];
|
|
+ PrivateTmp = true;
|
|
+ PrivateDevices = true;
|
|
+ ProtectKernelTunables = true;
|
|
+ ProtectKernelModules = true;
|
|
+ ProtectControlGroups = true;
|
|
+ RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
|
|
+ RestrictNamespaces = true;
|
|
+ LockPersonality = true;
|
|
+ MemoryDenyWriteExecute = false; # Required for BEAM JIT
|
|
+ RestrictRealtime = true;
|
|
+ RestrictSUIDSGID = true;
|
|
+ RemoveIPC = true;
|
|
+ };
|
|
+ };
|
|
+
|
|
+ networking.firewall = lib.mkIf cfg.openFirewall {
|
|
+ allowedTCPPorts = [ cfg.port ];
|
|
+ };
|
|
+ };
|
|
+}
|
|
```
|
|
## Agent (2026-02-21 20:15:25)
|
|
|
|
Now let's update the flake.nix to include the nixosModules output:
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 20:15:26
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/flake.nix</path>
|
|
<type>file</type>
|
|
<content>1: {
|
|
2: description = "Org-garden — org-roam to website publishing pipeline";
|
|
3:
|
|
4: inputs = {
|
|
5: nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
6: flake-utils.url = "github:numtide/flake-utils";
|
|
7: };
|
|
8:
|
|
9: outputs = { self, nixpkgs, flake-utils }:
|
|
10: flake-utils.lib.eachDefaultSystem (system:
|
|
11: let
|
|
12: pkgs = import nixpkgs { inherit system; };
|
|
13: fs = pkgs.lib.fileset;
|
|
14:
|
|
15: # =========================================================================
|
|
16: # Emacs with ox-hugo
|
|
17: # =========================================================================
|
|
18: # Needed at runtime by the escript (export calls `emacs --batch` with ox-hugo)
|
|
19: emacsWithOxHugo = (pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages
|
|
20: (epkgs: [ epkgs.ox-hugo ]);
|
|
21:
|
|
22: # =========================================================================
|
|
23: # Elixir Pipeline
|
|
24: # =========================================================================
|
|
25:
|
|
26: # Pre-fetched Hex/Mix dependencies
|
|
27: mixDeps = pkgs.beamPackages.fetchMixDeps {
|
|
28: pname = "org-garden-mix-deps";
|
|
29: version = "0.1.0";
|
|
30: src = fs.toSource {
|
|
31: root = ./.;
|
|
32: fileset = fs.unions [
|
|
33: ./mix.exs
|
|
34: ./mix.lock
|
|
35: ];
|
|
36: };
|
|
37: sha256 = "sha256-si7JAomY1HZ33m6ihUJP5i6PO39CE1clYvuMtn0CbPU=";
|
|
38: };
|
|
39:
|
|
40: # Compiled org-garden escript
|
|
41: orgGardenEscript = pkgs.beamPackages.mixRelease {
|
|
42: pname = "org-garden";
|
|
43: version = "0.1.0";
|
|
44: src = fs.toSource {
|
|
45: root = ./.;
|
|
46: fileset = fs.unions [
|
|
47: ./mix.exs
|
|
48: ./mix.lock
|
|
49: ./lib
|
|
50: ];
|
|
51: };
|
|
52: escriptBinName = "org_garden";
|
|
53: mixFodDeps = mixDeps;
|
|
54: stripDebug = true;
|
|
55: };
|
|
56:
|
|
57: # =========================================================================
|
|
58: # Quartz (fetched from upstream, patched)
|
|
59: # =========================================================================
|
|
60:
|
|
61: # Pin to specific upstream commit
|
|
62: quartzVersion = "4.5.2";
|
|
63: quartzRev = "ec00a40aefca73596ab76e3ebe3a8e1129b43688";
|
|
64:
|
|
65: # Fetch upstream Quartz source
|
|
66: quartzSrc = pkgs.fetchFromGitHub {
|
|
67: owner = "jackyzha0";
|
|
68: repo = "quartz";
|
|
69: rev = quartzRev;
|
|
70: hash = "sha256-HdtQB5+SRWiypOvAJuJa3Nodl4JHehp2Mz6Rj5gOG0w=";
|
|
71: };
|
|
72:
|
|
73: # Apply our patches to Quartz
|
|
74: quartzPatched = pkgs.runCommand "quartz-patched-${quartzVersion}" {
|
|
75: src = quartzSrc;
|
|
76: } ''
|
|
77: cp -r $src $out
|
|
78: chmod -R u+w $out
|
|
79: cd $out
|
|
80: patch -p1 < ${./patches/01-glob-gitignore.patch}
|
|
81: patch -p1 < ${./patches/02-build-gitignore.patch}
|
|
82: patch -p1 < ${./patches/03-static-hugo.patch}
|
|
83: patch -p1 < ${./patches/04-oxhugofm-figure.patch}
|
|
84: '';
|
|
85:
|
|
86: # Pre-fetch Quartz npm dependencies
|
|
87: quartzDeps = pkgs.buildNpmPackage {
|
|
88: pname = "org-garden-quartz-deps";
|
|
89: version = quartzVersion;
|
|
90: src = quartzPatched;
|
|
91: npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4=";
|
|
92: dontBuild = true;
|
|
93: installPhase = ''
|
|
94: mkdir -p $out
|
|
95: cp -r node_modules $out/node_modules
|
|
96: '';
|
|
97: };
|
|
98:
|
|
99: # =========================================================================
|
|
100: # Combined Application
|
|
101: # =========================================================================
|
|
102:
|
|
103: # Wrapped org-garden with Quartz bundled
|
|
104: orgGardenApp = pkgs.writeShellApplication {
|
|
105: name = "org-garden";
|
|
106: runtimeInputs = [ emacsWithOxHugo pkgs.inotify-tools pkgs.nodejs_22 ];
|
|
107: text = ''
|
|
108: # Set up Quartz working directory
|
|
109: QUARTZ_WORK=$(mktemp -d)
|
|
110: trap 'rm -rf "$QUARTZ_WORK"' EXIT
|
|
111:
|
|
112: # Copy patched Quartz source
|
|
113: cp -r ${quartzPatched}/. "$QUARTZ_WORK/"
|
|
114: chmod -R u+w "$QUARTZ_WORK"
|
|
115:
|
|
116: # Copy default config files
|
|
117: cp ${./quartz-config/quartz.config.ts} "$QUARTZ_WORK/"
|
|
118: cp ${./quartz-config/quartz.layout.ts} "$QUARTZ_WORK/"
|
|
119: cp ${./quartz-config/globals.d.ts} "$QUARTZ_WORK/"
|
|
120: cp ${./quartz-config/index.d.ts} "$QUARTZ_WORK/"
|
|
121:
|
|
122: # Link pre-built node_modules
|
|
123: ln -s ${quartzDeps}/node_modules "$QUARTZ_WORK/node_modules"
|
|
124:
|
|
125: export QUARTZ_PATH="$QUARTZ_WORK"
|
|
126: export NODE_PATH="${pkgs.nodejs_22}/bin/node"
|
|
127:
|
|
128: exec ${orgGardenEscript}/bin/org_garden "$@"
|
|
129: '';
|
|
130: };
|
|
131:
|
|
132: in
|
|
133: {
|
|
134: packages.default = orgGardenApp;
|
|
135: packages.escript = orgGardenEscript;
|
|
136: packages.quartz-patched = quartzPatched;
|
|
137:
|
|
138: devShells.default = pkgs.mkShell {
|
|
139: buildInputs = [
|
|
140: pkgs.elixir
|
|
141: pkgs.inotify-tools
|
|
142: emacsWithOxHugo
|
|
143: pkgs.nodejs_22
|
|
144: ];
|
|
145: };
|
|
146: });
|
|
147: }
|
|
148:
|
|
|
|
(End of file - total 148 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:15:49
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,158 @@
|
|
+{
|
|
+ description = "Org-garden — org-roam to website publishing pipeline";
|
|
+
|
|
+ inputs = {
|
|
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
+ flake-utils.url = "github:numtide/flake-utils";
|
|
+ };
|
|
+
|
|
+ outputs = { self, nixpkgs, flake-utils }:
|
|
+ let
|
|
+ # System-independent outputs
|
|
+ nixosModule = import ./nix/module.nix;
|
|
+ in
|
|
+ {
|
|
+ # NixOS module (system-independent)
|
|
+ nixosModules.default = nixosModule;
|
|
+ nixosModules.org-garden = nixosModule;
|
|
+ } //
|
|
+ flake-utils.lib.eachDefaultSystem (system:
|
|
+ let
|
|
+ pkgs = import nixpkgs { inherit system; };
|
|
+ fs = pkgs.lib.fileset;
|
|
+
|
|
+ # =========================================================================
|
|
+ # Emacs with ox-hugo
|
|
+ # =========================================================================
|
|
+ # Needed at runtime by the escript (export calls `emacs --batch` with ox-hugo)
|
|
+ emacsWithOxHugo = (pkgs.emacsPackagesFor pkgs.emacs-nox).emacsWithPackages
|
|
+ (epkgs: [ epkgs.ox-hugo ]);
|
|
+
|
|
+ # =========================================================================
|
|
+ # Elixir Pipeline
|
|
+ # =========================================================================
|
|
+
|
|
+ # Pre-fetched Hex/Mix dependencies
|
|
+ mixDeps = pkgs.beamPackages.fetchMixDeps {
|
|
+ pname = "org-garden-mix-deps";
|
|
+ version = "0.1.0";
|
|
+ src = fs.toSource {
|
|
+ root = ./.;
|
|
+ fileset = fs.unions [
|
|
+ ./mix.exs
|
|
+ ./mix.lock
|
|
+ ];
|
|
+ };
|
|
+ sha256 = "sha256-si7JAomY1HZ33m6ihUJP5i6PO39CE1clYvuMtn0CbPU=";
|
|
+ };
|
|
+
|
|
+ # Compiled org-garden escript
|
|
+ orgGardenEscript = pkgs.beamPackages.mixRelease {
|
|
+ pname = "org-garden";
|
|
+ version = "0.1.0";
|
|
+ src = fs.toSource {
|
|
+ root = ./.;
|
|
+ fileset = fs.unions [
|
|
+ ./mix.exs
|
|
+ ./mix.lock
|
|
+ ./lib
|
|
+ ./config
|
|
+ ./rel
|
|
+ ];
|
|
+ };
|
|
+ escriptBinName = "org_garden";
|
|
+ mixFodDeps = mixDeps;
|
|
+ stripDebug = true;
|
|
+ };
|
|
+
|
|
+ # =========================================================================
|
|
+ # Quartz (fetched from upstream, patched)
|
|
+ # =========================================================================
|
|
+
|
|
+ # Pin to specific upstream commit
|
|
+ quartzVersion = "4.5.2";
|
|
+ quartzRev = "ec00a40aefca73596ab76e3ebe3a8e1129b43688";
|
|
+
|
|
+ # Fetch upstream Quartz source
|
|
+ quartzSrc = pkgs.fetchFromGitHub {
|
|
+ owner = "jackyzha0";
|
|
+ repo = "quartz";
|
|
+ rev = quartzRev;
|
|
+ hash = "sha256-HdtQB5+SRWiypOvAJuJa3Nodl4JHehp2Mz6Rj5gOG0w=";
|
|
+ };
|
|
+
|
|
+ # Apply our patches to Quartz
|
|
+ quartzPatched = pkgs.runCommand "quartz-patched-${quartzVersion}" {
|
|
+ src = quartzSrc;
|
|
+ } ''
|
|
+ cp -r $src $out
|
|
+ chmod -R u+w $out
|
|
+ cd $out
|
|
+ patch -p1 < ${./patches/01-glob-gitignore.patch}
|
|
+ patch -p1 < ${./patches/02-build-gitignore.patch}
|
|
+ patch -p1 < ${./patches/03-static-hugo.patch}
|
|
+ patch -p1 < ${./patches/04-oxhugofm-figure.patch}
|
|
+ '';
|
|
+
|
|
+ # Pre-fetch Quartz npm dependencies
|
|
+ quartzDeps = pkgs.buildNpmPackage {
|
|
+ pname = "org-garden-quartz-deps";
|
|
+ version = quartzVersion;
|
|
+ src = quartzPatched;
|
|
+ npmDepsHash = "sha256-7u+VlIx44B3/ivM9vLMIOn+e4TL4eS6B682vhS+Ikb4=";
|
|
+ dontBuild = true;
|
|
+ installPhase = ''
|
|
+ mkdir -p $out
|
|
+ cp -r node_modules $out/node_modules
|
|
+ '';
|
|
+ };
|
|
+
|
|
+ # =========================================================================
|
|
+ # Combined Application
|
|
+ # =========================================================================
|
|
+
|
|
+ # Wrapped org-garden with Quartz bundled
|
|
+ orgGardenApp = pkgs.writeShellApplication {
|
|
+ name = "org-garden";
|
|
+ runtimeInputs = [ emacsWithOxHugo pkgs.inotify-tools pkgs.nodejs_22 ];
|
|
+ text = ''
|
|
+ # Set up Quartz working directory
|
|
+ QUARTZ_WORK=$(mktemp -d)
|
|
+ trap 'rm -rf "$QUARTZ_WORK"' EXIT
|
|
+
|
|
+ # Copy patched Quartz source
|
|
+ cp -r ${quartzPatched}/. "$QUARTZ_WORK/"
|
|
+ chmod -R u+w "$QUARTZ_WORK"
|
|
+
|
|
+ # Copy default config files
|
|
+ cp ${./quartz-config/quartz.config.ts} "$QUARTZ_WORK/"
|
|
+ cp ${./quartz-config/quartz.layout.ts} "$QUARTZ_WORK/"
|
|
+ cp ${./quartz-config/globals.d.ts} "$QUARTZ_WORK/"
|
|
+ cp ${./quartz-config/index.d.ts} "$QUARTZ_WORK/"
|
|
+
|
|
+ # Link pre-built node_modules
|
|
+ ln -s ${quartzDeps}/node_modules "$QUARTZ_WORK/node_modules"
|
|
+
|
|
+ export QUARTZ_PATH="$QUARTZ_WORK"
|
|
+ export NODE_PATH="${pkgs.nodejs_22}/bin/node"
|
|
+
|
|
+ exec ${orgGardenEscript}/bin/org_garden "$@"
|
|
+ '';
|
|
+ };
|
|
+
|
|
+ in
|
|
+ {
|
|
+ packages.default = orgGardenApp;
|
|
+ packages.escript = orgGardenEscript;
|
|
+ packages.quartz-patched = quartzPatched;
|
|
+
|
|
+ devShells.default = pkgs.mkShell {
|
|
+ buildInputs = [
|
|
+ pkgs.elixir
|
|
+ pkgs.inotify-tools
|
|
+ emacsWithOxHugo
|
|
+ pkgs.nodejs_22
|
|
+ ];
|
|
+ };
|
|
+ });
|
|
+}
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:15:59
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "completed",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "completed",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Verify compilation",
|
|
"status": "in_progress",
|
|
"priority": "high"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:16:01)
|
|
|
|
Now let's verify the code compiles. First, we need to get the updated dependencies:
|
|
|
|
### Tool Call [completed]: cd /home/ignaciobll/code/org-garden && mix deps.get
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:16:03
|
|
|
|
```
|
|
zsh:1: command not found: mix
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: nix develop --command mix deps.get
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:16:11
|
|
|
|
```
|
|
warning: Git tree '/home/ignaciobll/code/org-garden' is dirty
|
|
Resolving Hex dependencies...
|
|
Resolution completed in 0.056s
|
|
New:
|
|
bandit 1.10.2
|
|
plug 1.19.1
|
|
plug_crypto 2.1.1
|
|
thousand_island 1.4.3
|
|
websock 0.5.3
|
|
Unchanged:
|
|
file_system 1.1.1
|
|
finch 0.21.0
|
|
hpax 1.0.3
|
|
jason 1.4.4
|
|
mime 2.0.7
|
|
mint 1.7.1
|
|
nimble_options 1.1.1
|
|
nimble_pool 1.1.0
|
|
req 0.5.17
|
|
telemetry 1.3.0
|
|
* Getting finch (Hex package)
|
|
* Getting req (Hex package)
|
|
* Getting jason (Hex package)
|
|
* Getting file_system (Hex package)
|
|
* Getting bandit (Hex package)
|
|
* Getting plug (Hex package)
|
|
* Getting telemetry (Hex package)
|
|
* Getting mime (Hex package)
|
|
* Getting plug_crypto (Hex package)
|
|
* Getting hpax (Hex package)
|
|
* Getting thousand_island (Hex package)
|
|
* Getting websock (Hex package)
|
|
* Getting mint (Hex package)
|
|
* Getting nimble_options (Hex package)
|
|
* Getting nimble_pool (Hex package)
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: nix develop --command mix compile
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:16:23
|
|
|
|
```
|
|
warning: Git tree '/home/ignaciobll/code/org-garden' is dirty
|
|
==> file_system
|
|
Compiling 7 files (.ex)
|
|
Generated file_system app
|
|
==> mime
|
|
Compiling 1 file (.ex)
|
|
Generated mime app
|
|
==> nimble_options
|
|
Compiling 3 files (.ex)
|
|
Generated nimble_options app
|
|
==> org_garden
|
|
===> Analyzing applications...
|
|
===> Compiling telemetry
|
|
==> thousand_island
|
|
Compiling 18 files (.ex)
|
|
Generated thousand_island app
|
|
==> jason
|
|
Compiling 10 files (.ex)
|
|
Generated jason app
|
|
==> plug_crypto
|
|
Compiling 5 files (.ex)
|
|
Generated plug_crypto app
|
|
==> hpax
|
|
Compiling 4 files (.ex)
|
|
Generated hpax app
|
|
==> mint
|
|
Compiling 1 file (.erl)
|
|
Compiling 20 files (.ex)
|
|
Generated mint app
|
|
==> plug
|
|
Compiling 1 file (.erl)
|
|
Compiling 42 files (.ex)
|
|
Generated plug app
|
|
==> nimble_pool
|
|
Compiling 2 files (.ex)
|
|
Generated nimble_pool app
|
|
==> finch
|
|
Compiling 14 files (.ex)
|
|
Generated finch app
|
|
==> req
|
|
Compiling 19 files (.ex)
|
|
Generated req app
|
|
==> websock
|
|
Compiling 1 file (.ex)
|
|
Generated websock app
|
|
==> bandit
|
|
Compiling 54 files (.ex)
|
|
Generated bandit app
|
|
==> org_garden
|
|
Compiling 17 files (.ex)
|
|
warning: defp do_match/4 is private, @doc attribute is always discarded for private functions/macros/types
|
|
│
|
|
19 │ @doc """
|
|
│ ~~~~~~~~
|
|
│
|
|
└─ lib/org_garden/health.ex:19: OrgGarden.Health.do_match/4
|
|
|
|
warning: defp do_match/4 is private, @doc attribute is always discarded for private functions/macros/types
|
|
│
|
|
26 │ @doc """
|
|
│ ~~~~~~~~
|
|
│
|
|
└─ lib/org_garden/health.ex:26: OrgGarden.Health.do_match/4
|
|
|
|
warning: defp do_match/4 is private, @doc attribute is always discarded for private functions/macros/types
|
|
│
|
|
40 │ @doc """
|
|
│ ~~~~~~~~
|
|
│
|
|
└─ lib/org_garden/health.ex:40: OrgGarden.Health.do_match/4
|
|
|
|
Generated org_garden app
|
|
```
|
|
## Agent (2026-02-21 20:16:25)
|
|
|
|
The code compiles. Let me fix those warnings in the health module:
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:16:28
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1,14 +1,10 @@
|
|
plug(:match)
|
|
plug(:dispatch)
|
|
|
|
- @doc """
|
|
- Liveness probe — is the process alive?
|
|
- """
|
|
+ # Liveness probe — is the process alive?
|
|
get "/health/live" do
|
|
send_resp(conn, 200, "ok")
|
|
end
|
|
|
|
- @doc """
|
|
- Readiness probe — are all components ready to serve?
|
|
- """
|
|
+ # Readiness probe — are all components ready to serve?
|
|
get "/health/ready" do
|
|
\ No newline at end of file
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:16:31
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1,7 +1,5 @@
|
|
end
|
|
end
|
|
|
|
- @doc """
|
|
- Full health status as JSON.
|
|
- """
|
|
+ # Full health status as JSON
|
|
get "/health" do
|
|
\ No newline at end of file
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: nix develop --command mix compile --warnings-as-errors
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:16:37
|
|
|
|
```
|
|
warning: Git tree '/home/ignaciobll/code/org-garden' is dirty
|
|
Compiling 1 file (.ex)
|
|
Generated org_garden app
|
|
```
|
|
## Agent (2026-02-21 20:16:40)
|
|
|
|
Now let's add a `.formatter.exs` file for consistent formatting:
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:16:41
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,3 @@
|
|
+[
|
|
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
|
+]
|
|
```
|
|
## Agent (2026-02-21 20:16:43)
|
|
|
|
Let's also update the README.org to document the new service features:
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 20:16:44
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/README.org</path>
|
|
<type>file</type>
|
|
<content>1: #+title: org-garden
|
|
2:
|
|
3: An [[https://orgmode.org/][org-roam]] to static website publishing pipeline. Converts =.org= notes into a rendered site using Emacs/[[https://ox-hugo.scripter.co/][ox-hugo]] for export and [[https://quartz.jzhao.xyz/][Quartz 4]] for site generation.
|
|
4:
|
|
5: * Usage
|
|
6:
|
|
7: #+begin_example
|
|
8: org-garden serve <notes-dir> # dev server with live reload
|
|
9: org-garden build <notes-dir> # production static build
|
|
10: org-garden export <notes-dir> # org → markdown only
|
|
11: #+end_example
|
|
12:
|
|
13: * Running with Nix (recommended)
|
|
14:
|
|
15: #+begin_src sh
|
|
16: nix run . -- serve <notes-dir>
|
|
17: nix run . -- build <notes-dir>
|
|
18: #+end_src
|
|
19:
|
|
20: * Running with Mix
|
|
21:
|
|
22: #+begin_src sh
|
|
23: mix deps.get
|
|
24: mix escript.build
|
|
25: ./org_garden serve <notes-dir>
|
|
26: #+end_src
|
|
27:
|
|
28: Requires =QUARTZ_PATH= to point to a Quartz install with =node_modules= for =serve= and =build= commands.
|
|
29:
|
|
|
|
(End of file - total 29 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: write
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:16:57
|
|
|
|
```
|
|
Wrote file successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -0,0 +1,77 @@
|
|
+#+title: org-garden
|
|
+
|
|
+An [[https://orgmode.org/][org-roam]] to static website publishing pipeline. Converts =.org= notes into a rendered site using Emacs/[[https://ox-hugo.scripter.co/][ox-hugo]] for export and [[https://quartz.jzhao.xyz/][Quartz 4]] for site generation.
|
|
+
|
|
+* Usage
|
|
+
|
|
+#+begin_example
|
|
+org-garden serve <notes-dir> # dev server with live reload
|
|
+org-garden build <notes-dir> # production static build
|
|
+org-garden export <notes-dir> # org -> markdown only
|
|
+#+end_example
|
|
+
|
|
+* Running with Nix (recommended)
|
|
+
|
|
+#+begin_src sh
|
|
+nix run . -- serve <notes-dir>
|
|
+nix run . -- build <notes-dir>
|
|
+#+end_src
|
|
+
|
|
+* Running with Mix
|
|
+
|
|
+#+begin_src sh
|
|
+mix deps.get
|
|
+mix escript.build
|
|
+./org_garden serve <notes-dir>
|
|
+#+end_src
|
|
+
|
|
+Requires =QUARTZ_PATH= to point to a Quartz install with =node_modules= for =serve= and =build= commands.
|
|
+
|
|
+* NixOS Service
|
|
+
|
|
+A NixOS module is provided for running org-garden as a systemd service:
|
|
+
|
|
+#+begin_src nix
|
|
+{
|
|
+ inputs.org-garden.url = "github:ignacio.ballesteros/org-garden";
|
|
+
|
|
+ outputs = { self, nixpkgs, org-garden }: {
|
|
+ nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
|
+ modules = [
|
|
+ org-garden.nixosModules.default
|
|
+ {
|
|
+ services.org-garden = {
|
|
+ enable = true;
|
|
+ package = org-garden.packages.x86_64-linux.default;
|
|
+ notesDir = /path/to/notes;
|
|
+ port = 8080;
|
|
+ };
|
|
+ }
|
|
+ ];
|
|
+ };
|
|
+ };
|
|
+}
|
|
+#+end_src
|
|
+
|
|
+* Health Checks
|
|
+
|
|
+When running in serve mode, health endpoints are available on port 9090:
|
|
+
|
|
+- =GET /health/live= — liveness probe (always 200)
|
|
+- =GET /health/ready= — readiness probe (200 if all components ready)
|
|
+- =GET /health= — JSON status of all components
|
|
+
|
|
+* Environment Variables
|
|
+
|
|
+| Variable | Default | Description |
|
|
+|----------------+---------------------------+----------------------------------|
|
|
+| =QUARTZ_PATH= | (required for serve/build)| Path to Quartz installation |
|
|
+| =NODE_PATH= | =node= | Node.js executable |
|
|
+| =NOTES_DIR= | (cli arg) | Source notes directory |
|
|
+| =OUTPUT_DIR= | =.= | Output base directory |
|
|
+| =ZOTERO_URL= | =http://localhost:23119= | Zotero Better BibTeX URL |
|
|
+| =BIBTEX_FILE= | (none) | Fallback BibTeX file |
|
|
+| =CITATION_MODE=| =warn= | =silent=, =warn=, or =strict= |
|
|
+| =PORT= | =8080= | HTTP server port |
|
|
+| =WS_PORT= | =3001= | WebSocket hot reload port |
|
|
+| =HEALTH_PORT= | =9090= | Health check endpoint port |
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: todowrite
|
|
|
|
**Tool:** other
|
|
**Timestamp:** 2026-02-21 20:17:08
|
|
|
|
```
|
|
[
|
|
{
|
|
"content": "Phase 2: Create config/config.exs with defaults",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create config/runtime.exs for env vars",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 2: Create lib/org_garden/config.ex module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Refactor application.ex with DynamicSupervisor",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Create lib/org_garden/server.ex",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 1: Update cli.ex to use Server module",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update quartz.ex for graceful shutdown",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 3: Update watcher.ex with drain period",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
},
|
|
{
|
|
"content": "Phase 5: Create lib/org_garden/telemetry.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 5: Add telemetry calls to export.ex and watcher.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Add bandit/plug deps to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 4: Create lib/org_garden/health.ex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Add releases config to mix.exs",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/env.sh.eex",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 6: Create rel/overlays/bin/org-garden",
|
|
"status": "completed",
|
|
"priority": "medium"
|
|
},
|
|
{
|
|
"content": "Phase 7: Create nix/module.nix",
|
|
"status": "completed",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Phase 7: Update flake.nix with nixosModules",
|
|
"status": "completed",
|
|
"priority": "low"
|
|
},
|
|
{
|
|
"content": "Verify compilation",
|
|
"status": "completed",
|
|
"priority": "high"
|
|
}
|
|
]
|
|
```
|
|
## Agent (2026-02-21 20:17:10)
|
|
|
|
All phases are complete. Let me provide a summary of what was implemented:
|
|
|
|
---
|
|
|
|
## Implementation Summary
|
|
|
|
All 7 phases have been implemented successfully. Here's what was added/changed:
|
|
|
|
### New Files Created
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `config/config.exs` | Base configuration with defaults |
|
|
| `config/runtime.exs` | Runtime configuration from env vars |
|
|
| `config/dev.exs` | Development-specific config |
|
|
| `config/prod.exs` | Production-specific config |
|
|
| `config/test.exs` | Test-specific config |
|
|
| `lib/org_garden/config.ex` | Centralized config access with validation |
|
|
| `lib/org_garden/server.ex` | Service mode lifecycle management |
|
|
| `lib/org_garden/health.ex` | Health check HTTP endpoints |
|
|
| `lib/org_garden/telemetry.ex` | Telemetry events and logging handler |
|
|
| `rel/env.sh.eex` | Release environment configuration |
|
|
| `rel/overlays/bin/org-garden` | CLI wrapper script for releases |
|
|
| `nix/module.nix` | NixOS service module |
|
|
| `.formatter.exs` | Code formatting configuration |
|
|
|
|
### Modified Files
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `lib/org_garden/application.ex` | Added Registry, DynamicSupervisor, Health server |
|
|
| `lib/org_garden/cli.ex` | Uses Server module, Config, proper process monitoring |
|
|
| `lib/org_garden/quartz.ex` | Graceful shutdown with SIGTERM, OS PID tracking |
|
|
| `lib/org_garden/watcher.ex` | Drain period, telemetry events, trap_exit |
|
|
| `lib/org_garden/export.ex` | Telemetry span wrapping |
|
|
| `mix.exs` | Added bandit, plug, telemetry deps + releases config |
|
|
| `flake.nix` | Added nixosModules output, config/rel in fileset |
|
|
| `README.org` | Documented NixOS service, health checks, env vars |
|
|
|
|
### Architecture Changes
|
|
|
|
```
|
|
OrgGarden.AppSupervisor (:one_for_one)
|
|
├── Finch (HTTP client pool)
|
|
├── Registry (process registry)
|
|
├── DynamicSupervisor (for serve/export workers)
|
|
│ └── OrgGarden.Supervisor (when serve mode)
|
|
│ ├── Watcher
|
|
│ └── Quartz
|
|
└── Bandit (Health check HTTP server on :9090)
|
|
```
|
|
|
|
### Key Features Added
|
|
|
|
1. **Proper supervision** — All processes under supervision tree
|
|
2. **Graceful shutdown** — SIGTERM handling, drain periods
|
|
3. **Health checks** — `/health/live`, `/health/ready`, `/health`
|
|
4. **Configuration system** — `config/*.exs` + `OrgGarden.Config`
|
|
5. **Telemetry** — Events for export, watcher operations
|
|
6. **Mix Release support** — `rel/` directory with overlay scripts
|
|
7. **NixOS module** — Systemd service with hardening options
|
|
|
|
The project now compiles cleanly with `mix compile --warnings-as-errors`.
|
|
|
|
## User (2026-02-21 20:38:14)
|
|
|
|
I have this error: nix build
|
|
warning: Git tree '/home/ignaciobll/code/org-garden' is dirty
|
|
error:
|
|
… while calling the 'derivationStrict' builtin
|
|
at <nix/derivation-internal.nix>:37:12:
|
|
36|
|
|
37| strict = derivationStrict drvAttrs;
|
|
| ^
|
|
38|
|
|
|
|
… while evaluating derivation 'org-garden'
|
|
whose name attribute is located at /nix/store/dx2qikyb4dyb6hbdfywbmsyla0z5a1h3-source/pkgs/stdenv/generic/make-derivation.nix:537:13
|
|
|
|
… while evaluating attribute 'text' of derivation 'org-garden'
|
|
at /nix/store/dx2qikyb4dyb6hbdfywbmsyla0z5a1h3-source/pkgs/build-support/trivial-builders/default.nix:130:13:
|
|
129| inherit
|
|
130| text
|
|
| ^
|
|
131| executable
|
|
|
|
(stack trace truncated; use '--show-trace' to show the full, detailed trace)
|
|
|
|
error: lib.fileset.unions: Element 3 (/nix/store/m2vsx2i50aqw7wn8z3xy0zxg7j1ddy0l-source/config) is a path that does not exist.
|
|
To create a file set from a path that may not exist, use `lib.fileset.maybeMissing`.
|
|
|
|
## Agent (2026-02-21 20:38:18)
|
|
|
|
The error is because the Nix build uses a clean git tree (only committed files), but we haven't committed the new `config/` directory yet. The `flake.nix` references `./config` which doesn't exist in the committed state.
|
|
|
|
Let me check git status and fix this:
|
|
|
|
### Tool Call [completed]: git status
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:38:20
|
|
|
|
```
|
|
On branch main
|
|
Your branch is up to date with 'origin/main'.
|
|
|
|
Changes to be committed:
|
|
(use "git restore --staged <file>..." to unstage)
|
|
modified: README.org
|
|
modified: flake.nix
|
|
modified: lib/org_garden/application.ex
|
|
modified: lib/org_garden/cli.ex
|
|
modified: lib/org_garden/export.ex
|
|
modified: lib/org_garden/quartz.ex
|
|
modified: lib/org_garden/watcher.ex
|
|
modified: mix.exs
|
|
modified: mix.lock
|
|
new file: nix/module.nix
|
|
new file: rel/env.sh.eex
|
|
new file: rel/overlays/bin/org-garden
|
|
|
|
Changes not staged for commit:
|
|
(use "git add <file>..." to update what will be committed)
|
|
(use "git restore <file>..." to discard changes in working directory)
|
|
modified: nix/module.nix
|
|
|
|
Untracked files:
|
|
(use "git add <file>..." to include in what will be committed)
|
|
.agent-shell/
|
|
.formatter.exs
|
|
config/
|
|
```
|
|
## Agent (2026-02-21 20:38:22)
|
|
|
|
The `config/` directory and several other files are not staged. Let me add them all and also update the flake.nix to use `maybeMissing` for safety, then we can commit:
|
|
|
|
### Tool Call [completed]: git add config/ .formatter.exs lib/org_garden/config.ex lib/org_garden/server.ex lib/org_garden/health.ex lib/org_garden/telemetry.ex nix/module.nix .gitignore
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:38:25
|
|
|
|
```
|
|
The following paths are ignored by one of your .gitignore files:
|
|
lib/org_garden
|
|
hint: Use -f if you really want to add them.
|
|
hint: Disable this message with "git config set advice.addIgnoredFile false"
|
|
```
|
|
## Agent (2026-02-21 20:38:27)
|
|
|
|
There's a conflict with the `.gitignore`. Let me check what's happening:
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 20:38:29
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/.gitignore</path>
|
|
<type>file</type>
|
|
<content>1: # Elixir
|
|
2: /_build/
|
|
3: /deps/
|
|
4: *.beam
|
|
5: *.ez
|
|
6: /cover/
|
|
7: /doc/
|
|
8: .fetch
|
|
9: erl_crash.dump
|
|
10: *.tar
|
|
11: /tmp/
|
|
12: org_garden
|
|
13:
|
|
14: # Nix
|
|
15: result
|
|
16: result-*
|
|
17: .direnv/
|
|
18:
|
|
19: # TypeScript / Node
|
|
20: node_modules/
|
|
21: dist/
|
|
22: .npm/
|
|
23: *.tsbuildinfo
|
|
24:
|
|
25: # This repo - generated output
|
|
26: content/
|
|
27: static/
|
|
28:
|
|
|
|
(End of file - total 28 lines)
|
|
</content>
|
|
```
|
|
## Agent (2026-02-21 20:38:31)
|
|
|
|
The `org_garden` entry on line 12 is matching `lib/org_garden/` directory. Let me fix that:
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:38:33
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1,3 +1,3 @@
|
|
*.tar
|
|
/tmp/
|
|
-org_garden
|
|
\ No newline at end of file
|
|
+/org_garden
|
|
\ No newline at end of file
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: git add config/ .formatter.exs lib/org_garden/config.ex lib/org_garden/server.ex lib/org_garden/health.ex lib/org_garden/telemetry.ex nix/module.nix .gitignore
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:38:36
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: git status
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:38:39
|
|
|
|
```
|
|
On branch main
|
|
Your branch is up to date with 'origin/main'.
|
|
|
|
Changes to be committed:
|
|
(use "git restore --staged <file>..." to unstage)
|
|
new file: .formatter.exs
|
|
modified: .gitignore
|
|
modified: README.org
|
|
new file: config/config.exs
|
|
new file: config/dev.exs
|
|
new file: config/prod.exs
|
|
new file: config/runtime.exs
|
|
new file: config/test.exs
|
|
modified: flake.nix
|
|
modified: lib/org_garden/application.ex
|
|
modified: lib/org_garden/cli.ex
|
|
new file: lib/org_garden/config.ex
|
|
modified: lib/org_garden/export.ex
|
|
new file: lib/org_garden/health.ex
|
|
modified: lib/org_garden/quartz.ex
|
|
new file: lib/org_garden/server.ex
|
|
new file: lib/org_garden/telemetry.ex
|
|
modified: lib/org_garden/watcher.ex
|
|
modified: mix.exs
|
|
modified: mix.lock
|
|
new file: nix/module.nix
|
|
new file: rel/env.sh.eex
|
|
new file: rel/overlays/bin/org-garden
|
|
|
|
Untracked files:
|
|
(use "git add <file>..." to include in what will be committed)
|
|
.agent-shell/
|
|
```
|
|
## Agent (2026-02-21 20:38:42)
|
|
|
|
All the necessary files are staged. Now let's commit:
|
|
|
|
### Tool Call [completed]: git commit -m "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"
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:38:47
|
|
|
|
```
|
|
[main 01805db] Add service infrastructure for long-running deployment
|
|
23 files changed, 1147 insertions(+), 83 deletions(-)
|
|
create mode 100644 .formatter.exs
|
|
create mode 100644 config/config.exs
|
|
create mode 100644 config/dev.exs
|
|
create mode 100644 config/prod.exs
|
|
create mode 100644 config/runtime.exs
|
|
create mode 100644 config/test.exs
|
|
create mode 100644 lib/org_garden/config.ex
|
|
create mode 100644 lib/org_garden/health.ex
|
|
create mode 100644 lib/org_garden/server.ex
|
|
create mode 100644 lib/org_garden/telemetry.ex
|
|
create mode 100644 nix/module.nix
|
|
create mode 100644 rel/env.sh.eex
|
|
create mode 100755 rel/overlays/bin/org-garden
|
|
```
|
|
## Agent (2026-02-21 20:38:49)
|
|
|
|
Now let's try the nix build again:
|
|
|
|
### Tool Call [completed]: nix build --show-trace 2>&1 | head -100
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:38:59
|
|
|
|
```
|
|
these 2 derivations will be built:
|
|
/nix/store/bnlwr4jm6cwz50rkxhhk1vjcafn0x8va-org-garden-0.1.0.drv
|
|
/nix/store/ifxi8iam6cnm8z79adk1n9c1si6a23n5-org-garden.drv
|
|
building '/nix/store/bnlwr4jm6cwz50rkxhhk1vjcafn0x8va-org-garden-0.1.0.drv'...
|
|
error: Cannot build '/nix/store/bnlwr4jm6cwz50rkxhhk1vjcafn0x8va-org-garden-0.1.0.drv'.
|
|
Reason: builder failed with exit code 1.
|
|
Output paths:
|
|
/nix/store/d7z0nnpx126shbzgh7imx7phjcv99pla-org-garden-0.1.0
|
|
Last 25 log lines:
|
|
> ===> Running hooks for compile in app telemetry (/build/deps/telemetry) with configuration:
|
|
> ===> {post_hooks, []}.
|
|
> ==> nimble_pool
|
|
> Compiling 2 files (.ex)
|
|
> Generated nimble_pool app
|
|
> ==> finch
|
|
> Compiling 14 files (.ex)
|
|
> Generated finch app
|
|
> ==> req
|
|
> Compiling 19 files (.ex)
|
|
> Generated req app
|
|
> Running phase: buildPhase
|
|
> Compiling 17 files (.ex)
|
|
> error: module Plug.Router is not loaded and could not be found
|
|
> │
|
|
> 14 │ use Plug.Router
|
|
> │ ^^^^^^^^^^^^^^^
|
|
> │
|
|
> └─ lib/org_garden/health.ex:14: OrgGarden.Health (module)
|
|
>
|
|
>
|
|
> == Compilation error in file lib/org_garden/health.ex ==
|
|
> ** (CompileError) lib/org_garden/health.ex: cannot compile module OrgGarden.Health (errors have been logged)
|
|
> (elixir 1.18.4) expanding macro: Kernel.use/1
|
|
> lib/org_garden/health.ex:14: OrgGarden.Health (module)
|
|
For full logs, run:
|
|
nix log /nix/store/bnlwr4jm6cwz50rkxhhk1vjcafn0x8va-org-garden-0.1.0.drv
|
|
error: Cannot build '/nix/store/ifxi8iam6cnm8z79adk1n9c1si6a23n5-org-garden.drv'.
|
|
Reason: 1 dependency failed.
|
|
Output paths:
|
|
/nix/store/pkyz1q8y8mk60dr2lnannqd1ha0h3lnx-org-garden
|
|
```
|
|
## Agent (2026-02-21 20:39:00)
|
|
|
|
The issue is that the mix.lock has changed (new dependencies like bandit/plug) but the Nix `mixDeps` hash is stale. We need to update the hash in flake.nix. Let me check the current mix.lock:
|
|
|
|
### Tool Call [completed]: nix develop --command mix deps.get 2>&1 | tail -20
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:06
|
|
|
|
```
|
|
Resolving Hex dependencies...
|
|
Resolution completed in 0.032s
|
|
Unchanged:
|
|
bandit 1.10.2
|
|
file_system 1.1.1
|
|
finch 0.21.0
|
|
hpax 1.0.3
|
|
jason 1.4.4
|
|
mime 2.0.7
|
|
mint 1.7.1
|
|
nimble_options 1.1.1
|
|
nimble_pool 1.1.0
|
|
plug 1.19.1
|
|
plug_crypto 2.1.1
|
|
req 0.5.17
|
|
telemetry 1.3.0
|
|
thousand_island 1.4.3
|
|
websock 0.5.3
|
|
All dependencies are up to date
|
|
```
|
|
## Agent (2026-02-21 20:39:08)
|
|
|
|
The issue is the `sha256` hash in flake.nix for `mixDeps` is outdated since we added new dependencies. We need to update it. The easiest way is to set it to an empty hash and let Nix tell us the correct one:
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:39:12
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1 +1 @@
|
|
- sha256 = "sha256-si7JAomY1HZ33m6ihUJP5i6PO39CE1clYvuMtn0CbPU=";
|
|
\ No newline at end of file
|
|
+ sha256 = "";
|
|
\ No newline at end of file
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: nix build 2>&1 | grep -A2 "got:"
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:21
|
|
|
|
```
|
|
got: sha256-t8fPBGFC7wHfxgdRNEwI1k730nS7lzF750pvh0ukb7g=
|
|
error: Cannot build '/nix/store/bljwfxq3i55yfm8h8jl793hybdsmniyl-org-garden-0.1.0.drv'.
|
|
Reason: 1 dependency failed.
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 20:39:24
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1 +1 @@
|
|
- sha256 = "";
|
|
\ No newline at end of file
|
|
+ sha256 = "sha256-t8fPBGFC7wHfxgdRNEwI1k730nS7lzF750pvh0ukb7g=";
|
|
\ No newline at end of file
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: nix build 2>&1 | tail -30
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:39
|
|
|
|
```
|
|
warning: Git tree '/home/ignaciobll/code/org-garden' is dirty
|
|
these 2 derivations will be built:
|
|
/nix/store/xpwdsdlhmcf1z3f0qc1h4ajhhpq9nm8j-org-garden-0.1.0.drv
|
|
/nix/store/rqf0j6kj4dzdjlxigk42x4i551072vpa-org-garden.drv
|
|
building '/nix/store/xpwdsdlhmcf1z3f0qc1h4ajhhpq9nm8j-org-garden-0.1.0.drv'...
|
|
building '/nix/store/rqf0j6kj4dzdjlxigk42x4i551072vpa-org-garden.drv'...
|
|
```
|
|
## Agent (2026-02-21 20:39:41)
|
|
|
|
The build succeeded. Let me verify:
|
|
|
|
### Tool Call [completed]: ls -la result/bin/
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:42
|
|
|
|
```
|
|
total 12
|
|
dr-xr-xr-x 2 root root 4096 Jan 1 1970 .
|
|
dr-xr-xr-x 3 root root 4096 Jan 1 1970 ..
|
|
-r-xr-xr-x 1 root root 1285 Jan 1 1970 org-garden
|
|
```
|
|
## Agent (2026-02-21 20:39:44)
|
|
|
|
Let me also verify the escript still works:
|
|
|
|
### Tool Call [completed]: ./result/bin/org-garden --help 2>&1 | head -20
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:45
|
|
|
|
```
|
|
20:39:45.616 module=Bandit [info] Running OrgGarden.Health with Bandit 1.10.2 at 0.0.0.0:9090 (http)
|
|
Usage: org-garden export <notes-dir> [options]
|
|
```
|
|
## Agent (2026-02-21 20:39:47)
|
|
|
|
The health server starts automatically. Let me commit the hash fix:
|
|
|
|
### Tool Call [completed]: git add flake.nix && git commit -m "Update mix deps hash for new dependencies"
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:48
|
|
|
|
```
|
|
[main c54c27f] Update mix deps hash for new dependencies
|
|
1 file changed, 1 insertion(+), 1 deletion(-)
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: git push
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 20:39:53
|
|
|
|
```
|
|
remote: . Processing 1 references
|
|
remote: Processed 1 references in total
|
|
To gitea.bueso.eu:ignacio.ballesteros/org-garden.git
|
|
6476b45..c54c27f main -> main
|
|
```
|
|
## Agent (2026-02-21 20:39:56)
|
|
|
|
Done! The build now succeeds. Summary of fixes:
|
|
|
|
1. Fixed `.gitignore` pattern `/org_garden` (was matching `lib/org_garden/`)
|
|
2. Updated `mixDeps` hash in `flake.nix` to `sha256-t8fPBGFC7wHfxgdRNEwI1k730nS7lzF750pvh0ukb7g=` for the new dependencies (bandit, plug, telemetry)
|
|
|
|
The service infrastructure is now fully implemented and the Nix build works.
|
|
|
|
## User (2026-02-21 21:36:48)
|
|
|
|
Do a quick change: improve parallelism while exporting org->md files. Make it configurable, by default 8.
|
|
|
|
|
|
|
|
### Tool Call [completed]: read
|
|
|
|
**Tool:** read
|
|
**Timestamp:** 2026-02-21 21:36:52
|
|
|
|
```
|
|
<path>/home/ignaciobll/code/org-garden/lib/org_garden/export.ex</path>
|
|
<type>file</type>
|
|
<content>1: defmodule OrgGarden.Export do
|
|
2: @moduledoc """
|
|
3: Org-to-Markdown export via Emacs batch + ox-hugo.
|
|
4:
|
|
5: Provides both single-file and batch export, plus a helper to compute
|
|
6: the expected `.md` output path for a given `.org` source file.
|
|
7: """
|
|
8:
|
|
9: require Logger
|
|
10:
|
|
11: @doc """
|
|
12: Export a single `.org` file to Markdown via `emacs --batch` + ox-hugo.
|
|
13:
|
|
14: Returns `{:ok, exit_code}` with the emacs exit code (0 = success),
|
|
15: or `{:error, reason}` if the command could not be executed.
|
|
16: """
|
|
17: @spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
|
|
18: def export_file(orgfile, notes_dir, output_dir) do
|
|
19: OrgGarden.Telemetry.span_export(orgfile, fn ->
|
|
20: do_export_file(orgfile, notes_dir, output_dir)
|
|
21: end)
|
|
22: end
|
|
23:
|
|
24: defp do_export_file(orgfile, notes_dir, output_dir) do
|
|
25: section =
|
|
26: orgfile
|
|
27: |> Path.dirname()
|
|
28: |> Path.relative_to(notes_dir)
|
|
29:
|
|
30: # ox-hugo requires static/ to exist for image asset copying
|
|
31: File.mkdir_p!(Path.join(output_dir, "static"))
|
|
32:
|
|
33: {output, exit_code} =
|
|
34: System.cmd(
|
|
35: "emacs",
|
|
36: [
|
|
37: "--batch",
|
|
38: "--eval", "(require 'ox-hugo)",
|
|
39: "--eval", """
|
|
40: (org-cite-register-processor 'passthrough
|
|
41: :export-citation
|
|
42: (lambda (citation _style _backend _info)
|
|
43: (let ((keys (mapcar (lambda (ref)
|
|
44: (concat "@" (org-element-property :key ref)))
|
|
45: (org-cite-get-references citation))))
|
|
46: (format "[cite:%s]" (string-join keys ";")))))
|
|
47: """,
|
|
48: "--eval", "(setq org-cite-export-processors '((t passthrough)))",
|
|
49: "--eval", ~s[(setq org-hugo-base-dir "#{output_dir}")],
|
|
50: "--eval", ~s[(setq org-hugo-default-section-directory "#{section}")],
|
|
51: "--visit", orgfile,
|
|
52: "--funcall", "org-hugo-export-to-md"
|
|
53: ],
|
|
54: stderr_to_stdout: true
|
|
55: )
|
|
56:
|
|
57: filtered =
|
|
58: output
|
|
59: |> String.split("\n")
|
|
60: |> Enum.reject(&String.match?(&1, ~r/^Loading|^ad-handle|^For information/))
|
|
61: |> Enum.join("\n")
|
|
62:
|
|
63: if filtered != "", do: Logger.info("emacs: #{filtered}")
|
|
64:
|
|
65: if exit_code == 0 do
|
|
66: {:ok, exit_code}
|
|
67: else
|
|
68: {:error, {:emacs_exit, exit_code, filtered}}
|
|
69: end
|
|
70: rescue
|
|
71: e -> {:error, e}
|
|
72: end
|
|
73:
|
|
74: @doc """
|
|
75: Export all `.org` files found under `notes_dir`.
|
|
76:
|
|
77: Returns `{:ok, count}` where `count` is the number of successfully
|
|
78: exported files, or `{:error, failures}` if any files failed.
|
|
79: """
|
|
80: @spec export_all(String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, list()}
|
|
81: def export_all(notes_dir, output_dir) do
|
|
82: org_files =
|
|
83: Path.join(notes_dir, "**/*.org")
|
|
84: |> Path.wildcard()
|
|
85:
|
|
86: if org_files == [] do
|
|
87: Logger.warning("No .org files found in #{notes_dir}")
|
|
88: {:ok, 0}
|
|
89: else
|
|
90: Logger.info("Exporting #{length(org_files)} org file(s) from #{notes_dir}")
|
|
91:
|
|
92: results =
|
|
93: Enum.map(org_files, fn orgfile ->
|
|
94: IO.puts(" exporting: #{orgfile}")
|
|
95: {orgfile, export_file(orgfile, notes_dir, output_dir)}
|
|
96: end)
|
|
97:
|
|
98: failures =
|
|
99: Enum.filter(results, fn
|
|
100: {_, {:ok, _}} -> false
|
|
101: {_, {:error, _}} -> true
|
|
102: end)
|
|
103:
|
|
104: if failures == [] do
|
|
105: {:ok, length(results)}
|
|
106: else
|
|
107: {:error, failures}
|
|
108: end
|
|
109: end
|
|
110: end
|
|
111:
|
|
112: @doc """
|
|
113: Compute the expected `.md` path for a given `.org` file.
|
|
114:
|
|
115: Uses the same section-mapping logic as ox-hugo: the relative directory
|
|
116: of the `.org` file within `notes_dir` becomes the section directory
|
|
117: under `content_dir`.
|
|
118:
|
|
119: ## Examples
|
|
120:
|
|
121: iex> OrgGarden.Export.expected_md_path("/notes/bus/emt.org", "/notes", "/out/content")
|
|
122: "/out/content/bus/emt.md"
|
|
123:
|
|
124: iex> OrgGarden.Export.expected_md_path("/notes/top-level.org", "/notes", "/out/content")
|
|
125: "/out/content/top-level.md"
|
|
126: """
|
|
127: @spec expected_md_path(String.t(), String.t(), String.t()) :: String.t()
|
|
128: def expected_md_path(orgfile, notes_dir, content_dir) do
|
|
129: section =
|
|
130: orgfile
|
|
131: |> Path.dirname()
|
|
132: |> Path.relative_to(notes_dir)
|
|
133:
|
|
134: basename = Path.basename(orgfile, ".org") <> ".md"
|
|
135:
|
|
136: case section do
|
|
137: "." -> Path.join(content_dir, basename)
|
|
138: _ -> Path.join([content_dir, section, basename])
|
|
139: end
|
|
140: end
|
|
141: end
|
|
142:
|
|
|
|
(End of file - total 142 lines)
|
|
</content>
|
|
```
|
|
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 21:37:07
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -1,6 +1,12 @@
|
|
+ @default_max_concurrency 8
|
|
+
|
|
@doc """
|
|
Export all `.org` files found under `notes_dir`.
|
|
|
|
+ Exports files in parallel for improved performance. The concurrency level
|
|
+ can be configured via the `:export_concurrency` application config or
|
|
+ the `EXPORT_CONCURRENCY` environment variable. Defaults to #{@default_max_concurrency}.
|
|
+
|
|
Returns `{:ok, count}` where `count` is the number of successfully
|
|
exported files, or `{:error, failures}` if any files failed.
|
|
"""
|
|
@@ -14,13 +20,21 @@
|
|
Logger.warning("No .org files found in #{notes_dir}")
|
|
{:ok, 0}
|
|
else
|
|
- Logger.info("Exporting #{length(org_files)} org file(s) from #{notes_dir}")
|
|
+ max_concurrency = get_concurrency()
|
|
+ Logger.info("Exporting #{length(org_files)} org file(s) from #{notes_dir} (concurrency: #{max_concurrency})")
|
|
|
|
results =
|
|
- Enum.map(org_files, fn orgfile ->
|
|
- IO.puts(" exporting: #{orgfile}")
|
|
- {orgfile, export_file(orgfile, notes_dir, output_dir)}
|
|
- end)
|
|
+ org_files
|
|
+ |> Task.async_stream(
|
|
+ fn orgfile ->
|
|
+ Logger.info(" exporting: #{orgfile}")
|
|
+ {orgfile, export_file(orgfile, notes_dir, output_dir)}
|
|
+ end,
|
|
+ max_concurrency: max_concurrency,
|
|
+ timeout: :infinity,
|
|
+ ordered: false
|
|
+ )
|
|
+ |> Enum.map(fn {:ok, result} -> result end)
|
|
|
|
failures =
|
|
Enum.filter(results, fn
|
|
@@ -34,4 +48,8 @@
|
|
{:error, failures}
|
|
end
|
|
end
|
|
+ end
|
|
+
|
|
+ defp get_concurrency do
|
|
+ Application.get_env(:org_garden, :export_concurrency, @default_max_concurrency)
|
|
end
|
|
\ No newline at end of file
|
|
```
|
|
## Agent (2026-02-21 21:37:09)
|
|
|
|
Now let's add the config option to runtime.exs:
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 21:37:12
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -2,4 +2,8 @@
|
|
config :org_garden,
|
|
http_port: RuntimeConfig.parse_int(System.get_env("PORT"), 8080),
|
|
ws_port: RuntimeConfig.parse_int(System.get_env("WS_PORT"), 3001),
|
|
- health_port: RuntimeConfig.parse_int(System.get_env("HEALTH_PORT"), 9090)
|
|
\ No newline at end of file
|
|
+ health_port: RuntimeConfig.parse_int(System.get_env("HEALTH_PORT"), 9090)
|
|
+
|
|
+# Export parallelism
|
|
+config :org_garden,
|
|
+ export_concurrency: RuntimeConfig.parse_int(System.get_env("EXPORT_CONCURRENCY"), 8)
|
|
\ No newline at end of file
|
|
```
|
|
## Agent (2026-02-21 21:37:14)
|
|
|
|
Also add to config.exs defaults:
|
|
|
|
### Tool Call [completed]: edit
|
|
|
|
**Tool:** edit
|
|
**Timestamp:** 2026-02-21 21:37:17
|
|
|
|
```
|
|
Edit applied successfully.
|
|
|
|
|
|
|
|
|
|
|
|
╭─────────╮
|
|
│ changes │
|
|
╰─────────╯
|
|
|
|
@@ -3,4 +3,5 @@
|
|
citation_mode: :warn,
|
|
http_port: 8080,
|
|
ws_port: 3001,
|
|
- health_port: 9090
|
|
\ No newline at end of file
|
|
+ health_port: 9090,
|
|
+ export_concurrency: 8
|
|
\ No newline at end of file
|
|
```
|
|
## Agent (2026-02-21 21:37:19)
|
|
|
|
Let's verify it compiles:
|
|
|
|
### Tool Call [completed]: nix develop --command mix compile --warnings-as-errors
|
|
|
|
**Tool:** execute
|
|
**Timestamp:** 2026-02-21 21:37:23
|
|
|
|
```
|
|
warning: Git tree '/home/ignaciobll/code/org-garden' is dirty
|
|
Compiling 17 files (.ex)
|
|
Generated org_garden app
|
|
```
|
|
## Agent (2026-02-21 21:37:25)
|
|
|
|
Now let's commit and push: |