diff --git a/.gitignore b/.gitignore index f74e48c..917fa09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/scheduled_tasks.json # Python __pycache__/ diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index a69ef67..2b732b8 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -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/` with `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `` 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 (`` 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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 3bfb903..1ec7624 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -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/` 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 ` 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 diff --git a/heph.nvim/README.md b/heph.nvim/README.md index 9fc630f..9854b44 100644 --- a/heph.nvim/README.md +++ b/heph.nvim/README.md @@ -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, -- 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, -- 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 | diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index 8a50a0c..6c5d8e0 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -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 `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 diff --git a/heph.nvim/lua/heph/daemon.lua b/heph.nvim/lua/heph/daemon.lua index c3ff8e8..7e4968f 100644 --- a/heph.nvim/lua/heph/daemon.lua +++ b/heph.nvim/lua/heph/daemon.lua @@ -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 diff --git a/heph.nvim/lua/heph/init.lua b/heph.nvim/lua/heph/init.lua index ea54747..d6e9cb4 100644 --- a/heph.nvim/lua/heph/init.lua +++ b/heph.nvim/lua/heph/init.lua @@ -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 diff --git a/heph.nvim/lua/heph/rpc.lua b/heph.nvim/lua/heph/rpc.lua index 3a71b4d..0bec263 100644 --- a/heph.nvim/lua/heph/rpc.lua +++ b/heph.nvim/lua/heph/rpc.lua @@ -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. diff --git a/heph.nvim/plugin/heph.lua b/heph.nvim/plugin/heph.lua index 1e708ab..54dd95c 100644 --- a/heph.nvim/plugin/heph.lua +++ b/heph.nvim/plugin/heph.lua @@ -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, }) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua index 2658ea6..73b3a8c 100644 --- a/heph.nvim/tests/e2e/helpers.lua +++ b/heph.nvim/tests/e2e/helpers.lua @@ -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 }) diff --git a/heph.nvim/tests/e2e/managed_daemon_spec.lua b/heph.nvim/tests/e2e/managed_daemon_spec.lua new file mode 100644 index 0000000..4bd65e1 --- /dev/null +++ b/heph.nvim/tests/e2e/managed_daemon_spec.lua @@ -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)