docs: daemon lifecycle becomes an explicit service (connect-only surfaces)
Some checks failed
Build / validate (pull_request) Has been cancelled

A surface-owned, auto-spawned daemon can't be shared once the CLI is also a
first-class client — so drop auto-spawn and manage the daemon as an OS service.

- design §4: daemon lifecycle = explicit OS service; surfaces connect-only
- heph-nvim.md: rewrite the daemon-lifecycle section (connect-only) + history
- new how-to/run-the-daemon.md (heph daemon start/stop/restart/status); indexed
- install-heph.md: post-install is `heph daemon start`; plugin no longer spawns
- tech-spec §14: mark the managed-daemon entry superseded
- changelog fragment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-02 21:07:11 -07:00
commit 1315b9ce18
7 changed files with 97 additions and 17 deletions

View file

@ -21,3 +21,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- `heph.nvim` follow-or-create: pressing `<CR>` on a `[[wiki-link]]` whose target doesn't exist yet now **creates** a doc with that title and opens it (the zettelkasten gesture), materializing the source's backlink — so you can link a journal entry to a brand-new note in one keystroke. Plus `:Heph doc <title>` to create a standalone wiki entry, and `:Heph home` — a single designated landing/index page (open-or-create by title, configurable via `opts.home`) to grow a map of content around. `:Heph journals` opens a recent-days picker (preview existing days, `@create` for new ones; count via `opts.journal_days`, default 7) — the dailies workflow. Pickers (Telescope) now support a preview pane. The `:Heph next`/`list` views are interactive: `<CR>` opens a task's context, `a` adds a task (prompt title + attention), `d` marks the task under the cursor done, `r` refreshes — with a dimmed key hint shown above the list.
- Dev/installed isolation tooling: a `mise run dev` task runs the working-tree `hephd` on isolated `.dev/` paths, and a how-to ([[install-heph]]) covers installing `heph`/`hephd` from the forge (build-from-source), the lazy.nvim plugin setup, and pointing a dev Neovim at the dev daemon via `$HEPH_SOCKET`/`$HEPH_DB` so it never touches the installed store.
- CLI as a complete task surface (§1, §6.2.1): `heph` now implements the entire daemon API and is the task capture/scripting surface. Structured fields are flags with **human dates** (`--do-date tomorrow|+3d|fri|YYYY-MM-DD`, shown back compactly in `next`/`list`) and **recurrence** (`--recur` presets/natural-language like "every 3 days", or a raw `--rrule`). New verbs: `list`, `done`/`drop`/`skip`, `attention`, `edit` (reschedule do-date/late-on/recurrence, re-attention, re-file — backed by the new `task.set_schedule` RPC), `promote`, `show`, `log` (append or tail), `health`, `node update`/`rm`, `resolve`, `links`/`backlinks`, `link add`, `project add [--parent]`, `sync [--status]`, `conflicts [resolve]`. Projects are referenced by name. Date/recurrence parsing is unit-tested; the new verbs have real-socket process tests.
- Daemon lifecycle is now an explicit OS service, and all surfaces are connect-only (no more auto-spawn). `heph daemon start/stop/restart/status/uninstall` idempotently manages a launchd agent (macOS) or systemd user service (Linux) that runs `hephd` on your default store; `heph.nvim` no longer spawns or supervises a daemon — it just connects and points you at `heph daemon start` if none is running. Rationale: once the CLI became a first-class surface, a daemon owned by one surface couldn't be shared (see [[run-the-daemon]], [[design]] §4).

View file

