generated from eblume/project-template
heph.nvim: plug-and-play managed daemon (autostart, self-heal, client/server guardrail)
The plugin now manages its own hephd by default (autostart = true): if nothing is serving the socket it spawns a local daemon against the default XDG paths, kills only what it spawned on VimLeavePre, and self-heals — rpc.call retries once through a respawn hook when the connection drops (the prior owner releases the DB lock on exit, so a respawn can claim it). - daemon.ensure() connects to an already-running daemon (any mode) or spawns one we own; stop_spawned()/is_managed() track lifecycle. - A server/client daemon you started is always respected (spawn only when nothing serves the socket). autostart = false → connect-only, warns/errors if down, and clears the self-heal hook so it fails loudly. - config: autostart defaults true; new `db` option; $HEPH_SOCKET / $HEPH_DB fallbacks isolate a dev Neovim onto a separate daemon + DB. e2e: managed_daemon_spec covers autostart spawn, self-heal-after-kill, and connect-only error. 10 specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fef0e82d26
commit
e3db2ac550
11 changed files with 277 additions and 18 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
|||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/scheduled_tasks.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
|
|||
- `heph.nvim` slice 11a (§8) — the primary surface begins: a Neovim plugin that is a thin client of the local `hephd` over its unix socket. A `vim.uv` JSON-RPC client (blocking `call` via `vim.wait`, id-demuxed, partial-line buffered, JSON `null`→Lua `nil`); buffer-backed nodes (`heph://node/<id>` with `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `<CR>` via a new exact `node.resolve {title}` RPC (alias-then-title, the same mapping that materializes `wiki` links — unresolved links allowed); the daily journal (`:Heph today`); and the `:Heph` command surface. Headless e2e (§9) drives the plugin against a real daemon over a temp socket with a self-contained busted-style runner (no external plugins, no network): journal round-trip, follow-link, and link-two-docs/backlink.
|
||||
- `heph.nvim` slice 11b (§8) — task views: `list` is enriched to return titled rows (the same shape as `next`, with the canonical-context id) so the Organizational survey needs no per-row `node.get`. The plugin gains the Tactical **`:Heph next`** and Organizational **`:Heph list`** views (`<CR>` opens a task's canonical-context doc), task **capture**, **set-attention**, **done/drop**, **skip**, and per-task **`log`** append — each resolving "the current task" from the buffer (a task node, or a context doc via its `canonical-context` backlink). A `vim.ui.select` picker (Telescope auto-upgrade when installed) backs `:Heph search`/`capture`/`attention`. Headless e2e adds the capture→next→context→checklist→done workflow and the recurring fresh-checklist workflow (completing a recurring task rolls it forward and the next occurrence presents an all-unchecked checklist).
|
||||
- `heph.nvim` slice 11c (§8) — promotion + CI: `task.promote` mints a committed task from a `- [ ]` context-item line (addressed by its 1-based index) and rewrites that line into a `[[link]]` to the new task; `:Heph promote` does this for the line under the cursor. Wiki-link resolution now excludes a task's canonical-context doc, so `[[Task Title]]` resolves to the task itself (not its identically-titled context doc). The headless e2e suite runs in CI via a Dagger function that bakes a pinned, arch-detected Neovim onto a Rust image and runs the same self-contained suite developers run natively with `mise run test-nvim`; the runner fails on a zero-spec discovery so a misconfigured path can't pass silently.
|
||||
- `heph.nvim` managed daemon — plug-and-play by default: `require("heph").setup({})` spawns and supervises a local `hephd` against the default paths when none is running, kills only the daemon it spawned on exit, and self-heals (respawns + reconnects if the daemon dies mid-session). A daemon you started yourself (a `server`/`client` architecture, or a service) is always respected — the plugin only spawns when nothing is serving the socket; with `autostart = false` it connects only and warns if unreachable. `$HEPH_SOCKET` / `$HEPH_DB` isolate a development Neovim onto a separate daemon + DB.
|
||||
|
|
|
|||
|
|
@ -24,7 +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` | Locate / spawn / readiness-poll `hephd` (shared with the e2e harness). |
|
||||
| `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. |
|
||||
| `command` | The `:Heph <subcommand>` dispatch + completion. |
|
||||
|
||||
|
|
@ -32,6 +32,18 @@ Surfaces never touch SQLite — every operation is a daemon RPC (tech-spec §3).
|
|||
The plugin is **mode-agnostic**: Tactical/Strategic/Organizational are
|
||||
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.
|
||||
|
||||
## Daemon RPC dependencies
|
||||
|
||||
Beyond the existing methods (tech-spec §6), the plugin relies on
|
||||
|
|
|
|||
|
|
@ -23,16 +23,35 @@ ordinary Neovim buffers; saving routes through the daemon.
|
|||
|
||||
## Setup
|
||||
|
||||
Requires a running `hephd` (`hephd --mode local`) and Neovim ≥ 0.10.
|
||||
Requires Neovim ≥ 0.10 and `hephd` on `PATH` (e.g. `cargo install`ed). By
|
||||
default the plugin is **plug-and-play** — it starts and manages its own `hephd`:
|
||||
|
||||
```lua
|
||||
require("heph").setup({}) -- spawns a local hephd against the default XDG paths
|
||||
```
|
||||
|
||||
- **`autostart = true`** (default): if nothing is serving the socket, the plugin
|
||||
spawns a local `hephd`, kills only what *it* spawned on exit, and **self-heals**
|
||||
(respawns + reconnects if the daemon dies mid-session).
|
||||
- **Running your own daemon** (a `server`/`client` architecture, or a launchd
|
||||
service)? Set `autostart = false` and point at its socket — the plugin then
|
||||
**connects only**, never spawning over your daemon, and warns if it's
|
||||
unreachable. A daemon already serving the socket is always respected, even with
|
||||
`autostart = true` (the plugin only spawns when nothing is there).
|
||||
|
||||
```lua
|
||||
require("heph").setup({
|
||||
-- socket = "/run/user/1000/heph/hephd.sock", -- defaults to hephd's path
|
||||
-- keymaps = true, -- <leader>h* maps
|
||||
-- autostart = false, -- spawn hephd if absent
|
||||
-- socket = "...", -- default: $HEPH_SOCKET, else hephd's XDG path
|
||||
-- db = "...", -- DB for an autostarted daemon ($HEPH_DB, else default)
|
||||
-- autostart = true, -- false = connect-only (you run hephd yourself)
|
||||
-- bin = "hephd", -- daemon binary for autostart
|
||||
-- keymaps = true, -- <leader>h* maps
|
||||
})
|
||||
```
|
||||
|
||||
**Dev isolation:** set `$HEPH_SOCKET` / `$HEPH_DB` (or run `mise run dev`) so a
|
||||
development Neovim drives a separate daemon + DB and never touches real data.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Action |
|
||||
|
|
|
|||
|
|
@ -3,19 +3,25 @@
|
|||
local M = {}
|
||||
|
||||
M.defaults = {
|
||||
--- Path to hephd's unix socket. `nil` → resolved to the daemon default.
|
||||
--- Path to hephd's unix socket. `nil` → `$HEPH_SOCKET`, else the daemon default.
|
||||
socket = nil,
|
||||
--- Spawn a local hephd if the socket is not ready (off by default in v1).
|
||||
autostart = false,
|
||||
--- hephd binary for autostart.
|
||||
--- DB path for an autostarted local daemon. `nil` → `$HEPH_DB`, else hephd's default.
|
||||
db = nil,
|
||||
--- Plug-and-play: spawn (and manage) a local hephd when none is serving
|
||||
--- `socket`. Set `false` when you run your own daemon (server/client): the
|
||||
--- plugin then connects only, and warns if nothing is reachable.
|
||||
autostart = true,
|
||||
--- hephd binary for autostart (on PATH for an installed heph).
|
||||
bin = "hephd",
|
||||
--- Set the default `<leader>h*` keymaps. `false` to opt out.
|
||||
keymaps = true,
|
||||
}
|
||||
|
||||
--- Resolve the socket path, mirroring hephd's `default_socket_path`:
|
||||
--- `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to the temp dir.
|
||||
--- Resolve the socket path: explicit opt, then `$HEPH_SOCKET`, then hephd's
|
||||
--- default (`$XDG_RUNTIME_DIR/heph/hephd.sock`, temp-dir fallback). The env knob
|
||||
--- lets a dev Neovim target a `mise run dev` daemon without touching real data.
|
||||
function M.resolve_socket(opt)
|
||||
opt = (opt and #opt > 0) and opt or vim.env.HEPH_SOCKET
|
||||
if opt and #opt > 0 then
|
||||
return opt
|
||||
end
|
||||
|
|
@ -24,6 +30,17 @@ function M.resolve_socket(opt)
|
|||
return (base:gsub("/+$", "")) .. "/heph/hephd.sock"
|
||||
end
|
||||
|
||||
--- Resolve the DB path for an autostarted daemon: explicit opt, then `$HEPH_DB`,
|
||||
--- else nil (let hephd pick its default). Pairs with `resolve_socket` for dev
|
||||
--- isolation.
|
||||
function M.resolve_db(opt)
|
||||
opt = (opt and #opt > 0) and opt or vim.env.HEPH_DB
|
||||
if opt and #opt > 0 then
|
||||
return opt
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Apply the default keymaps (no-op when `opts.keymaps` is false).
|
||||
function M.apply_keymaps(opts)
|
||||
if not opts.keymaps then
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ local uv = vim.uv or vim.loop
|
|||
|
||||
local M = {}
|
||||
|
||||
-- The daemon THIS nvim spawned (nil if we connected to an existing one).
|
||||
-- `{ handle, exited = { done }, socket, db, bin }`.
|
||||
M._managed = nil
|
||||
|
||||
--- Spawn a `local`-mode hephd against `opts.db` listening on `opts.socket`.
|
||||
--- `opts.bin` defaults to `hephd` on PATH. Returns `{ handle, pid }`.
|
||||
function M.spawn(opts)
|
||||
|
|
@ -55,4 +59,71 @@ function M.wait_ready(socket, timeout)
|
|||
return true
|
||||
end
|
||||
|
||||
--- Ensure a daemon is reachable at `opts.socket`. If one is already serving the
|
||||
--- socket (any mode — local/server/client), connect to it and do NOT spawn. Else
|
||||
--- if `opts.autostart`, spawn a local hephd we own (and manage its lifecycle).
|
||||
--- Returns `reachable, spawned_by_us`.
|
||||
function M.ensure(opts)
|
||||
-- Already serving? A quick probe respects a daemon someone else started.
|
||||
if M.wait_ready(opts.socket, opts.probe_ms or 400) then
|
||||
return true, false
|
||||
end
|
||||
if not opts.autostart then
|
||||
return false, false
|
||||
end
|
||||
local exited = { done = false }
|
||||
local d = M.spawn({
|
||||
bin = opts.bin,
|
||||
socket = opts.socket,
|
||||
db = opts.db,
|
||||
on_exit = function()
|
||||
exited.done = true
|
||||
end,
|
||||
})
|
||||
local ok, reason = M.wait_ready(opts.socket, opts.ready_ms or 5000)
|
||||
if not ok then
|
||||
pcall(function()
|
||||
if not d.handle:is_closing() then
|
||||
d.handle:kill("sigterm")
|
||||
end
|
||||
end)
|
||||
error("heph: spawned hephd but it never became ready: " .. tostring(reason))
|
||||
end
|
||||
M._managed = {
|
||||
handle = d.handle,
|
||||
exited = exited,
|
||||
socket = opts.socket,
|
||||
db = opts.db,
|
||||
bin = opts.bin,
|
||||
}
|
||||
return true, true
|
||||
end
|
||||
|
||||
--- True if this nvim currently owns a live spawned daemon.
|
||||
function M.is_managed()
|
||||
return M._managed ~= nil and not M._managed.exited.done
|
||||
end
|
||||
|
||||
--- Stop the daemon this nvim spawned (no-op if we connected to an existing one).
|
||||
function M.stop_spawned()
|
||||
local m = M._managed
|
||||
if not m then
|
||||
return
|
||||
end
|
||||
M._managed = nil
|
||||
if m.handle and not m.exited.done then
|
||||
pcall(function()
|
||||
m.handle:kill("sigterm")
|
||||
end)
|
||||
vim.wait(2000, function()
|
||||
return m.exited.done
|
||||
end, 20)
|
||||
end
|
||||
pcall(function()
|
||||
if m.handle and not m.handle:is_closing() then
|
||||
m.handle:close()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -14,15 +14,44 @@ M.config = nil
|
|||
function M.setup(opts)
|
||||
local cfg = vim.tbl_deep_extend("force", config.defaults, opts or {})
|
||||
cfg.socket = config.resolve_socket(cfg.socket)
|
||||
cfg.db = config.resolve_db(cfg.db)
|
||||
M.config = cfg
|
||||
|
||||
require("heph.rpc").setup(cfg.socket)
|
||||
local rpc = require("heph.rpc")
|
||||
local daemon = require("heph.daemon")
|
||||
rpc.setup(cfg.socket)
|
||||
|
||||
if cfg.autostart then
|
||||
local ok = require("heph.daemon").wait_ready(cfg.socket, 500)
|
||||
-- Plug-and-play: bring up a managed local daemon if none is serving, and
|
||||
-- self-heal a dropped connection on later calls.
|
||||
local ok = pcall(daemon.ensure, {
|
||||
socket = cfg.socket,
|
||||
db = cfg.db,
|
||||
bin = cfg.bin,
|
||||
autostart = true,
|
||||
})
|
||||
if not ok then
|
||||
require("heph.daemon").spawn({ bin = cfg.bin, socket = cfg.socket, db = nil })
|
||||
require("heph.daemon").wait_ready(cfg.socket, 5000)
|
||||
require("heph.util").notify(
|
||||
"could not start hephd; will retry on first use",
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
rpc.set_respawn(function()
|
||||
pcall(daemon.ensure, {
|
||||
socket = cfg.socket,
|
||||
db = cfg.db,
|
||||
bin = cfg.bin,
|
||||
autostart = true,
|
||||
})
|
||||
end)
|
||||
else
|
||||
-- Explicit architecture: connect only, never spawn over the user's daemon.
|
||||
rpc.set_respawn(nil)
|
||||
if not daemon.ensure({ socket = cfg.socket, autostart = false }) then
|
||||
require("heph.util").notify(
|
||||
"no hephd reachable at " .. cfg.socket .. " (autostart disabled)",
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -182,9 +182,34 @@ function M.session()
|
|||
return M._default
|
||||
end
|
||||
|
||||
--- Blocking call on the default session.
|
||||
--- Register a hook that (re)ensures the daemon — called once to self-heal a
|
||||
--- dropped connection before a single retry. `nil` disables self-heal (used when
|
||||
--- autostart is off, so a connect-only setup fails loudly instead of respawning).
|
||||
function M.set_respawn(fn)
|
||||
M._respawn = fn
|
||||
end
|
||||
|
||||
local function is_connection_error(msg)
|
||||
msg = tostring(msg)
|
||||
return msg:find("connect", 1, true) ~= nil
|
||||
or msg:find("connection", 1, true) ~= nil
|
||||
or msg:find("timeout", 1, true) ~= nil
|
||||
end
|
||||
|
||||
--- Blocking call on the default session. If the call fails because the
|
||||
--- connection is dead and a respawn hook is set, ensure the daemon and retry
|
||||
--- once (the prior owner releases the DB lock on exit, so a respawn can claim it).
|
||||
function M.call(method, params, opts)
|
||||
return M.session():call(method, params, opts)
|
||||
local ok, result = pcall(M.session().call, M.session(), method, params, opts)
|
||||
if ok then
|
||||
return result
|
||||
end
|
||||
if M._respawn and is_connection_error(result) then
|
||||
pcall(M._respawn)
|
||||
M.session():close() -- drop the dead connection so the retry reconnects
|
||||
return M.session():call(method, params, opts)
|
||||
end
|
||||
error(result)
|
||||
end
|
||||
|
||||
--- An isolated session for a socket — used by tests for independent assertions.
|
||||
|
|
|
|||
|
|
@ -31,13 +31,16 @@ vim.api.nvim_create_autocmd("BufWriteCmd", {
|
|||
end,
|
||||
})
|
||||
|
||||
-- Release the socket cleanly on exit.
|
||||
-- Release the socket and stop any daemon this nvim spawned, cleanly, on exit.
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = grp,
|
||||
callback = function()
|
||||
pcall(function()
|
||||
require("heph.rpc").close()
|
||||
end)
|
||||
pcall(function()
|
||||
require("heph.daemon").stop_spawned()
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ local function unique_dir()
|
|||
return dir
|
||||
end
|
||||
|
||||
--- A fresh temp dir + short socket/db paths, WITHOUT spawning a daemon (for
|
||||
--- tests that drive the plugin's own autostart/lifecycle). `rm` removes it.
|
||||
function M.tmp()
|
||||
local dir = unique_dir()
|
||||
return { dir = dir, sock = dir .. "/s", db = dir .. "/db", rm = function()
|
||||
pcall(function()
|
||||
vim.fn.delete(dir, "rf")
|
||||
end)
|
||||
end }
|
||||
end
|
||||
|
||||
--- Start a fresh daemon and bind the plugin's rpc to it. Returns a `ctx` with:
|
||||
--- `dir, sock, db, daemon, exited, q` (an isolated session for assertions).
|
||||
function M.start()
|
||||
|
|
@ -81,6 +92,7 @@ function M.stop(ctx)
|
|||
pcall(function()
|
||||
rpc.close()
|
||||
end)
|
||||
rpc.set_respawn(nil) -- don't let a managed-daemon spec leak self-heal here
|
||||
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_get_name(b):match("^heph://") then
|
||||
pcall(vim.api.nvim_buf_delete, b, { force = true })
|
||||
|
|
|
|||
68
heph.nvim/tests/e2e/managed_daemon_spec.lua
Normal file
68
heph.nvim/tests/e2e/managed_daemon_spec.lua
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
-- The plugin-managed daemon lifecycle (tech-spec §8): plug-and-play autostart,
|
||||
-- self-heal on a dropped connection, and connect-only when autostart is off.
|
||||
|
||||
local h = require("e2e.helpers")
|
||||
|
||||
describe("managed daemon", function()
|
||||
local t
|
||||
before_each(function()
|
||||
t = h.tmp() -- temp paths; no daemon spawned by the harness
|
||||
end)
|
||||
after_each(function()
|
||||
pcall(function()
|
||||
require("heph.daemon").stop_spawned()
|
||||
end)
|
||||
pcall(function()
|
||||
require("heph.rpc").close()
|
||||
end)
|
||||
require("heph.rpc").set_respawn(nil)
|
||||
t.rm()
|
||||
end)
|
||||
|
||||
it("autostart spawns a local daemon and connects plug-and-play", function()
|
||||
require("heph").setup({
|
||||
socket = t.sock,
|
||||
db = t.db,
|
||||
bin = h.hephd_bin(),
|
||||
autostart = true,
|
||||
keymaps = false,
|
||||
})
|
||||
assert.is_true(require("heph.daemon").is_managed())
|
||||
-- A real call works because the plugin brought the daemon up itself.
|
||||
assert.is_truthy(require("heph.rpc").call("health", {}))
|
||||
end)
|
||||
|
||||
it("self-heals: respawns and reconnects when the daemon dies", function()
|
||||
require("heph").setup({
|
||||
socket = t.sock,
|
||||
db = t.db,
|
||||
bin = h.hephd_bin(),
|
||||
autostart = true,
|
||||
keymaps = false,
|
||||
})
|
||||
require("heph.rpc").call("health", {})
|
||||
|
||||
-- Kill the managed daemon out from under the plugin.
|
||||
local m = require("heph.daemon")._managed
|
||||
m.handle:kill("sigterm")
|
||||
vim.wait(2000, function()
|
||||
return m.exited.done
|
||||
end, 20)
|
||||
|
||||
-- The next call transparently respawns the daemon and succeeds.
|
||||
assert.is_truthy(require("heph.rpc").call("health", {}))
|
||||
assert.is_true(require("heph.daemon").is_managed())
|
||||
end)
|
||||
|
||||
it("connect-only (autostart=false) errors when no daemon is running", function()
|
||||
require("heph").setup({
|
||||
socket = t.sock,
|
||||
autostart = false,
|
||||
keymaps = false,
|
||||
})
|
||||
assert.is_false(require("heph.daemon").is_managed())
|
||||
-- No daemon, no autostart, no self-heal → a call fails loudly.
|
||||
local ok = pcall(require("heph.rpc").call, "health", {})
|
||||
assert.is_false(ok, "expected connect-only to fail with no daemon running")
|
||||
end)
|
||||
end)
|
||||
Loading…
Add table
Add a link
Reference in a new issue