@ -96,6 +96,7 @@ Layers, top to bottom:
- The **web UI** is the occasional hub-served surface. A later iOS/Watch client talks to the hub directly.
- 🔒 **Single binary, three modes.** One Rust binary runs as `local` / `server` / `client` ([[tech-spec]] §3.1); the `heph` CLI shares the same command surface. Mode is two orthogonal axes (backend + inbound listener) plus an optional `hub_url` that makes any `local` instance a syncing **spoke** — the everyday device is `local` + `hub_url`, the hub is `server`, `client` is the online-only convenience.
- **Per-device daemon (`hephd`):** owns the local SQLite handle, the CRDT/op-log state, and background sync. All local surfaces connect to it over a local socket. This is what makes multi-surface access concurrent and safe on one SQLite file, and gives one place to run background sync.
- 🔒 **Lifecycle = an explicit OS service; surfaces are connect-only (decided 2026-06).** Since the daemon is shared across surfaces (CLI, TUI, nvim), no single surface owns it — so none auto-spawns it (an earlier nvim "managed daemon" that spawned + killed-on-exit was removed: a surface-owned daemon can't be shared, and "when do we stop it?" has no good answer). Instead it runs as a launchd agent (macOS) / systemd user service (Linux), managed by **`heph daemon start/stop/restart/status`** ([[run-the-daemon]]). Surfaces only connect, and tell you to run `heph daemon start` if nothing is serving the socket.
- **Core crate (Rust lib):** data model, query engine, markdown parsing + wiki-link extraction, and sync logic. Linked by both `hephd` and the hub server.
- **Hub:** `hephd` running in "server" mode on blumeops k3s — central SQLite, the sync rendezvous point, and the web UI host. May be offline for long periods; devices keep working and reconcile when it returns.

View file

@ -17,4 +17,5 @@ Task-oriented guides for common operations.
## heph
- [[install-heph]] — Install `heph`/`hephd` from the forge, set up the Neovim plugin, and isolate in-repo development
- [[run-the-daemon]] — Run `hephd` as an OS service with `heph daemon start/stop/restart/status`
- [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`)

View file

@ -48,15 +48,22 @@ git clone --branch feature/v1-prototype \
{
dir = vim.fn.expand("~/.local/share/heph/checkout/heph.nvim"),
config = function()
require("heph").setup({}) -- plug-and-play: spawns + manages its own hephd
require("heph").setup({}) -- connect-only: talks to the daemon you started
end,
}
```
`setup({})` is plug-and-play — it starts and supervises a local `hephd` against
the default paths, so you don't need a separate service. Update the plugin by
`git pull`ing the checkout. (A future split of `heph.nvim` into its own forge
repo will make this a normal `{ "eblume/heph.nvim" }` spec.)
The plugin is **connect-only** — it talks to a `hephd` you run as a service, it
does not start one itself. Start the daemon once:
```bash
heph daemon start # launchd agent (macOS) / systemd user service (Linux)
```
See [[run-the-daemon]] for `start`/`stop`/`restart`/`status`. Update the plugin
by `git pull`ing the checkout (and after a `cargo install` upgrade, `heph daemon
restart` to pick up the new `hephd`). (A future split of `heph.nvim` into its own
forge repo will make this a normal `{ "eblume/heph.nvim" }` spec.)
## 3. Isolate development

View file

@ -0,0 +1,66 @@
---
title: Run the heph daemon
modified: 2026-06-02
tags:
- how-to
---
# Run the heph daemon
`heph` and `heph.nvim` are thin clients — they talk to a `hephd` daemon over a
unix socket and **never start one themselves** ([[design]] §4). Run the daemon
as an OS-managed service with `heph daemon`:
```bash
heph daemon start # install + start (idempotent)
heph daemon status # is it installed/running? where are its socket/db/log?
heph daemon restart # restart — run this after upgrading the binary
heph daemon stop # stop it now
heph daemon uninstall # stop and remove the service for good
```
All verbs are idempotent — `start` when it's already running is a no-op, `stop`
when it's already stopped is fine.
## What it manages
- **macOS** — a launchd **LaunchAgent** (`org.hephaestus.hephd`) at
`~/Library/LaunchAgents/org.hephaestus.hephd.plist`, with `RunAtLoad` +
`KeepAlive` (starts at login, restarts if it crashes).
- **Linux** — a **systemd user service** (`heph.service`) at
`~/.config/systemd/user/heph.service`, with `Restart=on-failure`, enabled for
login.
Either way it runs `hephd --mode local` against the default store
(`~/.local/share/heph/heph.db`) and socket, with logs at
`~/.local/share/heph/hephd.log`.
> **`stop` vs `uninstall`:** `stop` halts the daemon now, but the service is
> still installed, so on macOS it starts again at next login. Use `uninstall`
> to stop it persistently.
## After upgrading
When you rebuild/reinstall (`cargo install … --force`), the running daemon is
still the old binary until you restart it:
```bash
heph daemon restart
```
## Development isolation
`heph daemon` manages the **installed** daemon on the default paths. For in-repo
development, run the working-tree daemon on separate paths instead and point a
dev Neovim/CLI at it (never touches your real store):
```bash
mise run dev # working-tree hephd on .dev/ paths
HEPH_SOCKET="$PWD/.dev/hephd.sock" HEPH_DB="$PWD/.dev/heph.db" nvim
HEPH_SOCKET="$PWD/.dev/hephd.sock" heph next
```
## Related
- [[install-heph]] — install `heph`/`hephd` and the plugin
- [[design]] — §4 the connect-only surface model

View file

@ -24,8 +24,7 @@ buffers; the daemon owns all storage and sync. Built in checkpointed slices on
| `node` | Buffer-backed nodes. A node is a buffer named `heph://node/<id>` with `buftype=acwrite`; `BufReadCmd` loads the body via `node.get`, `BufWriteCmd` saves the whole buffer via `node.update`. |
| `link` | Parse the `[[wiki-link]]` under the cursor (mirroring `extract.rs` grammar) and follow it via `node.resolve` (exact, never fuzzy `search`). Unresolved links are allowed. |
| `journal` | Open/create a dated journal node (idempotent — deterministic id). |
| `daemon` | Managed-daemon lifecycle: `ensure` (connect if a daemon already serves the socket, else spawn one we own), `stop_spawned` (kill only what we spawned, on exit), readiness-poll. Shared with the e2e harness. |
| `config` / `init` | `setup(opts)`, socket resolution, default keymaps. |
| `config` / `init` | `setup(opts)`, socket resolution, default keymaps. The plugin is **connect-only** — it never spawns a daemon (see Daemon lifecycle). |
| `command` | The `:Heph <subcommand>` dispatch + completion. |
Surfaces never touch SQLite — every operation is a daemon RPC (tech-spec §3).
@ -34,15 +33,20 @@ plugin-side compositions of daemon primitives, not daemon concepts.
## Daemon lifecycle
`setup({})` is **plug-and-play** by default (`autostart = true`): if nothing is
serving the socket, the plugin spawns a local `hephd` against the default XDG
paths, kills only the daemon *it* spawned on `VimLeavePre`, and **self-heals**
`rpc.call` retries once through a respawn hook if the connection drops. It only
ever spawns when nothing is already serving the socket, so a `server`/`client`
daemon you started is respected. With `autostart = false` the plugin **connects
only** and warns/errors if unreachable — for when you run your own daemon. The
`$HEPH_SOCKET` / `$HEPH_DB` env knobs (and `mise run dev`) isolate a dev Neovim
onto a separate daemon + DB so real data is never touched.
The plugin is **connect-only**: it never spawns or supervises a `hephd`. The
daemon is an explicit, OS-managed service started once with **`heph daemon
start`** (a launchd agent on macOS, a systemd user service on Linux — see
[[run-the-daemon]]); every surface (CLI, TUI, this plugin) is a pure client of
that one daemon. On `setup({})` the plugin resolves the socket and connects; if
nothing is serving it, it notifies once with guidance to run `heph daemon
start` (and a dropped connection is retried with a plain reconnect — never a
spawn). The `$HEPH_SOCKET` / `$HEPH_DB` env knobs (and `mise run dev`) point a
dev Neovim at a separate daemon + DB so real data is never touched.
> **History:** earlier iterations had the plugin auto-spawn and supervise its
> own daemon (`autostart`, self-heal, kill-on-exit). That was removed once the
> CLI became a first-class surface — a daemon owned by one surface can't be
> shared, so lifecycle moved to an explicit service ([[design]] §4).
## Daemon RPC dependencies

View file

@ -364,7 +364,7 @@ See [[design]] §5§7 for the constraints later phases impose on present choi
- ✅ **`heph.nvim` slice 11b (§8) — task views:** **`list` enriched** to titled [`RankedTask`] rows (title + `canonical_context_id`, shared `ranked_from_row` with `next`) so the Organizational view needs no N+1 `node.get`. Plugin: **Tactical `next`** + **Organizational `list`** views (rendered scratch buffers, `<CR>` opens a row's canonical-context doc — the node autocmd narrowed to `heph://node/*` so view buffers don't trip it); **task capture**, **set-attention**, **done/drop**, **skip**, **per-task `log` append** — all resolving "the current task" from the buffer (a `task` node, or a context doc via its `canonical-context` backlink); **`vim.ui.select` picker** (`picker.lua`) with Telescope auto-upgrade; `:Heph next/list/capture/attention/done/drop/skip/log/search` subcommands. e2e specs: **capture→next→open context→add/check checklist→done**, and **recurring fresh-checklist** (complete rolls forward in place; the next occurrence is all-unchecked — the §4.4 hard requirement).
- ✅ **`heph.nvim` slice 11c (§8) — promotion + Dagger CI:** backend **`task.promote {container_id, item_ref, attention?, project?}`** — mints a committed task from the `item_ref`-th `- [ ]` context item (1-based, document order via a new `extract::context_item_lines`) and rewrites that source line into a `[[link]]` to it. **Wiki-link resolution now excludes canonical-context docs** (`resolve_id`), so `[[Task Title]]` deterministically resolves to the task, not its identically-titled context doc — a general fix surfaced by promotion. Plugin: `:Heph promote`, `promote_under_cursor` (save-if-dirty → `util.context_item_index_at_cursor` mirrors extract's fence rules → `task.promote` → reload). e2e spec (f). **CI via Dagger:** a `test_nvim` function in `.dagger/` bakes a **pinned, arch-detected Neovim** (`v0.11.2`; Debian's is too old for `vim.uv`) onto `rust:1-bookworm`, builds `hephd`, and runs the shim suite (cargo + cargo-target cache volumes); `build.yaml` calls `dagger call test-nvim`. `run.lua` fails on zero-specs (no false-green) — validated end-to-end (failing spec → Dagger exit 1).
- ✅ **`heph.nvim` UX iteration + install (§8) — post-11c, makes the plugin a daily driver:**
- **Plug-and-play managed daemon:** `setup({})` spawns + supervises its own `hephd` (default XDG paths), kills only what it spawned on exit, and **self-heals** (`rpc.call` respawns + retries once on a dropped connection). A daemon you run yourself is respected (spawn only when nothing serves the socket); `autostart=false` ⇒ connect-only. **Bugfix:** `daemon.wait_ready` must not call the rpc probe inside a `vim.wait` predicate (nested `vim.wait` deadlocks Neovim) — bit on the 2nd launch via the prior daemon's stale socket; now a plain-loop probe + socket-unlink on exit, with a regression test.
- **Managed daemon (~~plug-and-play autostart~~ — SUPERSEDED 2026-06 by `heph daemon`, below):** the plugin used to spawn + supervise its own `hephd` and kill it on exit. Removed once the CLI became a first-class surface — a surface-owned daemon can't be shared. Lifecycle is now an explicit OS service ([[design]] §4); all surfaces are connect-only.
- **Knowledge-base UX:** **follow-or-create** (`<CR>` on an unresolved `[[link]]` mints the doc + materializes the source backlink), **`:Heph doc`**, **`:Heph home`** (an open-or-create index/landing page), **`:Heph journals`** (recent-days dailies picker with Telescope preview + `@create`).
- **Interactive task views:** `:Heph next`/`list` buffers gained `a` add / `d` done / `r` refresh (+ `<CR>` open), with a dimmed key-hint **header line** (virt-lines-above the first row render off-screen — use a real header).
- **Dev/installed isolation:** installed `heph`/`hephd` own the default paths; `mise run dev` runs the working-tree daemon on `.dev/` paths; `$HEPH_SOCKET`/`$HEPH_DB` point a dev Neovim at it.