From bbac338f760762edc17c5e177a00d11ff6be3ccb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 18:52:15 -0700 Subject: [PATCH 01/91] Scaffold cargo workspace + heph-core foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kick off Phase 1 (v1 prototype) per tech-spec §11.1. Sets up the Cargo workspace and the first TDD slice of heph-core: - Migration runner + §4.5 SQLite schema (nodes, tasks, links, aliases, users, oplog, sync_state, conflicts), versioned via PRAGMA user_version. - Clock-injected `Clock` trait (no ambient wall-clock reads; §2). - `Store` trait + `LocalStore` SQLite backend with node create/get, bootstrapping the single local user (oidc_sub NULL, §13). - Node model (kinds: doc/task/project/tag/journal). Repo housekeeping: fill AGENTS.md Project Structure (last template TODO), ignore /target, add self-bootstrapping .forgejo/scripts/build that runs cargo fmt/clippy/test in CI (§9), changelog fragment. Tests green: 4 unit tests (migration version, local-user idempotency, create/get round-trip, missing-node None). Co-Authored-By: Claude Opus 4.8 (1M context) --- .forgejo/scripts/build | 30 ++ .gitignore | 3 + AGENTS.md | 33 +- Cargo.lock | 420 ++++++++++++++++++++++ Cargo.toml | 20 ++ crates/heph-core/Cargo.toml | 14 + crates/heph-core/src/clock.rs | 22 ++ crates/heph-core/src/error.rs | 24 ++ crates/heph-core/src/lib.rs | 21 ++ crates/heph-core/src/model.rs | 91 +++++ crates/heph-core/src/sqlite/migrations.rs | 112 ++++++ crates/heph-core/src/sqlite/mod.rs | 213 +++++++++++ crates/heph-core/src/store.rs | 23 ++ docs/changelog.d/v1-prototype.feature.md | 1 + 14 files changed, 1010 insertions(+), 17 deletions(-) create mode 100755 .forgejo/scripts/build create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/heph-core/Cargo.toml create mode 100644 crates/heph-core/src/clock.rs create mode 100644 crates/heph-core/src/error.rs create mode 100644 crates/heph-core/src/lib.rs create mode 100644 crates/heph-core/src/model.rs create mode 100644 crates/heph-core/src/sqlite/migrations.rs create mode 100644 crates/heph-core/src/sqlite/mod.rs create mode 100644 crates/heph-core/src/store.rs create mode 100644 docs/changelog.d/v1-prototype.feature.md diff --git a/.forgejo/scripts/build b/.forgejo/scripts/build new file mode 100755 index 0000000..1a6afd8 --- /dev/null +++ b/.forgejo/scripts/build @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# Project build hook (tech-spec §9): run the Rust workspace checks in CI. +# +# Invoked by .forgejo/workflows/build.yaml after the generic `prek` pass. +# Bootstraps a minimal Rust toolchain via rustup when the runner image lacks +# one, so the suite runs without a bespoke CI image. The headless nvim e2e +# suite is added here once heph.nvim exists. + +set -euo pipefail + +if ! command -v cargo >/dev/null 2>&1; then + echo "cargo not found; bootstrapping Rust toolchain via rustup..." + export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" + export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal + # shellcheck disable=SC1091 + . "$CARGO_HOME/env" + rustup component add clippy rustfmt +fi + +echo "== cargo fmt --check ==" +cargo fmt --all --check + +echo "== cargo clippy ==" +cargo clippy --all-targets --all-features -- -D warnings + +echo "== cargo test ==" +cargo test --all diff --git a/.gitignore b/.gitignore index 46e9957..f74e48c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ *.pyo .venv/ +# Rust +/target/ + # Linter caches .ruff_cache/ diff --git a/AGENTS.md b/AGENTS.md index 4e0d4cc..53ec884 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,18 +6,7 @@ Guidance for Claude Code working in this repository. See also [[ai-assistance-gu **hephaestus** — Personal context management system: wiki-style knowledge base and task management. -## First-Time Setup - -This repository was instantiated from the `project-template` Forgejo template. Setup status: - -- [x] `baseUrl` in `docs/quartz.config.ts` set to `localhost` (update once docs are hosted) -- [x] Dagger module renamed to `.dagger/src/hephaestus_ci/` (`HephaestusCi` class) -- [ ] Fill in the Project Structure section below once the Rust workspace is scaffolded -- [x] Fill in license info in `README.md` (All Rights Reserved / private) - -Delete this section once the remaining items are resolved. - -> **This is now a generated repo, not the template source.** C1/C2 changes use feature branches + PRs (`tea pr create`); noteworthy changes get changelog fragments in `docs/changelog.d/`. +> **This is a generated repo, not the template source.** C1/C2 changes use feature branches + PRs (`tea pr create`); noteworthy changes get changelog fragments in `docs/changelog.d/`. ## Rules @@ -52,13 +41,23 @@ See [[agent-change-process]] for the full methodology. ## Project Structure +A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; crates are added to the workspace as their slice begins, so not every crate below exists yet. + ``` -./docs/ # Diataxis docs, Quartz config, and release content -./docs/changelog.d/ # keep only .gitkeep in the template; generated repos add towncrier fragments here +./Cargo.toml # workspace manifest (shared deps + members) +./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction, + # recurrence, "what is next?" ranking, op-log/HLC/CRDT sync +./crates/hephd/ # daemon (planned): local/server/client modes; JSON-RPC over unix socket +./crates/heph/ # CLI (planned): export, scripting, `heph conflicts` +./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim +./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content +./docs/changelog.d/ # towncrier fragments for noteworthy changes ./.dagger/ # Dagger module (src/hephaestus_ci/) backing docs builds and releases -./.forgejo/workflows/ # generic build and release workflows for generated repos -./.forgejo/scripts/ # optional per-project build/release hooks consumed by the workflows +./.forgejo/workflows/ # build + release workflows +./.forgejo/scripts/ # per-project build/release hooks (build runs cargo test once present) ./mise-tasks/ # repo automation via `mise run` ``` -Other code paths will be listed via ai-docs. When you encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards. +**Development is TDD** (tech-spec §2, §9): failing test first, implement to green, commit on green. `heph-core` is clock-injected — no ambient wall-clock reads; time is always passed in. Canonical spec is [[tech-spec]]; rationale is [[design]]. + +Other doc paths are listed via `mise run ai-docs`. Wiki-links (`[[like-this]]`) refer to `docs/` cards. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..766cb10 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,420 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heph-core" +version = "0.0.0" +dependencies = [ + "rusqlite", + "thiserror", + "ulid", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ee065a0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] +resolver = "2" +members = ["crates/heph-core"] + +[workspace.package] +edition = "2021" +version = "0.0.0" +license = "LicenseRef-Proprietary" +publish = false +authors = ["Erich Blume "] +rust-version = "1.85" + +[workspace.dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +ulid = "1" +thiserror = "2" +anyhow = "1" + +[profile.release] +lto = "thin" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml new file mode 100644 index 0000000..f70dc26 --- /dev/null +++ b/crates/heph-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "heph-core" +description = "Hephaestus core: data model, storage backend, query engine, extraction, recurrence, and sync." +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[dependencies] +rusqlite.workspace = true +ulid.workspace = true +thiserror.workspace = true diff --git a/crates/heph-core/src/clock.rs b/crates/heph-core/src/clock.rs new file mode 100644 index 0000000..caf54d1 --- /dev/null +++ b/crates/heph-core/src/clock.rs @@ -0,0 +1,22 @@ +//! Clock injection. +//! +//! `heph-core` never reads the ambient wall clock (tech-spec §2): the current +//! time is always supplied through a [`Clock`]. This keeps ranking and +//! recurrence deterministic and testable. The daemon wires in a real +//! system clock; tests wire in a [`FixedClock`]. + +/// Source of the current time, in epoch milliseconds. +pub trait Clock: Send + Sync { + /// Current time as epoch milliseconds. + fn now_ms(&self) -> i64; +} + +/// A clock pinned to a fixed instant — for deterministic tests. +#[derive(Debug, Clone, Copy)] +pub struct FixedClock(pub i64); + +impl Clock for FixedClock { + fn now_ms(&self) -> i64 { + self.0 + } +} diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs new file mode 100644 index 0000000..e69957b --- /dev/null +++ b/crates/heph-core/src/error.rs @@ -0,0 +1,24 @@ +//! Error type for `heph-core`. + +/// Errors surfaced by the core library. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A SQLite-level failure. + #[error("sqlite: {0}")] + Sqlite(#[from] rusqlite::Error), + + /// The DB file is already locked by another `local`/`server` process. + #[error("store is already locked by another process: {0}")] + Locked(String), + + /// A referenced node does not exist. + #[error("node not found: {0}")] + NodeNotFound(String), + + /// A value in the database did not match the expected shape. + #[error("data integrity: {0}")] + Integrity(String), +} + +/// Convenience result alias. +pub type Result = std::result::Result; diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs new file mode 100644 index 0000000..566774d --- /dev/null +++ b/crates/heph-core/src/lib.rs @@ -0,0 +1,21 @@ +//! `heph-core` — the Hephaestus core library. +//! +//! Data model, the [`Store`](store::Store) abstraction + local SQLite store, +//! query engine, markdown extraction, recurrence, and (later) the sync engine. +//! See `docs/reference/tech-spec.md` for the canonical specification. +//! +//! The library is synchronous and side-effect-light. All time enters through an +//! injected [`Clock`](clock::Clock) (tech-spec §2) so ranking and recurrence are +//! deterministic. + +pub mod clock; +pub mod error; +pub mod model; +pub mod sqlite; +pub mod store; + +pub use clock::{Clock, FixedClock}; +pub use error::{Error, Result}; +pub use model::{NewNode, Node, NodeKind}; +pub use sqlite::LocalStore; +pub use store::Store; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs new file mode 100644 index 0000000..2278999 --- /dev/null +++ b/crates/heph-core/src/model.rs @@ -0,0 +1,91 @@ +//! Core data model: nodes and their kinds (tech-spec §4). +//! +//! Every first-class entity is a [`Node`]. Tasks, links, recurrence, and the +//! derived context-item index build on top of this base in later slices. + +use crate::error::{Error, Result}; + +/// Discriminator for the kind of thing a node is (tech-spec §4.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeKind { + /// Rich context document (knowledge base, work-logs). Body = markdown. + Doc, + /// Thin task. Carries no body; context arrives via links. + Task, + /// Grouping/context for tasks. + Project, + /// A label. + Tag, + /// A daily note, titled by ISO date. + Journal, +} + +impl NodeKind { + /// The wire/storage string for this kind. + pub fn as_str(self) -> &'static str { + match self { + NodeKind::Doc => "doc", + NodeKind::Task => "task", + NodeKind::Project => "project", + NodeKind::Tag => "tag", + NodeKind::Journal => "journal", + } + } + + /// Parse a storage string back into a [`NodeKind`]. + pub fn parse(s: &str) -> Result { + Ok(match s { + "doc" => NodeKind::Doc, + "task" => NodeKind::Task, + "project" => NodeKind::Project, + "tag" => NodeKind::Tag, + "journal" => NodeKind::Journal, + other => return Err(Error::Integrity(format!("unknown node kind: {other}"))), + }) + } +} + +/// A persisted node (a row of the `nodes` table). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Node { + /// Stable, sync-safe id (ULID for content nodes; deterministic for journal/tag). + pub id: String, + /// Owning user id (per-user isolation). + pub owner_id: String, + /// What kind of node this is. + pub kind: NodeKind, + /// Human-facing title. + pub title: String, + /// Markdown body (nullable — tasks have none). + pub body: Option, + /// Creation time, epoch ms. + pub created_at: i64, + /// Last-modified time, epoch ms. + pub modified_at: i64, + /// Hybrid logical clock of the last write (sync ordering; placeholder until §12). + pub hlc: String, + /// Whether the node is tombstoned (soft-deleted). + pub tombstoned: bool, +} + +/// Input for creating a node. +#[derive(Debug, Clone)] +pub struct NewNode { + /// What kind of node to create. + pub kind: NodeKind, + /// Human-facing title. + pub title: String, + /// Optional markdown body. + pub body: Option, +} + +impl NewNode { + /// A document node with a title and body. + pub fn doc(title: impl Into, body: impl Into) -> NewNode { + NewNode { + kind: NodeKind::Doc, + title: title.into(), + body: Some(body.into()), + } + } +} diff --git a/crates/heph-core/src/sqlite/migrations.rs b/crates/heph-core/src/sqlite/migrations.rs new file mode 100644 index 0000000..62a7e55 --- /dev/null +++ b/crates/heph-core/src/sqlite/migrations.rs @@ -0,0 +1,112 @@ +//! Schema migrations, applied in order against SQLite's `user_version`. +//! +//! Each migration is `(version, sql)`. On open we read `PRAGMA user_version` +//! and apply every migration whose version is greater, bumping the pragma as we +//! go. Migrations are additive across build slices (tech-spec §4.5 is a +//! starting point; later slices add tables such as `nodes_fts`). + +use crate::error::Result; +use rusqlite::Connection; + +/// The ordered list of migrations. Never reorder or mutate a shipped entry — +/// only append. +const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_0001)]; + +/// v1 — the base node graph, identity, and sync scaffolding (tech-spec §4.5). +const MIGRATION_0001: &str = r#" +CREATE TABLE users ( + id TEXT PRIMARY KEY, + oidc_sub TEXT UNIQUE, + name TEXT, + created_at INTEGER NOT NULL +); + +CREATE TABLE nodes ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + kind TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT, + body_crdt BLOB, + created_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + hlc TEXT NOT NULL, + tombstoned INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_nodes_owner_kind ON nodes(owner_id, kind); + +CREATE TABLE tasks ( + node_id TEXT PRIMARY KEY REFERENCES nodes(id), + attention TEXT, + do_date INTEGER, + late_on INTEGER, + state TEXT NOT NULL, + recurrence TEXT +); + +CREATE TABLE links ( + id TEXT PRIMARY KEY, + src_id TEXT NOT NULL REFERENCES nodes(id), + dst_id TEXT NOT NULL REFERENCES nodes(id), + type TEXT NOT NULL, + created_at INTEGER NOT NULL, + tombstoned INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_links_src ON links(src_id, type); +CREATE INDEX idx_links_dst ON links(dst_id, type); + +CREATE TABLE aliases ( + node_id TEXT REFERENCES nodes(id), + alias TEXT +); +CREATE INDEX idx_aliases_alias ON aliases(alias); + +CREATE TABLE oplog ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + hlc TEXT NOT NULL, + origin TEXT NOT NULL, + op_type TEXT NOT NULL, + target_id TEXT NOT NULL, + payload TEXT NOT NULL, + applied INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE sync_state ( + peer TEXT PRIMARY KEY, + last_pushed_hlc TEXT, + last_pulled_hlc TEXT, + updated_at INTEGER NOT NULL +); + +CREATE TABLE conflicts ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + node_id TEXT NOT NULL REFERENCES nodes(id), + field TEXT NOT NULL, + local_val TEXT, + remote_val TEXT, + local_hlc TEXT, + remote_hlc TEXT, + status TEXT NOT NULL, + created_at INTEGER NOT NULL +); +"#; + +/// Apply all pending migrations to `conn`. +pub fn migrate(conn: &Connection) -> Result<()> { + let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; + for &(version, sql) in MIGRATIONS { + if version > current { + conn.execute_batch(sql)?; + // user_version doesn't accept bound params; the value is a trusted const. + conn.execute_batch(&format!("PRAGMA user_version = {version}"))?; + } + } + Ok(()) +} + +/// The schema version this build migrates up to (the latest migration number). +pub fn latest_version() -> i64 { + MIGRATIONS.last().map(|&(v, _)| v).unwrap_or(0) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs new file mode 100644 index 0000000..6db3734 --- /dev/null +++ b/crates/heph-core/src/sqlite/mod.rs @@ -0,0 +1,213 @@ +//! SQLite-backed [`Store`] implementation. +//! +//! `LocalStore` opens a SQLite file directly. The exclusive-lock handoff of +//! tech-spec §3.1 is layered on by `hephd` when it owns the file; the store +//! itself stays a thin, synchronous SQLite wrapper so it is trivially testable +//! against an in-memory database. + +mod migrations; + +pub use migrations::latest_version; + +use std::path::Path; + +use rusqlite::{Connection, OptionalExtension, Row}; +use ulid::Ulid; + +use crate::clock::Clock; +use crate::error::Result; +use crate::model::{NewNode, Node, NodeKind}; +use crate::store::Store; + +/// A SQLite file (or in-memory database) opened directly as a backend. +pub struct LocalStore { + conn: Connection, + owner_id: String, + clock: Box, +} + +impl LocalStore { + /// Open (creating if needed) a SQLite database at `path`. + pub fn open(path: impl AsRef, clock: Box) -> Result { + let conn = Connection::open(path)?; + Self::init(conn, clock) + } + + /// Open a throwaway in-memory database — for tests. + pub fn open_in_memory(clock: Box) -> Result { + let conn = Connection::open_in_memory()?; + Self::init(conn, clock) + } + + fn init(conn: Connection, clock: Box) -> Result { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + migrations::migrate(&conn)?; + let owner_id = ensure_local_user(&conn, clock.as_ref())?; + Ok(LocalStore { + conn, + owner_id, + clock, + }) + } + + /// The id of the user whose data this store reads/writes. + /// + /// For a local-only instance this is the single generated local user + /// (`oidc_sub = NULL`, tech-spec §13). + pub fn owner_id(&self) -> &str { + &self.owner_id + } + + /// Placeholder HLC string until the real hybrid logical clock lands (§12). + /// + /// Zero-padded epoch ms keeps it lexically sortable in the meantime. + fn next_hlc(&self, now_ms: i64) -> String { + format!("{now_ms:016}") + } +} + +/// Ensure a single local user exists, returning its id. +fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result { + if let Some(id) = conn + .query_row( + "SELECT id FROM users ORDER BY created_at LIMIT 1", + [], + |r| r.get::<_, String>(0), + ) + .optional()? + { + return Ok(id); + } + let id = Ulid::new().to_string(); + conn.execute( + "INSERT INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'local', ?2)", + (&id, clock.now_ms()), + )?; + Ok(id) +} + +/// Map a `nodes` row to a [`Node`]. Column order must match the SELECT. +fn row_to_node(row: &Row) -> rusqlite::Result { + Ok(Node { + id: row.get("id")?, + owner_id: row.get("owner_id")?, + kind: NodeKind::parse(&row.get::<_, String>("kind")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + title: row.get("title")?, + body: row.get("body")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + hlc: row.get("hlc")?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + }) +} + +const NODE_COLUMNS: &str = + "id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned"; + +impl Store for LocalStore { + fn create_node(&mut self, input: NewNode) -> Result { + let now = self.clock.now_ms(); + let node = Node { + id: Ulid::new().to_string(), + owner_id: self.owner_id.clone(), + kind: input.kind, + title: input.title, + body: input.body, + created_at: now, + modified_at: now, + hlc: self.next_hlc(now), + tombstoned: false, + }; + self.conn.execute( + "INSERT INTO nodes (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)", + ( + &node.id, + &node.owner_id, + node.kind.as_str(), + &node.title, + &node.body, + node.created_at, + node.modified_at, + &node.hlc, + ), + )?; + Ok(node) + } + + fn get_node(&self, id: &str) -> Result> { + let node = self + .conn + .query_row( + &format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1"), + [id], + row_to_node, + ) + .optional()?; + Ok(node) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::FixedClock; + + fn store_at(now_ms: i64) -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(now_ms))).expect("open in-memory store") + } + + #[test] + fn migrations_bring_schema_to_latest() { + let store = store_at(0); + let v: i64 = store + .conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, latest_version()); + } + + #[test] + fn opening_twice_is_idempotent_for_the_local_user() { + // Re-running init against the same connection-equivalent must not create + // a second local user; ensure_local_user is the guard. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); + migrations::migrate(&conn).unwrap(); + let a = ensure_local_user(&conn, &FixedClock(1)).unwrap(); + let b = ensure_local_user(&conn, &FixedClock(2)).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn create_then_get_round_trips() { + let mut store = store_at(1_700_000_000_000); + let created = store + .create_node(NewNode::doc( + "Roof leak log", + "# Roof\n\nCalled contractor.", + )) + .unwrap(); + + assert_eq!(created.kind, NodeKind::Doc); + assert_eq!(created.title, "Roof leak log"); + assert_eq!( + created.body.as_deref(), + Some("# Roof\n\nCalled contractor.") + ); + assert_eq!(created.created_at, 1_700_000_000_000); + assert_eq!(created.modified_at, 1_700_000_000_000); + assert!(!created.tombstoned); + assert_eq!(created.owner_id, store.owner_id()); + + let fetched = store.get_node(&created.id).unwrap(); + assert_eq!(fetched.as_ref(), Some(&created)); + } + + #[test] + fn get_missing_node_is_none() { + let store = store_at(0); + assert_eq!(store.get_node("01ARYZ6S41NONEXISTENT00000").unwrap(), None); + } +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs new file mode 100644 index 0000000..6c7a8ed --- /dev/null +++ b/crates/heph-core/src/store.rs @@ -0,0 +1,23 @@ +//! The storage abstraction (tech-spec §3.1). +//! +//! A runtime points at *something that stores nodes*; whether that is a local +//! SQLite file ([`crate::sqlite::LocalStore`]) or a remote server (a future +//! `RemoteStore`) is configuration. This trait is the seam. + +use crate::error::Result; +use crate::model::{NewNode, Node}; + +/// A backend that can store and retrieve nodes. +/// +/// Methods that mutate take `&mut self`: a `LocalStore` holds an exclusive lock +/// on its file, so single-writer semantics are honest at the type level. +pub trait Store { + /// Create a node, assigning it an id and timestamps. Returns the stored row. + fn create_node(&mut self, input: NewNode) -> Result; + + /// Fetch a node by id. Returns `None` if it does not exist. + /// + /// Tombstoned nodes are still returned here (callers that must exclude them + /// — `next`, `list`, `search`, `export` — filter explicitly). + fn get_node(&self, id: &str) -> Result>; +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md new file mode 100644 index 0000000..df5c325 --- /dev/null +++ b/docs/changelog.d/v1-prototype.feature.md @@ -0,0 +1 @@ +Begin the v1 prototype (Phase 1): scaffold the Cargo workspace and the `heph-core` crate with a migration-run SQLite schema (tech-spec §4.5), a clock-injected `Store` trait, and a `LocalStore` backend with node create/get. CI now runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From 1995e0e3cf556555ab910a520e2a46e31e77390c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 18:56:59 -0700 Subject: [PATCH 02/91] heph-core: markdown extraction (wiki-links + checkboxes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 (tech-spec §5). Pure, deterministic derivation from a body: - `[[wiki-links]]` → wiki-link targets, in first-seen order, deduped, honoring `[[target|display]]`. Scans the raw body (CommonMark mangles `[[ ]]` brackets in inline parsing) and excludes matches inside code, whose byte ranges come from pulldown-cmark's offset iterator. - GFM `- [ ]` / `- [x]` task items → the local context-item index (Fork A): label keeps raw markdown (for promotion) + checked state. - Code blocks are correctly skipped for both. 10 extraction unit tests incl. idempotency; 14 total green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 24 +++ Cargo.toml | 1 + crates/heph-core/Cargo.toml | 1 + crates/heph-core/src/extract.rs | 231 +++++++++++++++++++++++ crates/heph-core/src/lib.rs | 2 + docs/changelog.d/v1-prototype.feature.md | 6 +- 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 crates/heph-core/src/extract.rs diff --git a/Cargo.lock b/Cargo.lock index 766cb10..f36f4a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ name = "heph-core" version = "0.0.0" dependencies = [ + "pulldown-cmark", "rusqlite", "thiserror", "ulid", @@ -152,6 +153,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + [[package]] name = "once_cell" version = "1.21.4" @@ -188,6 +195,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.45" @@ -311,6 +329,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index ee065a0..38ddb1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } ulid = "1" thiserror = "2" anyhow = "1" +pulldown-cmark = { version = "0.13", default-features = false } [profile.release] lto = "thin" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index f70dc26..803eae7 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -12,3 +12,4 @@ rust-version.workspace = true rusqlite.workspace = true ulid.workspace = true thiserror.workspace = true +pulldown-cmark.workspace = true diff --git a/crates/heph-core/src/extract.rs b/crates/heph-core/src/extract.rs new file mode 100644 index 0000000..cf6f4e4 --- /dev/null +++ b/crates/heph-core/src/extract.rs @@ -0,0 +1,231 @@ +//! Markdown derivation (tech-spec §5). +//! +//! From a node's body we derive two things, purely and deterministically: +//! +//! - **`[[wiki-links]]`** → `wiki` link targets (resolved to nodes later, via +//! `aliases`/title; unresolved targets are allowed and recorded). +//! - **GFM task-list items** (`- [ ]` / `- [x]`) → the **local context-item +//! index** (Fork A, [[design]] §6.3). The `[ ]`/`[x]` marker *is* the item's +//! only state; this index is derived per replica, never synced. +//! +//! Derivation is **idempotent**: the same body always yields the same +//! [`Extraction`]. Code blocks are skipped (a `- [ ]` inside a fenced block is +//! not a task; a `[[link]]` inside one is not a link), which is why this goes +//! through a real CommonMark parser rather than a line scan. + +use std::collections::HashSet; +use std::ops::Range; + +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + +/// A context-item line derived from a body (Fork A). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextItem { + /// The visible label text (markers and surrounding whitespace stripped). + pub text: String, + /// `true` for `- [x]` (not-outstanding), `false` for `- [ ]` (outstanding). + pub checked: bool, +} + +/// Everything derived from a single body. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Extraction { + /// Wiki-link targets, in first-seen document order, de-duplicated. + pub wiki_links: Vec, + /// Context items, in document order. + pub context_items: Vec, +} + +/// Derive [`Extraction`] from a markdown body. +pub fn extract(body: &str) -> Extraction { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TASKLISTS); + + let mut context_items: Vec = Vec::new(); + // Byte ranges covered by code (fenced/indented blocks and inline spans). + // Wiki-links found inside these are not links. + let mut code_ranges: Vec> = Vec::new(); + // Depth of nested code blocks; their inner text ranges are code. + let mut code_depth: u32 = 0; + // The task item currently being collected, if any: (checked, accumulated text). + let mut current: Option<(bool, String)> = None; + + for (event, range) in Parser::new_ext(body, options).into_offset_iter() { + match event { + Event::Start(Tag::CodeBlock(_)) => code_depth += 1, + Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1), + + Event::TaskListMarker(checked) => { + current = Some((checked, String::new())); + } + Event::End(TagEnd::Item) => { + if let Some((checked, text)) = current.take() { + context_items.push(ContextItem { + checked, + text: text.trim().to_string(), + }); + } + } + + Event::Text(text) => { + if code_depth > 0 { + code_ranges.push(range); + } + if let Some((_, label)) = current.as_mut() { + label.push_str(&text); + } + } + // Inline code is part of an item's visible label, but its contents + // are never a wiki-link source. + Event::Code(code) => { + code_ranges.push(range); + if let Some((_, label)) = current.as_mut() { + label.push_str(&code); + } + } + Event::SoftBreak | Event::HardBreak => { + if let Some((_, label)) = current.as_mut() { + label.push(' '); + } + } + _ => {} + } + } + + // Scan the raw body for wiki-links (CommonMark mangles `[[ ]]` brackets, so + // we can't rely on Text events), excluding any that start inside code. + let wiki_links = scan_wiki_links(body, &code_ranges); + + Extraction { + wiki_links, + context_items, + } +} + +/// Find `[[target]]` (or `[[target|display]]`) spans in `body`, returning each +/// unique, non-empty target in first-seen order. Matches starting inside a +/// `code` range are skipped. The `[` / `]` delimiters are ASCII, so byte +/// indexing stays on char boundaries. +fn scan_wiki_links(body: &str, code_ranges: &[Range]) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let bytes = body.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'[' && bytes[i + 1] == b'[' { + let rest = &body[i + 2..]; + match rest.find("]]") { + Some(close) => { + let in_code = code_ranges.iter().any(|r| r.contains(&i)); + if !in_code { + let inner = &rest[..close]; + // `[[target|display]]` — the target is the left side. + let target = inner.split('|').next().unwrap_or("").trim(); + if !target.is_empty() && seen.insert(target.to_string()) { + out.push(target.to_string()); + } + } + i += 2 + close + 2; + continue; + } + // Unterminated `[[` — nothing more to find. + None => break, + } + } + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn links(body: &str) -> Vec { + extract(body).wiki_links + } + + fn items(body: &str) -> Vec { + extract(body).context_items + } + + #[test] + fn extracts_simple_wiki_links_in_order() { + assert_eq!( + links("See [[Roof]] then [[Contractor calls]]."), + vec!["Roof".to_string(), "Contractor calls".to_string()] + ); + } + + #[test] + fn wiki_link_target_is_left_of_pipe() { + assert_eq!(links("[[borgmatic|Borgmatic backups]]"), vec!["borgmatic"]); + } + + #[test] + fn wiki_links_are_deduplicated_first_seen_order() { + assert_eq!( + links("[[A]] [[B]] [[A]] [[a]]"), + vec!["A".to_string(), "B".to_string(), "a".to_string()] + ); + } + + #[test] + fn empty_and_unterminated_wiki_links_are_ignored() { + assert!(links("[[]] and [[ ]] and [[oops").is_empty()); + } + + #[test] + fn wiki_links_inside_code_are_not_extracted() { + let body = "real [[Keep]]\n\n```\nnot [[Skip]] here\n```\n"; + assert_eq!(links(body), vec!["Keep"]); + } + + #[test] + fn extracts_checkbox_items_with_state() { + let body = "- [ ] feed birds\n- [x] brush teeth\n"; + assert_eq!( + items(body), + vec![ + ContextItem { + text: "feed birds".to_string(), + checked: false + }, + ContextItem { + text: "brush teeth".to_string(), + checked: true + }, + ] + ); + } + + #[test] + fn checkbox_inside_code_block_is_not_an_item() { + let body = "- [ ] real item\n\n```\n- [ ] not an item\n```\n"; + assert_eq!(items(body).len(), 1); + assert_eq!(items(body)[0].text, "real item"); + } + + #[test] + fn checkbox_item_can_carry_a_wiki_link() { + // A checkbox line is both a context item and a wiki-link source. The + // item label keeps the raw markdown (`[[...]]` intact) so promotion can + // locate and rewrite the source line later (Fork A, §6). + let e = extract("- [ ] call [[Contractor]] back"); + assert_eq!(e.wiki_links, vec!["Contractor"]); + assert_eq!(e.context_items.len(), 1); + assert_eq!(e.context_items[0].text, "call [[Contractor]] back"); + assert!(!e.context_items[0].checked); + } + + #[test] + fn extraction_is_idempotent() { + let body = "# Mixed\n\n- [ ] do [[X]]\n- [x] done\n\nsee [[Y]]\n"; + assert_eq!(extract(body), extract(body)); + } + + #[test] + fn body_without_links_or_items_yields_empty() { + assert_eq!(extract("just prose, no structure"), Extraction::default()); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 566774d..41eea60 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -10,12 +10,14 @@ pub mod clock; pub mod error; +pub mod extract; pub mod model; pub mod sqlite; pub mod store; pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; +pub use extract::{extract, ContextItem, Extraction}; pub use model::{NewNode, Node, NodeKind}; pub use sqlite::LocalStore; pub use store::Store; diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index df5c325..8c80911 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -1 +1,5 @@ -Begin the v1 prototype (Phase 1): scaffold the Cargo workspace and the `heph-core` crate with a migration-run SQLite schema (tech-spec §4.5), a clock-injected `Store` trait, and a `LocalStore` backend with node create/get. CI now runs the Rust suite (fmt/clippy/test) via the project build hook. +Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: + +- Cargo workspace + `heph-core` crate; migration-run SQLite schema (§4.5); clock-injected `Store` trait + `LocalStore` node create/get; single local-user bootstrap. +- Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks. +- CI runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From b9d2072f755dfe5517b3d3fd6b3330e3132a99c9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 19:02:35 -0700 Subject: [PATCH 03/91] heph-core: tasks, links, canonical-context doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 (tech-spec §4.2–§4.3, §6). Refactor the SQLite layer into focused submodules (nodes/tasks/links) behind a thin delegating Store impl so a transaction can span several. - Model: Attention (white/orange/red/blue), TaskState (outstanding/done/dropped), LinkType, Link, Task, NewTask. - create_task: in one transaction mints the task node + tasks row, the canonical context doc, the canonical-context link, and an optional in-project link. get_task / set_task_state / set_task_attention. - Links CRUD: add_link, outgoing_links, backlinks (non-tombstoned). - update_node: a body change re-runs extraction and reconciles this node's wiki links — diff-based and idempotent, resolved via alias then exact title (owner-scoped); unresolved targets link on a later edit once the target exists; dropped targets are tombstoned. 20 tests green (12 unit + 8 integration). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/lib.rs | 2 +- crates/heph-core/src/model.rs | 173 ++++++++++++++++++ crates/heph-core/src/sqlite/links.rs | 148 +++++++++++++++ crates/heph-core/src/sqlite/mod.rs | 160 +++++++--------- crates/heph-core/src/sqlite/nodes.rs | 147 +++++++++++++++ crates/heph-core/src/sqlite/tasks.rs | 136 ++++++++++++++ crates/heph-core/src/store.rs | 42 ++++- crates/heph-core/tests/tasks_and_links.rs | 212 ++++++++++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + 9 files changed, 922 insertions(+), 99 deletions(-) create mode 100644 crates/heph-core/src/sqlite/links.rs create mode 100644 crates/heph-core/src/sqlite/nodes.rs create mode 100644 crates/heph-core/src/sqlite/tasks.rs create mode 100644 crates/heph-core/tests/tasks_and_links.rs diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 41eea60..6b3eeef 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -18,6 +18,6 @@ pub mod store; pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use extract::{extract, ContextItem, Extraction}; -pub use model::{NewNode, Node, NodeKind}; +pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; pub use sqlite::LocalStore; pub use store::Store; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 2278999..1525396 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -68,6 +68,179 @@ pub struct Node { pub tombstoned: bool, } +/// A task's attention-state — the lived colour discipline ([[design]] §6.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Attention { + /// Default — actionable once the do-date arrives. + White, + /// Top of mind (keep ≤ 6). + Orange, + /// Top of mind **+ a consequence exists if late** (consequence, not severity). + Red, + /// On-deck / backlog, deliberately cooling off (hidden from `next`). + Blue, +} + +impl Attention { + /// The storage string. + pub fn as_str(self) -> &'static str { + match self { + Attention::White => "white", + Attention::Orange => "orange", + Attention::Red => "red", + Attention::Blue => "blue", + } + } + + /// Parse a storage string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "white" => Attention::White, + "orange" => Attention::Orange, + "red" => Attention::Red, + "blue" => Attention::Blue, + other => return Err(Error::Integrity(format!("unknown attention: {other}"))), + }) + } +} + +/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped` +/// are both "not outstanding"; the distinction is retained for honesty/history. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + /// Still to be done. + Outstanding, + /// Accomplished. + Done, + /// Let go / dismissed (e.g. during a Blue review). + Dropped, +} + +impl TaskState { + /// The storage string. + pub fn as_str(self) -> &'static str { + match self { + TaskState::Outstanding => "outstanding", + TaskState::Done => "done", + TaskState::Dropped => "dropped", + } + } + + /// Parse a storage string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "outstanding" => TaskState::Outstanding, + "done" => TaskState::Done, + "dropped" => TaskState::Dropped, + other => return Err(Error::Integrity(format!("unknown task state: {other}"))), + }) + } +} + +/// A typed, directional edge between two nodes (tech-spec §4.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkType { + /// Materialized from a `[[link]]` in a body. + Wiki, + /// Task → its auto-created context doc. + CanonicalContext, + /// Doc ↔ task context association. + ContextOf, + /// Task → its append-only log node. + LogOf, + /// Blocking dependency. + Blocks, + /// Hierarchy. + Parent, + /// Node → tag. + Tagged, + /// Task → project. + InProject, +} + +impl LinkType { + /// The storage string. + pub fn as_str(self) -> &'static str { + match self { + LinkType::Wiki => "wiki", + LinkType::CanonicalContext => "canonical-context", + LinkType::ContextOf => "context-of", + LinkType::LogOf => "log-of", + LinkType::Blocks => "blocks", + LinkType::Parent => "parent", + LinkType::Tagged => "tagged", + LinkType::InProject => "in-project", + } + } + + /// Parse a storage string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "wiki" => LinkType::Wiki, + "canonical-context" => LinkType::CanonicalContext, + "context-of" => LinkType::ContextOf, + "log-of" => LinkType::LogOf, + "blocks" => LinkType::Blocks, + "parent" => LinkType::Parent, + "tagged" => LinkType::Tagged, + "in-project" => LinkType::InProject, + other => return Err(Error::Integrity(format!("unknown link type: {other}"))), + }) + } +} + +/// A persisted link (a row of the `links` table). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Link { + /// ULID id. + pub id: String, + /// Source node id. + pub src_id: String, + /// Destination node id. + pub dst_id: String, + /// The edge type. + pub link_type: LinkType, + /// Creation time, epoch ms. + pub created_at: i64, + /// Whether tombstoned. + pub tombstoned: bool, +} + +/// A persisted committed task (a `tasks` row joined to its node id). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Task { + /// The id of the backing `task` node. + pub node_id: String, + /// Attention-state (the colour discipline). + pub attention: Option, + /// Earliest-actionable date, epoch ms (candidacy gate only, §7). + pub do_date: Option, + /// When lateness becomes a problem, epoch ms (the sole urgency signal, §7). + pub late_on: Option, + /// Lifecycle state. + pub state: TaskState, + /// RFC-5545 RRULE; present ⇒ a recurring definition (§4.4). + pub recurrence: Option, +} + +/// Input for creating a committed task. The canonical context `doc` and the +/// `canonical-context` link are created automatically (tech-spec §6). +#[derive(Debug, Clone, Default)] +pub struct NewTask { + /// Title (shared by the task node and its canonical context doc). + pub title: String, + /// Attention-state. + pub attention: Option, + /// Earliest-actionable date, epoch ms. + pub do_date: Option, + /// Lateness-problem marker, epoch ms. + pub late_on: Option, + /// RRULE for a recurring definition. + pub recurrence: Option, + /// Optional project node to link via `in-project`. + pub project_id: Option, +} + /// Input for creating a node. #[derive(Debug, Clone)] pub struct NewNode { diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs new file mode 100644 index 0000000..87bb5f9 --- /dev/null +++ b/crates/heph-core/src/sqlite/links.rs @@ -0,0 +1,148 @@ +//! `links` table operations, plus `wiki` link materialization from bodies. + +use std::collections::HashSet; + +use rusqlite::{Connection, OptionalExtension, Row}; + +use super::new_id; +use crate::error::Result; +use crate::extract::extract; +use crate::model::{Link, LinkType}; + +const COLUMNS: &str = "id, src_id, dst_id, type, created_at, tombstoned"; + +fn from_row(row: &Row) -> rusqlite::Result { + Ok(Link { + id: row.get("id")?, + src_id: row.get("src_id")?, + dst_id: row.get("dst_id")?, + link_type: LinkType::parse(&row.get::<_, String>("type")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + created_at: row.get("created_at")?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + }) +} + +/// Add a typed link. +pub(super) fn add( + conn: &Connection, + now: i64, + src_id: &str, + dst_id: &str, + link_type: LinkType, +) -> Result { + let link = Link { + id: new_id(), + src_id: src_id.to_string(), + dst_id: dst_id.to_string(), + link_type, + created_at: now, + tombstoned: false, + }; + conn.execute( + "INSERT INTO links (id, src_id, dst_id, type, created_at, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, 0)", + ( + &link.id, + &link.src_id, + &link.dst_id, + link.link_type.as_str(), + link.created_at, + ), + )?; + Ok(link) +} + +/// All non-tombstoned links originating at `id`. +pub(super) fn outgoing(conn: &Connection, id: &str) -> Result> { + query(conn, "src_id", id) +} + +/// All non-tombstoned links pointing at `id`. +pub(super) fn backlinks(conn: &Connection, id: &str) -> Result> { + query(conn, "dst_id", id) +} + +fn query(conn: &Connection, column: &str, id: &str) -> Result> { + let sql = format!( + "SELECT {COLUMNS} FROM links WHERE {column} = ?1 AND tombstoned = 0 ORDER BY created_at, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([id], from_row)?; + Ok(rows.collect::>>()?) +} + +/// Reconcile the `wiki` links out of `src_id` to match the resolvable +/// `[[wiki-links]]` in `body`. Diff-based and idempotent: unchanged bodies +/// produce no writes. Targets that don't resolve to a node are left for a later +/// re-sync once the target exists (tech-spec §5). +pub(super) fn sync_wiki_links( + conn: &Connection, + owner: &str, + src_id: &str, + body: &str, + now: i64, +) -> Result<()> { + // Desired set: resolved destination node ids, de-duplicated, order-stable. + let mut desired: Vec = Vec::new(); + let mut desired_set: HashSet = HashSet::new(); + for target in extract(body).wiki_links { + if let Some(dst) = resolve(conn, owner, &target)? { + if dst != src_id && desired_set.insert(dst.clone()) { + desired.push(dst); + } + } + } + + // Existing wiki links from this source. + let existing: Vec<(String, String)> = { + let mut stmt = conn.prepare( + "SELECT id, dst_id FROM links + WHERE src_id = ?1 AND type = 'wiki' AND tombstoned = 0", + )?; + let rows = stmt.query_map([src_id], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::>>()? + }; + let existing_dsts: HashSet<&str> = existing.iter().map(|(_, d)| d.as_str()).collect(); + + // Tombstone links whose target is no longer referenced. + for (link_id, dst) in &existing { + if !desired_set.contains(dst) { + conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; + } + } + // Add links for newly-referenced targets. + for dst in &desired { + if !existing_dsts.contains(dst.as_str()) { + add(conn, now, src_id, dst, LinkType::Wiki)?; + } + } + Ok(()) +} + +/// Resolve a wiki-link target to a node id for this owner, matching an alias +/// first, then an exact title. `None` if nothing matches. +fn resolve(conn: &Connection, owner: &str, target: &str) -> Result> { + let by_alias: Option = conn + .query_row( + "SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id + WHERE a.alias = ?1 AND n.owner_id = ?2 AND n.tombstoned = 0 + ORDER BY n.created_at, n.id LIMIT 1", + (target, owner), + |r| r.get(0), + ) + .optional()?; + if by_alias.is_some() { + return Ok(by_alias); + } + let by_title: Option = conn + .query_row( + "SELECT id FROM nodes + WHERE title = ?1 AND owner_id = ?2 AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + (target, owner), + |r| r.get(0), + ) + .optional()?; + Ok(by_title) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 6db3734..1979b33 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -4,19 +4,26 @@ //! tech-spec §3.1 is layered on by `hephd` when it owns the file; the store //! itself stays a thin, synchronous SQLite wrapper so it is trivially testable //! against an in-memory database. +//! +//! The query logic lives in focused submodules ([`nodes`], [`tasks`], [`links`]) +//! as free functions over a `&Connection`; the [`Store`] impl here is a thin +//! delegating layer so a transaction can span several of them. +mod links; mod migrations; +mod nodes; +mod tasks; pub use migrations::latest_version; use std::path::Path; -use rusqlite::{Connection, OptionalExtension, Row}; +use rusqlite::{Connection, OptionalExtension}; use ulid::Ulid; use crate::clock::Clock; use crate::error::Result; -use crate::model::{NewNode, Node, NodeKind}; +use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::store::Store; /// A SQLite file (or in-memory database) opened directly as a backend. @@ -57,13 +64,17 @@ impl LocalStore { pub fn owner_id(&self) -> &str { &self.owner_id } +} - /// Placeholder HLC string until the real hybrid logical clock lands (§12). - /// - /// Zero-padded epoch ms keeps it lexically sortable in the meantime. - fn next_hlc(&self, now_ms: i64) -> String { - format!("{now_ms:016}") - } +/// A fresh ULID, as a string id. +pub(crate) fn new_id() -> String { + Ulid::new().to_string() +} + +/// Placeholder HLC string until the real hybrid logical clock lands (§12). +/// Zero-padded epoch ms keeps it lexically sortable in the meantime. +pub(crate) fn hlc_for(now_ms: i64) -> String { + format!("{now_ms:016}") } /// Ensure a single local user exists, returning its id. @@ -78,7 +89,7 @@ fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result { { return Ok(id); } - let id = Ulid::new().to_string(); + let id = new_id(); conn.execute( "INSERT INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'local', ?2)", (&id, clock.now_ms()), @@ -86,66 +97,56 @@ fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result { Ok(id) } -/// Map a `nodes` row to a [`Node`]. Column order must match the SELECT. -fn row_to_node(row: &Row) -> rusqlite::Result { - Ok(Node { - id: row.get("id")?, - owner_id: row.get("owner_id")?, - kind: NodeKind::parse(&row.get::<_, String>("kind")?) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, - title: row.get("title")?, - body: row.get("body")?, - created_at: row.get("created_at")?, - modified_at: row.get("modified_at")?, - hlc: row.get("hlc")?, - tombstoned: row.get::<_, i64>("tombstoned")? != 0, - }) -} - -const NODE_COLUMNS: &str = - "id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned"; - impl Store for LocalStore { fn create_node(&mut self, input: NewNode) -> Result { let now = self.clock.now_ms(); - let node = Node { - id: Ulid::new().to_string(), - owner_id: self.owner_id.clone(), - kind: input.kind, - title: input.title, - body: input.body, - created_at: now, - modified_at: now, - hlc: self.next_hlc(now), - tombstoned: false, - }; - self.conn.execute( - "INSERT INTO nodes (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)", - ( - &node.id, - &node.owner_id, - node.kind.as_str(), - &node.title, - &node.body, - node.created_at, - node.modified_at, - &node.hlc, - ), - )?; - Ok(node) + nodes::create(&self.conn, &self.owner_id, now, input) } fn get_node(&self, id: &str) -> Result> { - let node = self - .conn - .query_row( - &format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1"), - [id], - row_to_node, - ) - .optional()?; - Ok(node) + nodes::get(&self.conn, id) + } + + fn update_node( + &mut self, + id: &str, + title: Option, + body: Option, + ) -> Result { + let now = self.clock.now_ms(); + nodes::update(&mut self.conn, &self.owner_id, now, id, title, body) + } + + fn create_task(&mut self, input: NewTask) -> Result { + let now = self.clock.now_ms(); + tasks::create(&mut self.conn, &self.owner_id, now, input) + } + + fn get_task(&self, node_id: &str) -> Result> { + tasks::get(&self.conn, node_id) + } + + fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result { + let now = self.clock.now_ms(); + tasks::set_state(&self.conn, now, node_id, state) + } + + fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result { + let now = self.clock.now_ms(); + tasks::set_attention(&self.conn, now, node_id, attention) + } + + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { + let now = self.clock.now_ms(); + links::add(&self.conn, now, src_id, dst_id, link_type) + } + + fn outgoing_links(&self, id: &str) -> Result> { + links::outgoing(&self.conn, id) + } + + fn backlinks(&self, id: &str) -> Result> { + links::backlinks(&self.conn, id) } } @@ -170,8 +171,6 @@ mod tests { #[test] fn opening_twice_is_idempotent_for_the_local_user() { - // Re-running init against the same connection-equivalent must not create - // a second local user; ensure_local_user is the guard. let conn = Connection::open_in_memory().unwrap(); conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); migrations::migrate(&conn).unwrap(); @@ -179,35 +178,4 @@ mod tests { let b = ensure_local_user(&conn, &FixedClock(2)).unwrap(); assert_eq!(a, b); } - - #[test] - fn create_then_get_round_trips() { - let mut store = store_at(1_700_000_000_000); - let created = store - .create_node(NewNode::doc( - "Roof leak log", - "# Roof\n\nCalled contractor.", - )) - .unwrap(); - - assert_eq!(created.kind, NodeKind::Doc); - assert_eq!(created.title, "Roof leak log"); - assert_eq!( - created.body.as_deref(), - Some("# Roof\n\nCalled contractor.") - ); - assert_eq!(created.created_at, 1_700_000_000_000); - assert_eq!(created.modified_at, 1_700_000_000_000); - assert!(!created.tombstoned); - assert_eq!(created.owner_id, store.owner_id()); - - let fetched = store.get_node(&created.id).unwrap(); - assert_eq!(fetched.as_ref(), Some(&created)); - } - - #[test] - fn get_missing_node_is_none() { - let store = store_at(0); - assert_eq!(store.get_node("01ARYZ6S41NONEXISTENT00000").unwrap(), None); - } } diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs new file mode 100644 index 0000000..a955749 --- /dev/null +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -0,0 +1,147 @@ +//! `nodes` table operations. + +use rusqlite::{Connection, OptionalExtension, Row}; + +use super::{hlc_for, links, new_id}; +use crate::error::{Error, Result}; +use crate::model::{NewNode, Node, NodeKind}; + +/// The `nodes` columns in a fixed order, shared by every SELECT here. +pub(super) const COLUMNS: &str = + "id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned"; + +/// Build an in-memory [`Node`] (not yet persisted). +pub(super) fn build( + owner: &str, + now: i64, + kind: NodeKind, + title: String, + body: Option, +) -> Node { + Node { + id: new_id(), + owner_id: owner.to_string(), + kind, + title, + body, + created_at: now, + modified_at: now, + hlc: hlc_for(now), + tombstoned: false, + } +} + +/// Insert a fully-formed [`Node`] row. +pub(super) fn insert(conn: &Connection, node: &Node) -> Result<()> { + conn.execute( + "INSERT INTO nodes (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ( + &node.id, + &node.owner_id, + node.kind.as_str(), + &node.title, + &node.body, + node.created_at, + node.modified_at, + &node.hlc, + node.tombstoned as i64, + ), + )?; + Ok(()) +} + +/// Map a `nodes` row (selected with [`COLUMNS`]) to a [`Node`]. +pub(super) fn from_row(row: &Row) -> rusqlite::Result { + Ok(Node { + id: row.get("id")?, + owner_id: row.get("owner_id")?, + kind: NodeKind::parse(&row.get::<_, String>("kind")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + title: row.get("title")?, + body: row.get("body")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + hlc: row.get("hlc")?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + }) +} + +/// Create and persist a node. +pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) -> Result { + let node = build(owner, now, input.kind, input.title, input.body); + insert(conn, &node)?; + Ok(node) +} + +/// Fetch a node by id (tombstoned rows included). +pub(super) fn get(conn: &Connection, id: &str) -> Result> { + let node = conn + .query_row( + &format!("SELECT {COLUMNS} FROM nodes WHERE id = ?1"), + [id], + from_row, + ) + .optional()?; + Ok(node) +} + +/// Update a node's title and/or body. A body change re-runs extraction and +/// reconciles this node's `wiki` links (tech-spec §5). +pub(super) fn update( + conn: &mut Connection, + owner: &str, + now: i64, + id: &str, + title: Option, + body: Option, +) -> Result { + let mut node = get(conn, id)?.ok_or_else(|| Error::NodeNotFound(id.to_string()))?; + + if let Some(t) = title { + node.title = t; + } + let body_changed = match body { + Some(b) => { + let changed = node.body.as_deref() != Some(b.as_str()); + node.body = Some(b); + changed + } + None => false, + }; + node.modified_at = now; + node.hlc = hlc_for(now); + + let tx = conn.transaction()?; + tx.execute( + "UPDATE nodes SET title = ?1, body = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", + ( + &node.title, + &node.body, + node.modified_at, + &node.hlc, + &node.id, + ), + )?; + if body_changed { + links::sync_wiki_links( + &tx, + owner, + &node.id, + node.body.as_deref().unwrap_or(""), + now, + )?; + } + tx.commit()?; + Ok(node) +} + +/// Bump `modified_at`/`hlc` on a node (used when a task scalar field changes so +/// the node's modified time reflects the mutation for sync ordering). +pub(super) fn touch(conn: &Connection, now: i64, id: &str) -> Result<()> { + conn.execute( + "UPDATE nodes SET modified_at = ?1, hlc = ?2 WHERE id = ?3", + (now, hlc_for(now), id), + )?; + Ok(()) +} diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs new file mode 100644 index 0000000..7f61915 --- /dev/null +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -0,0 +1,136 @@ +//! `tasks` table operations. +//! +//! A committed task is a `task` node plus a `tasks` row. On creation it also +//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6). + +use rusqlite::{Connection, OptionalExtension, Row}; + +use super::{links, nodes}; +use crate::error::{Error, Result}; +use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; + +fn from_row(row: &Row) -> rusqlite::Result { + let attention = match row.get::<_, Option>("attention")? { + Some(s) => Some( + Attention::parse(&s) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + ), + None => None, + }; + Ok(Task { + node_id: row.get("node_id")?, + attention, + do_date: row.get("do_date")?, + late_on: row.get("late_on")?, + state: TaskState::parse(&row.get::<_, String>("state")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + recurrence: row.get("recurrence")?, + }) +} + +const COLUMNS: &str = "node_id, attention, do_date, late_on, state, recurrence"; + +/// Create a committed task: its task node, the `tasks` row, the canonical +/// context doc, the `canonical-context` link, and (if given) an `in-project` +/// link — all in one transaction. +pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTask) -> Result { + let task = Task { + node_id: String::new(), // filled below + attention: input.attention, + do_date: input.do_date, + late_on: input.late_on, + state: TaskState::Outstanding, + recurrence: input.recurrence, + }; + + let tx = conn.transaction()?; + + let task_node = nodes::build(owner, now, NodeKind::Task, input.title.clone(), None); + nodes::insert(&tx, &task_node)?; + tx.execute( + "INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ( + &task_node.id, + task.attention.map(|a| a.as_str()), + task.do_date, + task.late_on, + task.state.as_str(), + &task.recurrence, + ), + )?; + + // The canonical context doc (the task's jumping-off point / checklist body). + let doc = nodes::build( + owner, + now, + NodeKind::Doc, + input.title.clone(), + Some(String::new()), + ); + nodes::insert(&tx, &doc)?; + links::add(&tx, now, &task_node.id, &doc.id, LinkType::CanonicalContext)?; + + if let Some(project_id) = &input.project_id { + links::add(&tx, now, &task_node.id, project_id, LinkType::InProject)?; + } + + tx.commit()?; + + Ok(Task { + node_id: task_node.id, + ..task + }) +} + +/// Fetch a task by node id. +pub(super) fn get(conn: &Connection, node_id: &str) -> Result> { + let task = conn + .query_row( + &format!("SELECT {COLUMNS} FROM tasks WHERE node_id = ?1"), + [node_id], + from_row, + ) + .optional()?; + Ok(task) +} + +fn require(conn: &Connection, node_id: &str) -> Result { + get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string())) +} + +/// Set a task's lifecycle state. +pub(super) fn set_state( + conn: &Connection, + now: i64, + node_id: &str, + state: TaskState, +) -> Result { + let updated = conn.execute( + "UPDATE tasks SET state = ?1 WHERE node_id = ?2", + (state.as_str(), node_id), + )?; + if updated == 0 { + return Err(Error::NodeNotFound(node_id.to_string())); + } + nodes::touch(conn, now, node_id)?; + require(conn, node_id) +} + +/// Set a task's attention-state. +pub(super) fn set_attention( + conn: &Connection, + now: i64, + node_id: &str, + attention: Attention, +) -> Result { + let updated = conn.execute( + "UPDATE tasks SET attention = ?1 WHERE node_id = ?2", + (attention.as_str(), node_id), + )?; + if updated == 0 { + return Err(Error::NodeNotFound(node_id.to_string())); + } + nodes::touch(conn, now, node_id)?; + require(conn, node_id) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 6c7a8ed..7465646 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,13 +5,15 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; -use crate::model::{NewNode, Node}; +use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; -/// A backend that can store and retrieve nodes. +/// A backend that can store and retrieve nodes, tasks, and links. /// /// Methods that mutate take `&mut self`: a `LocalStore` holds an exclusive lock /// on its file, so single-writer semantics are honest at the type level. pub trait Store { + // --- nodes --- + /// Create a node, assigning it an id and timestamps. Returns the stored row. fn create_node(&mut self, input: NewNode) -> Result; @@ -20,4 +22,40 @@ pub trait Store { /// Tombstoned nodes are still returned here (callers that must exclude them /// — `next`, `list`, `search`, `export` — filter explicitly). fn get_node(&self, id: &str) -> Result>; + + /// Update a node's title and/or body. A body update re-runs markdown + /// extraction and reconciles this node's `wiki` links (tech-spec §5, §6). + fn update_node( + &mut self, + id: &str, + title: Option, + body: Option, + ) -> Result; + + // --- tasks --- + + /// Create a committed task, auto-creating its canonical context `doc` and + /// the `canonical-context` link (tech-spec §6). + fn create_task(&mut self, input: NewTask) -> Result; + + /// Fetch a task by its node id. + fn get_task(&self, node_id: &str) -> Result>; + + /// Set a task's lifecycle state. (Recurrence roll-forward is layered on in + /// a later slice — tech-spec §4.4.) + fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result; + + /// Set a task's attention-state. + fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; + + // --- links --- + + /// Add a typed link between two nodes. + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result; + + /// All non-tombstoned links originating at `id`. + fn outgoing_links(&self, id: &str) -> Result>; + + /// All non-tombstoned links pointing at `id` (backlinks). + fn backlinks(&self, id: &str) -> Result>; } diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs new file mode 100644 index 0000000..ee17402 --- /dev/null +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -0,0 +1,212 @@ +//! Public-API tests for tasks, the canonical context doc, links, and +//! wiki-link materialization (tech-spec §4–§6, slice 3). + +use heph_core::{ + Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store, TaskState, +}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn create_task_makes_a_canonical_context_doc_and_link() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Fix the roof leak".into(), + attention: Some(Attention::Orange), + ..Default::default() + }) + .unwrap(); + + // The task node exists and is a task. + let node = s.get_node(&task.node_id).unwrap().unwrap(); + assert_eq!(node.kind, NodeKind::Task); + assert_eq!(node.title, "Fix the roof leak"); + assert_eq!(node.body, None); + + // It has exactly one canonical-context link to a doc node. + let out = s.outgoing_links(&task.node_id).unwrap(); + let ctx: Vec<_> = out + .iter() + .filter(|l| l.link_type == LinkType::CanonicalContext) + .collect(); + assert_eq!(ctx.len(), 1); + + let doc = s.get_node(&ctx[0].dst_id).unwrap().unwrap(); + assert_eq!(doc.kind, NodeKind::Doc); + assert_eq!(doc.title, "Fix the roof leak"); + assert_eq!(doc.body.as_deref(), Some("")); +} + +#[test] +fn task_scalar_fields_round_trip_and_default_to_outstanding() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Renew passport".into(), + attention: Some(Attention::Red), + do_date: Some(1_700_000_100_000), + late_on: Some(1_700_000_200_000), + ..Default::default() + }) + .unwrap(); + + let got = s.get_task(&task.node_id).unwrap().unwrap(); + assert_eq!(got.attention, Some(Attention::Red)); + assert_eq!(got.do_date, Some(1_700_000_100_000)); + assert_eq!(got.late_on, Some(1_700_000_200_000)); + assert_eq!(got.state, TaskState::Outstanding); + assert_eq!(got.recurrence, None); +} + +#[test] +fn set_state_and_attention_persist() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Pay invoice".into(), + ..Default::default() + }) + .unwrap(); + + s.set_task_attention(&task.node_id, Attention::Blue) + .unwrap(); + let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(t.state, TaskState::Done); + assert_eq!(t.attention, Some(Attention::Blue)); + + let reread = s.get_task(&task.node_id).unwrap().unwrap(); + assert_eq!(reread.state, TaskState::Done); + assert_eq!(reread.attention, Some(Attention::Blue)); +} + +#[test] +fn project_link_is_created_when_given() { + let mut s = store(); + let project = s + .create_node(NewNode { + kind: NodeKind::Project, + title: "Chores".into(), + body: None, + }) + .unwrap(); + let task = s + .create_task(NewTask { + title: "Take out trash".into(), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + + let has_project = s + .outgoing_links(&task.node_id) + .unwrap() + .iter() + .any(|l| l.link_type == LinkType::InProject && l.dst_id == project.id); + assert!(has_project); +} + +#[test] +fn updating_a_body_materializes_resolved_wiki_links() { + let mut s = store(); + let target = s + .create_node(NewNode { + kind: NodeKind::Doc, + title: "Contractor calls".into(), + body: Some(String::new()), + }) + .unwrap(); + let source = s.create_node(NewNode::doc("Roof log", "")).unwrap(); + + s.update_node(&source.id, None, Some("See [[Contractor calls]].".into())) + .unwrap(); + + let wiki: Vec<_> = s + .outgoing_links(&source.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::Wiki) + .collect(); + assert_eq!(wiki.len(), 1); + assert_eq!(wiki[0].dst_id, target.id); + + // And the target sees it as a backlink. + let back = s.backlinks(&target.id).unwrap(); + assert!(back.iter().any(|l| l.src_id == source.id)); +} + +#[test] +fn unresolved_wiki_link_links_once_target_is_created() { + let mut s = store(); + let source = s.create_node(NewNode::doc("Notes", "")).unwrap(); + + // Target doesn't exist yet → no wiki link. + s.update_node(&source.id, None, Some("plan [[Nursery]] build".into())) + .unwrap(); + assert!(s + .outgoing_links(&source.id) + .unwrap() + .iter() + .all(|l| l.link_type != LinkType::Wiki)); + + // Create the target, then edit the body again → the link now resolves. + let nursery = s.create_node(NewNode::doc("Nursery", "")).unwrap(); + s.update_node(&source.id, None, Some("plan [[Nursery]] build now".into())) + .unwrap(); + + let wiki: Vec<_> = s + .outgoing_links(&source.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::Wiki) + .collect(); + assert_eq!(wiki.len(), 1); + assert_eq!(wiki[0].dst_id, nursery.id); +} + +#[test] +fn wiki_links_reconcile_on_edit_add_and_remove() { + let mut s = store(); + let a = s.create_node(NewNode::doc("A", "")).unwrap(); + let b = s.create_node(NewNode::doc("B", "")).unwrap(); + let src = s.create_node(NewNode::doc("Src", "")).unwrap(); + + s.update_node(&src.id, None, Some("[[A]] and [[B]]".into())) + .unwrap(); + assert_eq!(active_wiki(&s, &src.id), 2); + + // Drop B from the body → its wiki link is tombstoned. + s.update_node(&src.id, None, Some("only [[A]] now".into())) + .unwrap(); + let dsts: Vec = s + .outgoing_links(&src.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::Wiki) + .map(|l| l.dst_id) + .collect(); + assert_eq!(dsts, vec![a.id.clone()]); + assert!(!dsts.contains(&b.id)); +} + +#[test] +fn re_saving_identical_body_does_not_duplicate_wiki_links() { + let mut s = store(); + let _a = s.create_node(NewNode::doc("A", "")).unwrap(); + let src = s.create_node(NewNode::doc("Src", "")).unwrap(); + + s.update_node(&src.id, None, Some("[[A]]".into())).unwrap(); + s.update_node(&src.id, None, Some("[[A]]".into())).unwrap(); + s.update_node(&src.id, None, Some("[[A]]".into())).unwrap(); + assert_eq!(active_wiki(&s, &src.id), 1); +} + +fn active_wiki(s: &LocalStore, id: &str) -> usize { + s.outgoing_links(id) + .unwrap() + .iter() + .filter(|l| l.link_type == LinkType::Wiki) + .count() +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 8c80911..1ff6bfc 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -2,4 +2,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Cargo workspace + `heph-core` crate; migration-run SQLite schema (§4.5); clock-injected `Store` trait + `LocalStore` node create/get; single local-user bootstrap. - Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks. +- Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent). - CI runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From 7f63f926d0a6878f8430b18f029b457c1cafb5cc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 19:07:16 -0700 Subject: [PATCH 04/91] =?UTF-8?q?heph-core:=20"what=20is=20next=3F"=20rank?= =?UTF-8?q?ing=20(tech-spec=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 — the flagship Tactical blank-slate engine. Pure and clock-injected, two stages: - Candidacy filter: committed ∧ outstanding ∧ ¬tombstoned ∧ ≠blue ∧ actionable (do_date NULL or ≤ now) ∧ in scope. do_date is used ONLY here — a boolean "can I do this now?" gate, never urgency. - Order: an ordered list of named Dimensions applied lexicographically (PastLateOn → LateOverdueAmount → Attention band → CreatedAt FIFO), with node_id as final tiebreak for a total order. Reorder RANKING in one place to retune. late_on is the sole urgency signal (global tier); age never becomes urgency. blue hidden; red always shown past limit. Storage `Store::next` loads candidates via a SQL join (project + canonical-context links) and runs the pure engine with the store clock. 13 table-driven unit cases + 3 proptests (antisymmetry, sorted output fully ordered, equality ⇒ identity) + 2 end-to-end. 38 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 167 ++++++++++ crates/heph-core/Cargo.toml | 3 + crates/heph-core/src/lib.rs | 2 + crates/heph-core/src/ranking.rs | 383 +++++++++++++++++++++++ crates/heph-core/src/sqlite/mod.rs | 6 + crates/heph-core/src/sqlite/tasks.rs | 53 ++++ crates/heph-core/src/store.rs | 6 + crates/heph-core/tests/next_ranking.rs | 95 ++++++ docs/changelog.d/v1-prototype.feature.md | 1 + 9 files changed, 716 insertions(+) create mode 100644 crates/heph-core/src/ranking.rs create mode 100644 crates/heph-core/tests/next_ranking.rs diff --git a/Cargo.lock b/Cargo.lock index f36f4a6..62780c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -42,6 +63,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -54,12 +85,24 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures-core" version = "0.3.32" @@ -118,6 +161,7 @@ dependencies = [ name = "heph-core" version = "0.0.0" dependencies = [ + "proptest", "pulldown-cmark", "rusqlite", "thiserror", @@ -153,12 +197,27 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -195,6 +254,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -206,6 +284,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -250,6 +334,21 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rusqlite" version = "0.32.1" @@ -264,12 +363,37 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "shlex" version = "2.0.1" @@ -299,6 +423,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -329,6 +466,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -353,6 +496,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -417,6 +569,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index 803eae7..c4aa4e6 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -13,3 +13,6 @@ rusqlite.workspace = true ulid.workspace = true thiserror.workspace = true pulldown-cmark.workspace = true + +[dev-dependencies] +proptest = "1" diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 6b3eeef..7892cf7 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod clock; pub mod error; pub mod extract; pub mod model; +pub mod ranking; pub mod sqlite; pub mod store; @@ -19,5 +20,6 @@ pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use extract::{extract, ContextItem, Extraction}; pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; +pub use ranking::{rank, Dimension, RankedTask, RANKING}; pub use sqlite::LocalStore; pub use store::Store; diff --git a/crates/heph-core/src/ranking.rs b/crates/heph-core/src/ranking.rs new file mode 100644 index 0000000..bb16120 --- /dev/null +++ b/crates/heph-core/src/ranking.rs @@ -0,0 +1,383 @@ +//! The "what is next?" ranking — the Tactical blank-slate engine (tech-spec §7). +//! +//! Pure and clock-injected. Two stages: +//! +//! 1. **Filter (candidacy)** — a predicate. `do_date` is used *only here*, as a +//! boolean "can this be done now?" gate, never as urgency. +//! 2. **Order** — an ordered list of named [`Dimension`]s applied +//! lexicographically. Reordering [`RANKING`] (one place) reshapes the +//! ranking without touching the comparison logic. +//! +//! `late_on` is the **sole** urgency signal: items past `late_on` float above +//! everything (incl. `red`), most-past first. Age never becomes urgency — the +//! only within-band tiebreak is `created_at` ascending (FIFO). + +use std::cmp::Ordering; + +use crate::model::{Attention, TaskState}; + +/// A task as seen by the ranking engine — the candidacy fields plus the bits +/// the Tactical output row shows. Used as both input and output of [`rank`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RankedTask { + /// The task node id. + pub node_id: String, + /// Title (for the output row). + pub title: String, + /// Attention-state. + pub attention: Option, + /// Earliest-actionable date (candidacy gate only). + pub do_date: Option, + /// Lateness-problem marker (the sole urgency signal). + pub late_on: Option, + /// Lifecycle state. + pub state: TaskState, + /// Whether tombstoned. + pub tombstoned: bool, + /// The task's project node id, if any (for `scope`). + pub project_id: Option, + /// The task's canonical context doc id (the one-keystroke jump). + pub canonical_context_id: Option, + /// Creation time, epoch ms (FIFO tiebreak). + pub created_at: i64, +} + +/// A named sort dimension. The order relation is the [`RANKING`] list applied +/// lexicographically; this enum is what makes that list reorderable as data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dimension { + /// Past `late_on` (a global top tier). Descending: past-late floats up. + PastLateOn, + /// How far past `late_on`. Descending: most-overdue first. + LateOverdueAmount, + /// Attention band. `red` → `orange` → `white`. + Attention, + /// Creation time. Ascending (FIFO). + CreatedAt, +} + +/// The ranking order, as data. Reorder this to retune the sort. +pub const RANKING: &[Dimension] = &[ + Dimension::PastLateOn, + Dimension::LateOverdueAmount, + Dimension::Attention, + Dimension::CreatedAt, +]; + +/// Filter to candidates, order by [`RANKING`], and apply `limit` (default +/// callers pass 5). `red` items always appear regardless of `limit`. +/// +/// `scope`, when `Some`, restricts to tasks in that project node id. +pub fn rank( + tasks: Vec, + now: i64, + scope: Option<&str>, + limit: usize, +) -> Vec { + let mut candidates: Vec = tasks + .into_iter() + .filter(|t| is_candidate(t, now, scope)) + .collect(); + candidates.sort_by(|a, b| order(a, b, now)); + candidates + .into_iter() + .enumerate() + .filter(|(i, t)| *i < limit || t.attention == Some(Attention::Red)) + .map(|(_, t)| t) + .collect() +} + +/// The candidacy predicate (stage 1). `committed` is implicit — every +/// [`RankedTask`] is a committed task. +pub fn is_candidate(t: &RankedTask, now: i64, scope: Option<&str>) -> bool { + t.state == TaskState::Outstanding + && !t.tombstoned + && t.attention != Some(Attention::Blue) + && t.do_date.is_none_or(|d| d <= now) + && scope.is_none_or(|s| t.project_id.as_deref() == Some(s)) +} + +/// The full order relation (stage 2): [`RANKING`] applied lexicographically, +/// with `node_id` as a final tiebreak so the order is total and deterministic. +pub fn order(a: &RankedTask, b: &RankedTask, now: i64) -> Ordering { + RANKING + .iter() + .fold(Ordering::Equal, |acc, &dim| { + acc.then_with(|| compare(dim, a, b, now)) + }) + .then_with(|| a.node_id.cmp(&b.node_id)) +} + +fn compare(dim: Dimension, a: &RankedTask, b: &RankedTask, now: i64) -> Ordering { + match dim { + // Descending: past-late (true) sorts before not-past (false). + Dimension::PastLateOn => past_late_on(b, now).cmp(&past_late_on(a, now)), + // Descending: larger overdue amount first. + Dimension::LateOverdueAmount => overdue_amount(b, now).cmp(&overdue_amount(a, now)), + // Ascending by band index: red(0) < orange(1) < white(2). + Dimension::Attention => attention_rank(a.attention).cmp(&attention_rank(b.attention)), + // Ascending: oldest first (FIFO). + Dimension::CreatedAt => a.created_at.cmp(&b.created_at), + } +} + +fn past_late_on(t: &RankedTask, now: i64) -> bool { + t.late_on.is_some_and(|l| now > l) +} + +fn overdue_amount(t: &RankedTask, now: i64) -> i64 { + t.late_on.map_or(0, |l| now.saturating_sub(l).max(0)) +} + +fn attention_rank(a: Option) -> u8 { + match a { + Some(Attention::Red) => 0, + Some(Attention::Orange) => 1, + Some(Attention::White) => 2, + // Blue is filtered out before ordering; None ranks last. + Some(Attention::Blue) => 3, + None => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a candidate with sensible defaults; override fields per test. + fn task(node_id: &str) -> RankedTask { + RankedTask { + node_id: node_id.to_string(), + title: node_id.to_string(), + attention: Some(Attention::White), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + tombstoned: false, + project_id: None, + canonical_context_id: None, + created_at: 0, + } + } + + const NOW: i64 = 1_000_000; + + fn ids(ranked: &[RankedTask]) -> Vec<&str> { + ranked.iter().map(|t| t.node_id.as_str()).collect() + } + + #[test] + fn empty_in_empty_out() { + assert!(rank(vec![], NOW, None, 5).is_empty()); + } + + #[test] + fn blue_is_hidden() { + let mut b = task("b"); + b.attention = Some(Attention::Blue); + assert!(rank(vec![b], NOW, None, 5).is_empty()); + } + + #[test] + fn non_outstanding_is_filtered() { + let mut done = task("done"); + done.state = TaskState::Done; + let mut dropped = task("dropped"); + dropped.state = TaskState::Dropped; + assert!(rank(vec![done, dropped], NOW, None, 5).is_empty()); + } + + #[test] + fn tombstoned_is_filtered() { + let mut t = task("t"); + t.tombstoned = true; + assert!(rank(vec![t], NOW, None, 5).is_empty()); + } + + #[test] + fn future_do_date_is_not_actionable_but_null_and_past_are() { + let mut future = task("future"); + future.do_date = Some(NOW + 1); + let mut today = task("today"); + today.do_date = Some(NOW); + let null = task("null"); + assert_eq!( + ids(&rank(vec![future, today, null], NOW, None, 5)), + vec!["null", "today"] + ); + } + + #[test] + fn attention_band_orders_red_orange_white() { + let mut red = task("red"); + red.attention = Some(Attention::Red); + let mut orange = task("orange"); + orange.attention = Some(Attention::Orange); + let white = task("white"); + // Feed in a deliberately non-sorted order. + let out = rank(vec![white, red, orange], NOW, None, 5); + assert_eq!(ids(&out), vec!["red", "orange", "white"]); + } + + #[test] + fn past_late_on_floats_above_red() { + // A white task past its late_on beats a red task that isn't late. + let mut late_white = task("late_white"); + late_white.late_on = Some(NOW - 10); + let mut red = task("red"); + red.attention = Some(Attention::Red); + let out = rank(vec![red, late_white], NOW, None, 5); + assert_eq!(ids(&out), vec!["late_white", "red"]); + } + + #[test] + fn most_overdue_late_on_comes_first() { + let mut a = task("a"); + a.late_on = Some(NOW - 5); + let mut b = task("b"); + b.late_on = Some(NOW - 50); + let out = rank(vec![a, b], NOW, None, 5); + assert_eq!(ids(&out), vec!["b", "a"]); + } + + #[test] + fn late_on_in_the_future_is_not_urgent() { + // late_on later than now is not "past late_on" — no urgency boost. + let mut not_yet = task("not_yet"); + not_yet.late_on = Some(NOW + 100); + not_yet.attention = Some(Attention::White); + let mut orange = task("orange"); + orange.attention = Some(Attention::Orange); + let out = rank(vec![not_yet, orange], NOW, None, 5); + assert_eq!(ids(&out), vec!["orange", "not_yet"]); + } + + #[test] + fn fifo_tiebreak_within_band() { + let mut older = task("older"); + older.created_at = 1; + let mut newer = task("newer"); + newer.created_at = 2; + let out = rank(vec![newer, older], NOW, None, 5); + assert_eq!(ids(&out), vec!["older", "newer"]); + } + + #[test] + fn scope_restricts_to_a_project() { + let mut in_p = task("in_p"); + in_p.project_id = Some("proj".into()); + let mut other = task("other"); + other.project_id = Some("nope".into()); + let none = task("none"); + let out = rank(vec![in_p, other, none], NOW, Some("proj"), 5); + assert_eq!(ids(&out), vec!["in_p"]); + } + + #[test] + fn limit_truncates_non_red_but_red_always_appears() { + let mut whites: Vec = (0..5) + .map(|i| { + let mut t = task(&format!("w{i}")); + t.created_at = i; + t + }) + .collect(); + // A red created last → sorts after the whites by band? No: red band wins, + // so red sorts first. Put it at the front of results regardless of limit. + let mut red = task("red"); + red.attention = Some(Attention::Red); + red.created_at = 100; + whites.push(red); + + let out = rank(whites, NOW, None, 2); + // limit 2 → red + first white; red guaranteed present. + assert!(out.iter().any(|t| t.node_id == "red")); + assert!(out.len() <= 3); + } + + #[test] + fn red_beyond_limit_is_still_included() { + // Many past-late_on whites fill the limit; reds after them still appear. + let mut tasks: Vec = (0..5) + .map(|i| { + let mut t = task(&format!("late{i}")); + t.late_on = Some(NOW - 1000 - i); // all past late_on + t.created_at = i; + t + }) + .collect(); + let mut red = task("red"); + red.attention = Some(Attention::Red); + tasks.push(red); + + let out = rank(tasks, NOW, None, 3); + assert!(out.iter().any(|t| t.node_id == "red")); + } + + // --- property tests: the ordering is a total order (tech-spec §9) --- + + use proptest::prelude::*; + + fn attention_strategy() -> impl Strategy> { + prop_oneof![ + Just(None), + Just(Some(Attention::Red)), + Just(Some(Attention::Orange)), + Just(Some(Attention::White)), + Just(Some(Attention::Blue)), + ] + } + + prop_compose! { + fn task_strategy()( + id in 0u32..50, + attention in attention_strategy(), + late_on in proptest::option::of(-1_000_000_000i64..1_000_000_000), + created_at in -1_000_000_000i64..1_000_000_000, + ) -> RankedTask { + let mut t = task(&format!("n{id:03}")); + t.attention = attention; + t.late_on = late_on; + t.created_at = created_at; + t + } + } + + proptest! { + /// Antisymmetry: order(a,b) is exactly the reverse of order(b,a). + #[test] + fn order_is_antisymmetric( + a in task_strategy(), + b in task_strategy(), + now in -2_000_000_000i64..2_000_000_000, + ) { + prop_assert_eq!(order(&a, &b, now), order(&b, &a, now).reverse()); + } + + /// Sorting yields a sequence with no out-of-order adjacent pair — + /// i.e. the relation is a consistent total order over any input. + #[test] + fn sorted_output_is_fully_ordered( + mut tasks in proptest::collection::vec(task_strategy(), 0..12), + now in -2_000_000_000i64..2_000_000_000, + ) { + tasks.sort_by(|x, y| order(x, y, now)); + for w in tasks.windows(2) { + prop_assert_ne!(order(&w[0], &w[1], now), Ordering::Greater); + } + } + + /// Equality of the relation implies identity (node_id is the final + /// tiebreak), so the order is strict/total, never merely a preorder. + #[test] + fn equal_only_when_same_node( + a in task_strategy(), + b in task_strategy(), + now in -2_000_000_000i64..2_000_000_000, + ) { + if order(&a, &b, now) == Ordering::Equal { + prop_assert_eq!(a.node_id, b.node_id); + } + } + } +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 1979b33..e6e2caf 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -24,6 +24,7 @@ use ulid::Ulid; use crate::clock::Clock; use crate::error::Result; use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::ranking::RankedTask; use crate::store::Store; /// A SQLite file (or in-memory database) opened directly as a backend. @@ -136,6 +137,11 @@ impl Store for LocalStore { tasks::set_attention(&self.conn, now, node_id, attention) } + fn next(&self, scope: Option<&str>, limit: usize) -> Result> { + let now = self.clock.now_ms(); + tasks::next(&self.conn, &self.owner_id, now, scope, limit) + } + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { let now = self.clock.now_ms(); links::add(&self.conn, now, src_id, dst_id, link_type) diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 7f61915..91bc532 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -8,6 +8,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; use super::{links, nodes}; use crate::error::{Error, Result}; use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; +use crate::ranking::{self, RankedTask}; fn from_row(row: &Row) -> rusqlite::Result { let attention = match row.get::<_, Option>("attention")? { @@ -117,6 +118,58 @@ pub(super) fn set_state( require(conn, node_id) } +/// The Tactical "what is next?" ranking for `owner` at `now` (tech-spec §7). +pub(super) fn next( + conn: &Connection, + owner: &str, + now: i64, + scope: Option<&str>, + limit: usize, +) -> Result> { + let candidates = load_candidates(conn, owner)?; + Ok(ranking::rank(candidates, now, scope, limit)) +} + +/// Load every non-tombstoned committed task for `owner` as a ranking candidate, +/// joining in its project and canonical-context link targets. +fn load_candidates(conn: &Connection, owner: &str) -> Result> { + let sql = " + SELECT n.id, n.title, n.created_at, n.tombstoned, + t.attention, t.do_date, t.late_on, t.state, + (SELECT dst_id FROM links + WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS project_id, + (SELECT dst_id FROM links + WHERE src_id = n.id AND type = 'canonical-context' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS ctx_id + FROM tasks t JOIN nodes n ON n.id = t.node_id + WHERE n.owner_id = ?1 AND n.tombstoned = 0"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([owner], |row| { + let attention = match row.get::<_, Option>("attention")? { + Some(s) => Some( + Attention::parse(&s) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + ), + None => None, + }; + Ok(RankedTask { + node_id: row.get("id")?, + title: row.get("title")?, + attention, + do_date: row.get("do_date")?, + late_on: row.get("late_on")?, + state: TaskState::parse(&row.get::<_, String>("state")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + project_id: row.get("project_id")?, + canonical_context_id: row.get("ctx_id")?, + created_at: row.get("created_at")?, + }) + })?; + Ok(rows.collect::>>()?) +} + /// Set a task's attention-state. pub(super) fn set_attention( conn: &Connection, diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 7465646..48638a5 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -6,6 +6,7 @@ use crate::error::Result; use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::ranking::RankedTask; /// A backend that can store and retrieve nodes, tasks, and links. /// @@ -48,6 +49,11 @@ pub trait Store { /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; + /// The Tactical "what is next?" ranking (tech-spec §7), using the store's + /// injected clock as `now`. `scope`, when `Some`, restricts to a project + /// node id; `red` items always appear regardless of `limit`. + fn next(&self, scope: Option<&str>, limit: usize) -> Result>; + // --- links --- /// Add a typed link between two nodes. diff --git a/crates/heph-core/tests/next_ranking.rs b/crates/heph-core/tests/next_ranking.rs new file mode 100644 index 0000000..775cf6e --- /dev/null +++ b/crates/heph-core/tests/next_ranking.rs @@ -0,0 +1,95 @@ +//! End-to-end test of `Store::next` through the SQLite loader (slice 4). +//! The pure ranking is unit-tested in `ranking.rs`; here we prove the join +//! (project + canonical-context) and candidacy reach the engine intact. + +use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState}; + +const NOW: i64 = 1_700_000_000_000; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(NOW))).unwrap() +} + +#[test] +fn next_ranks_red_first_and_hides_blue_and_future() { + let mut s = store(); + + let red = s + .create_task(NewTask { + title: "Red now".into(), + attention: Some(Attention::Red), + ..Default::default() + }) + .unwrap(); + let _white = s + .create_task(NewTask { + title: "White now".into(), + attention: Some(Attention::White), + ..Default::default() + }) + .unwrap(); + let _blue = s + .create_task(NewTask { + title: "Blue backlog".into(), + attention: Some(Attention::Blue), + ..Default::default() + }) + .unwrap(); + let _future = s + .create_task(NewTask { + title: "Not yet".into(), + attention: Some(Attention::Orange), + do_date: Some(NOW + 1_000), + ..Default::default() + }) + .unwrap(); + + let ranked = s.next(None, 5).unwrap(); + let titles: Vec<&str> = ranked.iter().map(|t| t.title.as_str()).collect(); + // Blue hidden, future not actionable → only the two "now" tasks, red first. + assert_eq!(titles, vec!["Red now", "White now"]); + // The canonical context link is surfaced for the one-keystroke jump. + assert_eq!(ranked[0].node_id, red.node_id); + assert!(ranked[0].canonical_context_id.is_some()); +} + +#[test] +fn next_respects_scope_and_excludes_completed() { + let mut s = store(); + let project = s + .create_node(heph_core::NewNode { + kind: heph_core::NodeKind::Project, + title: "Work".into(), + body: None, + }) + .unwrap(); + + let in_scope = s + .create_task(NewTask { + title: "Work task".into(), + attention: Some(Attention::Orange), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + let _other = s + .create_task(NewTask { + title: "Life task".into(), + attention: Some(Attention::Orange), + ..Default::default() + }) + .unwrap(); + let done = s + .create_task(NewTask { + title: "Already done".into(), + attention: Some(Attention::Orange), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + s.set_task_state(&done.node_id, TaskState::Done).unwrap(); + + let ranked = s.next(Some(&project.id), 5).unwrap(); + let ids: Vec<&str> = ranked.iter().map(|t| t.node_id.as_str()).collect(); + assert_eq!(ids, vec![in_scope.node_id.as_str()]); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 1ff6bfc..8ec8d72 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -3,4 +3,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Cargo workspace + `heph-core` crate; migration-run SQLite schema (§4.5); clock-injected `Store` trait + `LocalStore` node create/get; single local-user bootstrap. - Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks. - Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent). +- "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From d0debfceb92ce127de0270b78debb9d1dc4f44f9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 19:14:22 -0700 Subject: [PATCH 05/91] heph-core: recurrence (roll-forward in place) + per-task logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5 (tech-spec §4.4). Completing a recurring task rolls it forward in place instead of marking it done — the Todoist-corner-avoiding model. Pure recurrence module: - next_occurrence(rrule, anchor, after): lazy RRULE expansion (rrule + chrono/UTC) returning the next instance strictly after `after`, skipping missed occurrences; None when a finite series is exhausted. - reset_checkboxes(body): the fresh-checklist transform — unchecks every `- [x]`, idempotent, preserves indentation/bullet/line-endings. Storage roll-forward (one transaction, on set_state(done) of a recurring task): reset the canonical context doc's checklist, append the completed occurrence to the task's log, advance do_date to the next instance after now (skipping misses); finite series finally goes done. `skip` advances the same way without logging. Non-recurring done is unchanged. Per-task append-only log (`log-of` doc): log_append / log_tail — the resumption breadcrumb + recurring-completion narrative ([[design]] §6.4). Tests: 7 recurrence unit + 2 proptests (no checked marker survives reset; reset idempotent for any body) + 6 end-to-end incl. five-occurrence no-carry-forward and missed-collapse-to-one. 53 tests green. This completes the heph-core library layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 289 ++++++++++++++++++++++- Cargo.toml | 2 + crates/heph-core/Cargo.toml | 2 + crates/heph-core/src/lib.rs | 2 + crates/heph-core/src/recurrence.rs | 185 +++++++++++++++ crates/heph-core/src/sqlite/links.rs | 19 ++ crates/heph-core/src/sqlite/log.rs | 83 +++++++ crates/heph-core/src/sqlite/mod.rs | 20 +- crates/heph-core/src/sqlite/tasks.rs | 97 +++++++- crates/heph-core/src/store.rs | 19 +- crates/heph-core/tests/recurrence.rs | 175 ++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + 12 files changed, 878 insertions(+), 16 deletions(-) create mode 100644 crates/heph-core/src/recurrence.rs create mode 100644 crates/heph-core/src/sqlite/log.rs create mode 100644 crates/heph-core/tests/recurrence.rs diff --git a/Cargo.lock b/Cargo.lock index 62780c9..5772df3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,24 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -63,6 +81,47 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "errno" version = "0.3.14" @@ -161,13 +220,39 @@ dependencies = [ name = "heph-core" version = "0.0.0" dependencies = [ + "chrono", "proptest", "pulldown-cmark", + "rrule", "rusqlite", - "thiserror", + "thiserror 2.0.18", "ulid", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -180,6 +265,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -203,6 +294,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + [[package]] name = "memchr" version = "2.8.1" @@ -224,6 +321,53 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -264,7 +408,7 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -305,6 +449,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" @@ -312,7 +465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -322,9 +475,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -340,7 +499,30 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] @@ -349,6 +531,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rrule" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff1ca93145ff07cdc878b5f6bb90391a299cc8712538af0ad73ebf37613e46a" +dependencies = [ + "chrono", + "chrono-tz", + "lazy_static", + "log", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -400,6 +596,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -436,13 +638,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -462,7 +684,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand", + "rand 0.9.4", "web-time", ] @@ -569,12 +791,65 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 38ddb1b..4cb4bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ ulid = "1" thiserror = "2" anyhow = "1" pulldown-cmark = { version = "0.13", default-features = false } +rrule = "0.13" +chrono = { version = "0.4", default-features = false, features = ["clock"] } [profile.release] lto = "thin" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index c4aa4e6..9633ecb 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -13,6 +13,8 @@ rusqlite.workspace = true ulid.workspace = true thiserror.workspace = true pulldown-cmark.workspace = true +rrule.workspace = true +chrono.workspace = true [dev-dependencies] proptest = "1" diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 7892cf7..40d431a 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod error; pub mod extract; pub mod model; pub mod ranking; +pub mod recurrence; pub mod sqlite; pub mod store; @@ -21,5 +22,6 @@ pub use error::{Error, Result}; pub use extract::{extract, ContextItem, Extraction}; pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; pub use ranking::{rank, Dimension, RankedTask, RANKING}; +pub use recurrence::{next_occurrence, reset_checkboxes}; pub use sqlite::LocalStore; pub use store::Store; diff --git a/crates/heph-core/src/recurrence.rs b/crates/heph-core/src/recurrence.rs new file mode 100644 index 0000000..9664c53 --- /dev/null +++ b/crates/heph-core/src/recurrence.rs @@ -0,0 +1,185 @@ +//! Recurrence — roll-forward in place (tech-spec §4.4, [[design]] §3.3). +//! +//! A recurring task is a **single node** carrying an RFC-5545 RRULE. On +//! completing an occurrence it (1) resets its checklist to all-unchecked, +//! (2) logs the occurrence, and (3) advances its do-date to the **next RRULE +//! instance strictly after now**, *skipping* missed occurrences. So a missed +//! daily routine is one gently-overdue item, never a pile, and **completion +//! never carries forward**. +//! +//! This module holds the two pure pieces: [`next_occurrence`] (lazy RRULE +//! expansion — only the next instance is ever computed) and +//! [`reset_checkboxes`] (the fresh-checklist transform). + +use chrono::TimeZone; +use rrule::{RRule, Tz, Unvalidated}; + +use crate::error::{Error, Result}; + +/// Hard cap on how many series instances we'll skip past while searching for +/// the next one after `now`. Protects against pathological iteration; a normal +/// missed-routine gap is a handful of instances. +const MAX_SKIP: usize = 100_000; + +/// The next RRULE instance **strictly after** `after_ms`, in epoch ms, anchored +/// at `anchor_ms` (the series DTSTART — in practice the task's current do-date, +/// which is itself a series instance). `None` if the series is exhausted +/// (a finite rule with `COUNT`/`UNTIL`). +/// +/// Lazy: it walks instances from the anchor and returns the first past `after`, +/// never materializing the series. +pub fn next_occurrence(rrule: &str, anchor_ms: i64, after_ms: i64) -> Result> { + let anchor = to_dt(anchor_ms)?; + let after = to_dt(after_ms)?; + + let rule: RRule = rrule + .parse() + .map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?; + let set = rule + .build(anchor) + .map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?; + + for (i, occ) in set.into_iter().enumerate() { + if i >= MAX_SKIP { + return Err(Error::Integrity(format!( + "recurrence search for {rrule:?} exceeded {MAX_SKIP} instances" + ))); + } + if occ.timestamp_millis() > after_ms && occ > after { + return Ok(Some(occ.timestamp_millis())); + } + } + Ok(None) +} + +fn to_dt(ms: i64) -> Result> { + Tz::UTC + .timestamp_millis_opt(ms) + .single() + .ok_or_else(|| Error::Integrity(format!("invalid timestamp {ms}"))) +} + +/// Return `body` with every checked GFM task marker (`- [x]` / `- [X]`) reset to +/// unchecked (`- [ ]`). Idempotent; preserves indentation, bullet style +/// (`-`/`*`/`+`), and line endings. Unchecked items are left untouched. +pub fn reset_checkboxes(body: &str) -> String { + body.split_inclusive('\n').map(uncheck_line).collect() +} + +fn uncheck_line(line: &str) -> String { + let indent_len = line.len() - line.trim_start().len(); + let (indent, rest) = line.split_at(indent_len); + let b = rest.as_bytes(); + let is_checked_item = b.len() >= 5 + && matches!(b[0], b'-' | b'*' | b'+') + && b[1] == b' ' + && b[2] == b'[' + && matches!(b[3], b'x' | b'X') + && b[4] == b']'; + if is_checked_item { + let bullet = &rest[0..1]; + let tail = &rest[5..]; // everything after "]" (incl. any trailing '\n') + format!("{indent}{bullet} [ ]{tail}") + } else { + line.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + // 2024-01-01T00:00:00Z and friends, in epoch ms. + const JAN1: i64 = 1_704_067_200_000; + const ONE_DAY: i64 = 86_400_000; + + #[test] + fn daily_advances_one_day_when_completed_on_time() { + // anchor = Jan 1; completing at Jan 1 noon → next instance Jan 2. + let next = next_occurrence("FREQ=DAILY", JAN1, JAN1 + ONE_DAY / 2) + .unwrap() + .unwrap(); + assert_eq!(next, JAN1 + ONE_DAY); + } + + #[test] + fn daily_skips_missed_occurrences() { + // anchor Jan 1, but we don't complete until 3.5 days later → next is + // Jan 5 (one item, not a pile of four). + let now = JAN1 + 3 * ONE_DAY + ONE_DAY / 2; + let next = next_occurrence("FREQ=DAILY", JAN1, now).unwrap().unwrap(); + assert_eq!(next, JAN1 + 4 * ONE_DAY); + } + + #[test] + fn weekly_advances_a_week() { + let next = next_occurrence("FREQ=WEEKLY", JAN1, JAN1 + ONE_DAY) + .unwrap() + .unwrap(); + assert_eq!(next, JAN1 + 7 * ONE_DAY); + } + + #[test] + fn finite_rule_can_be_exhausted() { + // COUNT=2 from the anchor → instances at Jan 1 and Jan 2; after Jan 2 + // there is no next. + let after_last = JAN1 + 5 * ONE_DAY; + assert_eq!( + next_occurrence("FREQ=DAILY;COUNT=2", JAN1, after_last).unwrap(), + None + ); + } + + #[test] + fn invalid_rrule_is_an_error() { + assert!(next_occurrence("FREQ=NONSENSE", JAN1, JAN1).is_err()); + } + + #[test] + fn reset_unchecks_all_checked_items() { + let body = "- [x] a\n- [ ] b\n* [X] c\n + [x] nested\n"; + let expected = "- [ ] a\n- [ ] b\n* [ ] c\n + [ ] nested\n"; + assert_eq!(reset_checkboxes(body), expected); + } + + #[test] + fn reset_is_idempotent() { + let body = "- [x] a\n- [ ] b\n"; + let once = reset_checkboxes(body); + assert_eq!(reset_checkboxes(&once), once); + } + + #[test] + fn reset_leaves_non_checkbox_text_alone() { + let body = "# Heading\n\nSome [x] not a checkbox\n- regular item\n"; + assert_eq!(reset_checkboxes(body), body); + } + + proptest! { + /// The fresh-checklist invariant: after a reset, no checked task marker + /// remains — completion can never carry forward (tech-spec §9). + #[test] + fn reset_leaves_no_checked_markers(body in ".{0,200}") { + let out = reset_checkboxes(&body); + for line in out.lines() { + let t = line.trim_start(); + let bytes = t.as_bytes(); + let checked = bytes.len() >= 5 + && matches!(bytes[0], b'-' | b'*' | b'+') + && bytes[1] == b' ' + && bytes[2] == b'[' + && matches!(bytes[3], b'x' | b'X') + && bytes[4] == b']'; + prop_assert!(!checked, "checked marker survived reset: {line:?}"); + } + } + + /// Reset is idempotent for any input. + #[test] + fn reset_idempotent_for_any_body(body in ".{0,200}") { + let once = reset_checkboxes(&body); + prop_assert_eq!(reset_checkboxes(&once), once); + } + } +} diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 87bb5f9..7e5c46a 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -53,6 +53,25 @@ pub(super) fn add( Ok(link) } +/// The destination of the first non-tombstoned link of `link_type` out of +/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc). +pub(super) fn first_dst( + conn: &Connection, + src_id: &str, + link_type: LinkType, +) -> Result> { + let dst = conn + .query_row( + "SELECT dst_id FROM links + WHERE src_id = ?1 AND type = ?2 AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + (src_id, link_type.as_str()), + |r| r.get(0), + ) + .optional()?; + Ok(dst) +} + /// All non-tombstoned links originating at `id`. pub(super) fn outgoing(conn: &Connection, id: &str) -> Result> { query(conn, "src_id", id) diff --git a/crates/heph-core/src/sqlite/log.rs b/crates/heph-core/src/sqlite/log.rs new file mode 100644 index 0000000..847b7f6 --- /dev/null +++ b/crates/heph-core/src/sqlite/log.rs @@ -0,0 +1,83 @@ +//! Per-task append-only log ([[design]] §6.4). +//! +//! A task's log is a `doc` node linked from the task by `log-of`. Entries are +//! appended as lines; the log is the resumption breadcrumb store ([[design]] +//! §6.1) and the narrative history of recurring completions (§4.4). +//! +//! These take `&Connection` so they compose inside a caller's transaction +//! (the recurrence roll-forward appends a completion entry within its tx). + +use rusqlite::Connection; + +use super::{hlc_for, links, nodes}; +use crate::error::{Error, Result}; +use crate::model::{LinkType, NodeKind}; + +/// The task's log doc id, creating (and linking) it on first use. +pub(super) fn ensure_doc( + conn: &Connection, + owner: &str, + now: i64, + task_id: &str, +) -> Result { + if let Some(id) = links::first_dst(conn, task_id, LinkType::LogOf)? { + return Ok(id); + } + let task = + nodes::get(conn, task_id)?.ok_or_else(|| Error::NodeNotFound(task_id.to_string()))?; + let doc = nodes::build( + owner, + now, + NodeKind::Doc, + format!("{} — log", task.title), + Some(String::new()), + ); + nodes::insert(conn, &doc)?; + links::add(conn, now, task_id, &doc.id, LinkType::LogOf)?; + Ok(doc.id) +} + +/// Append `text` as a new entry (one line) to the task's log. +pub(super) fn append( + conn: &Connection, + owner: &str, + now: i64, + task_id: &str, + text: &str, +) -> Result<()> { + let doc_id = ensure_doc(conn, owner, now, task_id)?; + let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?; + let new_body = append_line(doc.body.as_deref().unwrap_or(""), text); + conn.execute( + "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", + (&new_body, now, hlc_for(now), &doc_id), + )?; + Ok(()) +} + +/// The task's latest `n` log entries (oldest→newest), empty if it has no log. +pub(super) fn tail(conn: &Connection, task_id: &str, n: usize) -> Result> { + let Some(doc_id) = links::first_dst(conn, task_id, LinkType::LogOf)? else { + return Ok(Vec::new()); + }; + let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?; + let body = doc.body.unwrap_or_default(); + let entries: Vec = body + .lines() + .filter(|l| !l.trim().is_empty()) + .map(str::to_string) + .collect(); + let start = entries.len().saturating_sub(n); + Ok(entries[start..].to_vec()) +} + +/// Append a single entry line to a log body, ensuring separation. +fn append_line(body: &str, text: &str) -> String { + let mut s = body.to_string(); + if !s.is_empty() && !s.ends_with('\n') { + s.push('\n'); + } + s.push_str(text); + s.push('\n'); + s +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index e6e2caf..c91ad84 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -10,6 +10,7 @@ //! delegating layer so a transaction can span several of them. mod links; +mod log; mod migrations; mod nodes; mod tasks; @@ -129,7 +130,12 @@ impl Store for LocalStore { fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result { let now = self.clock.now_ms(); - tasks::set_state(&self.conn, now, node_id, state) + tasks::set_state(&mut self.conn, &self.owner_id, now, node_id, state) + } + + fn skip_recurrence(&mut self, node_id: &str) -> Result { + let now = self.clock.now_ms(); + tasks::skip(&self.conn, now, node_id) } fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result { @@ -154,6 +160,18 @@ impl Store for LocalStore { fn backlinks(&self, id: &str) -> Result> { links::backlinks(&self.conn, id) } + + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { + let now = self.clock.now_ms(); + let tx = self.conn.transaction()?; + log::append(&tx, &self.owner_id, now, task_id, text)?; + tx.commit()?; + Ok(()) + } + + fn log_tail(&self, task_id: &str, n: usize) -> Result> { + log::tail(&self.conn, task_id, n) + } } #[cfg(test)] diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 91bc532..be836bd 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -5,10 +5,11 @@ use rusqlite::{Connection, OptionalExtension, Row}; -use super::{links, nodes}; +use super::{hlc_for, links, log, nodes}; use crate::error::{Error, Result}; use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::ranking::{self, RankedTask}; +use crate::recurrence; fn from_row(row: &Row) -> rusqlite::Result { let attention = match row.get::<_, Option>("attention")? { @@ -100,20 +101,104 @@ fn require(conn: &Connection, node_id: &str) -> Result { get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string())) } -/// Set a task's lifecycle state. +/// Set a task's lifecycle state. Completing a **recurring** task rolls it +/// forward in place (tech-spec §4.4) rather than marking it done. pub(super) fn set_state( - conn: &Connection, + conn: &mut Connection, + owner: &str, now: i64, node_id: &str, state: TaskState, ) -> Result { - let updated = conn.execute( + let task = require(conn, node_id)?; + if state == TaskState::Done && task.recurrence.is_some() { + return roll_forward(conn, owner, now, &task); + } + conn.execute( "UPDATE tasks SET state = ?1 WHERE node_id = ?2", (state.as_str(), node_id), )?; - if updated == 0 { - return Err(Error::NodeNotFound(node_id.to_string())); + nodes::touch(conn, now, node_id)?; + require(conn, node_id) +} + +/// Roll a recurring task forward on completion (tech-spec §4.4): reset its +/// checklist to all-unchecked, log the occurrence, and advance the do-date to +/// the next RRULE instance strictly after `now` (skipping misses) — all in one +/// transaction. If the series is exhausted, the task is finally marked done. +fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Result { + let rrule = task + .recurrence + .as_deref() + .expect("roll_forward called on a recurring task"); + let tx = conn.transaction()?; + + // 1. Fresh checklist — reset the canonical context doc's checkboxes. + if let Some(doc_id) = links::first_dst(&tx, &task.node_id, LinkType::CanonicalContext)? { + if let Some(doc) = nodes::get(&tx, &doc_id)? { + let body = doc.body.unwrap_or_default(); + let reset = recurrence::reset_checkboxes(&body); + if reset != body { + tx.execute( + "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", + (&reset, now, hlc_for(now), &doc_id), + )?; + links::sync_wiki_links(&tx, owner, &doc_id, &reset, now)?; + } + } } + + // 2. Narrative history — append the completed occurrence to the log. + let entry = match task.do_date { + Some(d) => format!("- completed occurrence (do-date {d})"), + None => "- completed occurrence".to_string(), + }; + log::append(&tx, owner, now, &task.node_id, &entry)?; + + // 3. Advance the do-date (or finally finish a finite series). + advance(&tx, now, &task.node_id, rrule, task.do_date)?; + nodes::touch(&tx, now, &task.node_id)?; + + tx.commit()?; + require(conn, &task.node_id) +} + +/// Advance a recurring task to its next instance after `now`, or mark it `done` +/// if the series is exhausted. Shared by completion roll-forward and `skip`. +fn advance( + conn: &Connection, + now: i64, + node_id: &str, + rrule: &str, + do_date: Option, +) -> Result<()> { + let anchor = do_date.unwrap_or(now); + match recurrence::next_occurrence(rrule, anchor, now)? { + Some(next) => { + conn.execute( + "UPDATE tasks SET do_date = ?1, state = 'outstanding' WHERE node_id = ?2", + (next, node_id), + )?; + } + None => { + conn.execute( + "UPDATE tasks SET state = 'done' WHERE node_id = ?1", + [node_id], + )?; + } + } + Ok(()) +} + +/// Skip the current occurrence of a recurring task: advance the do-date the same +/// way as completion but **without** logging a completion (tech-spec §4.4). +pub(super) fn skip(conn: &Connection, now: i64, node_id: &str) -> Result { + let task = require(conn, node_id)?; + let rrule = task + .recurrence + .as_deref() + .ok_or_else(|| Error::Integrity(format!("skip on non-recurring task {node_id}")))?; + advance(conn, now, node_id, rrule, task.do_date)?; nodes::touch(conn, now, node_id)?; require(conn, node_id) } diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 48638a5..854d39e 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -42,10 +42,16 @@ pub trait Store { /// Fetch a task by its node id. fn get_task(&self, node_id: &str) -> Result>; - /// Set a task's lifecycle state. (Recurrence roll-forward is layered on in - /// a later slice — tech-spec §4.4.) + /// Set a task's lifecycle state. Completing a **recurring** task rolls it + /// forward in place — fresh checklist, logged occurrence, advanced do-date + /// (tech-spec §4.4) — rather than marking it done. fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result; + /// Skip the current occurrence of a recurring task: advance its do-date + /// without logging a completion (tech-spec §4.4). Errors on a non-recurring + /// task. + fn skip_recurrence(&mut self, node_id: &str) -> Result; + /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; @@ -64,4 +70,13 @@ pub trait Store { /// All non-tombstoned links pointing at `id` (backlinks). fn backlinks(&self, id: &str) -> Result>; + + // --- per-task log ([[design]] §6.4) --- + + /// Append a line to a task's append-only log (creating the log on first + /// use). The log is the resumption breadcrumb store. + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()>; + + /// The task's latest `n` log entries (oldest→newest); empty if it has none. + fn log_tail(&self, task_id: &str, n: usize) -> Result>; } diff --git a/crates/heph-core/tests/recurrence.rs b/crates/heph-core/tests/recurrence.rs new file mode 100644 index 0000000..72844e7 --- /dev/null +++ b/crates/heph-core/tests/recurrence.rs @@ -0,0 +1,175 @@ +//! End-to-end recurrence: completing a recurring task rolls it forward in +//! place — fresh checklist, logged occurrence, advanced do-date, never +//! carrying completion forward (tech-spec §4.4, slice 5). + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +use heph_core::{Clock, LinkType, LocalStore, NewTask, Store, TaskState}; + +const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z +const ONE_DAY: i64 = 86_400_000; + +/// A clock the test can advance between completions. Cloneable so the test +/// keeps a handle while the store owns its own `Box`. +#[derive(Clone)] +struct StepClock(Arc); + +impl StepClock { + fn new(ms: i64) -> Self { + StepClock(Arc::new(AtomicI64::new(ms))) + } + fn set(&self, ms: i64) { + self.0.store(ms, Ordering::SeqCst); + } +} + +impl Clock for StepClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +fn store_at(ms: i64) -> (LocalStore, StepClock) { + let clock = StepClock::new(ms); + let store = LocalStore::open_in_memory(Box::new(clock.clone())).unwrap(); + (store, clock) +} + +fn canonical_doc_id(s: &LocalStore, task_id: &str) -> String { + s.outgoing_links(task_id) + .unwrap() + .into_iter() + .find(|l| l.link_type == LinkType::CanonicalContext) + .unwrap() + .dst_id +} + +#[test] +fn completing_a_recurring_task_rolls_forward_with_a_fresh_checklist() { + let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Morning routine".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + let doc_id = canonical_doc_id(&s, &task.node_id); + + // Put a checklist in the context doc and check items off. + s.update_node( + &doc_id, + None, + Some("- [x] brush teeth\n- [x] feed birds\n- [ ] coffee\n".into()), + ) + .unwrap(); + + let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + + // It stays outstanding and the do-date advanced to the next day. + assert_eq!(rolled.state, TaskState::Outstanding); + assert_eq!(rolled.do_date, Some(JAN1 + ONE_DAY)); + + // The checklist is fresh — every item unchecked. Completion did NOT carry. + let doc = s.get_node(&doc_id).unwrap().unwrap(); + assert_eq!( + doc.body.as_deref(), + Some("- [ ] brush teeth\n- [ ] feed birds\n- [ ] coffee\n") + ); + + // The completion was logged (the narrative breadcrumb). + let log = s.log_tail(&task.node_id, 10).unwrap(); + assert_eq!(log.len(), 1); + assert!(log[0].contains("completed occurrence")); +} + +#[test] +fn missed_occurrences_collapse_to_one_not_a_pile() { + // Complete 3.5 days late → next instance is day 4, not a backlog of 4. + let (mut s, _clock) = store_at(JAN1 + 3 * ONE_DAY + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Water plants".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + + let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(rolled.do_date, Some(JAN1 + 4 * ONE_DAY)); + assert_eq!(rolled.state, TaskState::Outstanding); +} + +#[test] +fn completion_never_carries_forward_across_many_occurrences() { + let (mut s, clock) = store_at(JAN1 + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Daily standup notes".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + let doc_id = canonical_doc_id(&s, &task.node_id); + + for occurrence in 0..5 { + // Check everything off this occurrence. + s.update_node(&doc_id, None, Some("- [x] note A\n- [x] note B\n".into())) + .unwrap(); + let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(rolled.state, TaskState::Outstanding); + + // Fresh checklist after every single completion. + let doc = s.get_node(&doc_id).unwrap().unwrap(); + assert_eq!( + doc.body.as_deref(), + Some("- [ ] note A\n- [ ] note B\n"), + "occurrence {occurrence} did not present a fresh checklist" + ); + + // Advance "now" past the new do-date for the next loop. + clock.set(rolled.do_date.unwrap() + ONE_DAY / 2); + } + + // Five completions → five log entries. + assert_eq!(s.log_tail(&task.node_id, 100).unwrap().len(), 5); +} + +#[test] +fn skip_advances_without_logging() { + let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Optional chore".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + + let skipped = s.skip_recurrence(&task.node_id).unwrap(); + assert_eq!(skipped.do_date, Some(JAN1 + ONE_DAY)); + assert_eq!(skipped.state, TaskState::Outstanding); + assert!(s.log_tail(&task.node_id, 10).unwrap().is_empty()); +} + +#[test] +fn non_recurring_task_done_is_just_done() { + let (mut s, _clock) = store_at(JAN1); + let task = s + .create_task(NewTask { + title: "One-off".into(), + ..Default::default() + }) + .unwrap(); + let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(t.state, TaskState::Done); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 8ec8d72..2ac879e 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -4,4 +4,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks. - Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent). - "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite. +- Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From ed8c7a733a01f2a5331854d65baaa49b0ee0da5b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 20:28:15 -0700 Subject: [PATCH 06/91] hephd local mode: file lock + JSON-RPC over unix socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6 (tech-spec §3, §6, §10). First async component — the per-device daemon in local mode. - `LockGuard`: exclusive advisory flock on a sidecar `.lock`; a second acquire fails and releases on drop (the §3.1 lock handoff). - JSON-RPC (line-delimited): `rpc::dispatch` maps node/task/next/links/log methods onto the heph-core Store; `Daemon::serve` accepts unix-socket connections and runs dispatch on tokio's blocking pool behind an Arc> (DB never touches an async worker). - Synchronous `Client` for surfaces/CLI; `hephd` binary (clap) opens the store under lock and serves the default socket. - heph-core model/ranking types are now serde-(de)serializable; added node.tombstone + Store::tombstone_node. Tests: 2 lock unit tests + 5 real-socket e2e (round-trip with clock injection, next, error paths, recurring roll-forward over RPC, 8-client concurrency). 60 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- Cargo.lock | 488 ++++++++++++++++++++++- Cargo.toml | 16 +- crates/heph-core/Cargo.toml | 1 + crates/heph-core/src/model.rs | 26 +- crates/heph-core/src/ranking.rs | 4 +- crates/heph-core/src/sqlite/mod.rs | 5 + crates/heph-core/src/sqlite/nodes.rs | 13 + crates/heph-core/src/store.rs | 3 + crates/hephd/Cargo.toml | 32 ++ crates/hephd/src/client.rs | 61 +++ crates/hephd/src/clock.rs | 20 + crates/hephd/src/lib.rs | 40 ++ crates/hephd/src/lock.rs | 91 +++++ crates/hephd/src/main.rs | 61 +++ crates/hephd/src/rpc.rs | 227 +++++++++++ crates/hephd/src/server.rs | 101 +++++ crates/hephd/tests/rpc_socket.rs | 215 ++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + 19 files changed, 1390 insertions(+), 17 deletions(-) create mode 100644 crates/hephd/Cargo.toml create mode 100644 crates/hephd/src/client.rs create mode 100644 crates/hephd/src/clock.rs create mode 100644 crates/hephd/src/lib.rs create mode 100644 crates/hephd/src/lock.rs create mode 100644 crates/hephd/src/main.rs create mode 100644 crates/hephd/src/rpc.rs create mode 100644 crates/hephd/src/server.rs create mode 100644 crates/hephd/tests/rpc_socket.rs diff --git a/AGENTS.md b/AGENTS.md index 53ec884..01016dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too ./Cargo.toml # workspace manifest (shared deps + members) ./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction, # recurrence, "what is next?" ranking, op-log/HLC/CRDT sync -./crates/hephd/ # daemon (planned): local/server/client modes; JSON-RPC over unix socket +./crates/hephd/ # daemon: local mode done (JSON-RPC over unix socket + file lock); server/client modes planned ./crates/heph/ # CLI (planned): export, scripting, `heph conflicts` ./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim ./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content diff --git a/Cargo.lock b/Cargo.lock index 5772df3..20bf208 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,62 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "autocfg" version = "1.5.1" @@ -65,6 +121,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.63" @@ -116,6 +178,52 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -129,7 +237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -162,6 +270,16 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fs4" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29c30684418547d476f0b48e84f4821639119c483b1eccd566c8cd0cd05f521" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -216,6 +334,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "heph-core" version = "0.0.0" @@ -225,10 +349,28 @@ dependencies = [ "pulldown-cmark", "rrule", "rusqlite", + "serde", "thiserror 2.0.18", "ulid", ] +[[package]] +name = "hephd" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "fs4", + "heph-core", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -253,6 +395,18 @@ dependencies = [ "cc", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.99" @@ -288,6 +442,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -300,12 +460,41 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -321,6 +510,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parse-zoneinfo" version = "0.3.1" @@ -559,6 +754,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -568,8 +776,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -590,6 +798,58 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -614,6 +874,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -634,8 +910,8 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -678,6 +954,102 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "ulid" version = "1.2.1" @@ -706,6 +1078,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -727,6 +1111,12 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -850,6 +1240,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -859,6 +1267,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -884,3 +1356,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 4cb4bd6..8e20550 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/heph-core"] +members = ["crates/heph-core", "crates/hephd"] [workspace.package] edition = "2021" @@ -18,6 +18,20 @@ anyhow = "1" pulldown-cmark = { version = "0.13", default-features = false } rrule = "0.13" chrono = { version = "0.4", default-features = false, features = ["clock"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = [ + "rt-multi-thread", + "net", + "io-util", + "macros", + "sync", + "time", +] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4", features = ["derive"] } +fs4 = "0.12" [profile.release] lto = "thin" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index 9633ecb..3cb94c7 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -15,6 +15,7 @@ thiserror.workspace = true pulldown-cmark.workspace = true rrule.workspace = true chrono.workspace = true +serde.workspace = true [dev-dependencies] proptest = "1" diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 1525396..28f8054 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -3,10 +3,13 @@ //! Every first-class entity is a [`Node`]. Tasks, links, recurrence, and the //! derived context-item index build on top of this base in later slices. +use serde::{Deserialize, Serialize}; + use crate::error::{Error, Result}; /// Discriminator for the kind of thing a node is (tech-spec §4.1). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum NodeKind { /// Rich context document (knowledge base, work-logs). Body = markdown. Doc, @@ -46,7 +49,7 @@ impl NodeKind { } /// A persisted node (a row of the `nodes` table). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Node { /// Stable, sync-safe id (ULID for content nodes; deterministic for journal/tag). pub id: String, @@ -69,7 +72,8 @@ pub struct Node { } /// A task's attention-state — the lived colour discipline ([[design]] §6.2). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Attention { /// Default — actionable once the do-date arrives. White, @@ -106,7 +110,8 @@ impl Attention { /// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped` /// are both "not outstanding"; the distinction is retained for honesty/history. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum TaskState { /// Still to be done. Outstanding, @@ -138,7 +143,8 @@ impl TaskState { } /// A typed, directional edge between two nodes (tech-spec §4.2). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum LinkType { /// Materialized from a `[[link]]` in a body. Wiki, @@ -190,7 +196,7 @@ impl LinkType { } /// A persisted link (a row of the `links` table). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Link { /// ULID id. pub id: String, @@ -207,7 +213,7 @@ pub struct Link { } /// A persisted committed task (a `tasks` row joined to its node id). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Task { /// The id of the backing `task` node. pub node_id: String, @@ -225,7 +231,8 @@ pub struct Task { /// Input for creating a committed task. The canonical context `doc` and the /// `canonical-context` link are created automatically (tech-spec §6). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] pub struct NewTask { /// Title (shared by the task node and its canonical context doc). pub title: String, @@ -242,13 +249,14 @@ pub struct NewTask { } /// Input for creating a node. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewNode { /// What kind of node to create. pub kind: NodeKind, /// Human-facing title. pub title: String, /// Optional markdown body. + #[serde(default)] pub body: Option, } diff --git a/crates/heph-core/src/ranking.rs b/crates/heph-core/src/ranking.rs index bb16120..e586829 100644 --- a/crates/heph-core/src/ranking.rs +++ b/crates/heph-core/src/ranking.rs @@ -14,11 +14,13 @@ use std::cmp::Ordering; +use serde::{Deserialize, Serialize}; + use crate::model::{Attention, TaskState}; /// A task as seen by the ranking engine — the candidacy fields plus the bits /// the Tactical output row shows. Used as both input and output of [`rank`]. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RankedTask { /// The task node id. pub node_id: String, diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index c91ad84..323304b 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -119,6 +119,11 @@ impl Store for LocalStore { nodes::update(&mut self.conn, &self.owner_id, now, id, title, body) } + fn tombstone_node(&mut self, id: &str) -> Result<()> { + let now = self.clock.now_ms(); + nodes::tombstone(&self.conn, now, id) + } + fn create_task(&mut self, input: NewTask) -> Result { let now = self.clock.now_ms(); tasks::create(&mut self.conn, &self.owner_id, now, input) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index a955749..1f68eac 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -136,6 +136,19 @@ pub(super) fn update( Ok(node) } +/// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge +/// monotonic (tech-spec §4.3). +pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> { + let updated = conn.execute( + "UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3", + (now, hlc_for(now), id), + )?; + if updated == 0 { + return Err(Error::NodeNotFound(id.to_string())); + } + Ok(()) +} + /// Bump `modified_at`/`hlc` on a node (used when a task scalar field changes so /// the node's modified time reflects the mutation for sync ordering). pub(super) fn touch(conn: &Connection, now: i64, id: &str) -> Result<()> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 854d39e..260c60a 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -33,6 +33,9 @@ pub trait Store { body: Option, ) -> Result; + /// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3). + fn tombstone_node(&mut self, id: &str) -> Result<()>; + // --- tasks --- /// Create a committed task, auto-creating its canonical context `doc` and diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml new file mode 100644 index 0000000..d9f751c --- /dev/null +++ b/crates/hephd/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "hephd" +description = "Hephaestus per-device daemon: owns the local store and serves surfaces over a unix socket." +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lib] +name = "hephd" +path = "src/lib.rs" + +[[bin]] +name = "hephd" +path = "src/main.rs" + +[dependencies] +heph-core = { path = "../heph-core" } +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +fs4.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/hephd/src/client.rs b/crates/hephd/src/client.rs new file mode 100644 index 0000000..c3c008b --- /dev/null +++ b/crates/hephd/src/client.rs @@ -0,0 +1,61 @@ +//! A minimal **synchronous** JSON-RPC client over the unix socket. +//! +//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly +//! (tech-spec §3) — they go through the daemon socket, which this wraps. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; + +use crate::rpc::Response; + +/// A connected client. One request/response per [`call`](Client::call). +pub struct Client { + reader: BufReader, + writer: UnixStream, + next_id: u64, +} + +impl Client { + /// Connect to a daemon listening at `socket_path`. + pub fn connect(socket_path: &Path) -> Result { + let stream = UnixStream::connect(socket_path) + .with_context(|| format!("connecting to hephd at {}", socket_path.display()))?; + let reader = BufReader::new(stream.try_clone()?); + Ok(Client { + reader, + writer: stream, + next_id: 1, + }) + } + + /// Call `method` with `params`, returning the `result` value (or an error + /// carrying the RPC error's code and message). + pub fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + + let mut line = serde_json::to_string(&json!({ + "id": id, + "method": method, + "params": params, + }))?; + line.push('\n'); + self.writer.write_all(line.as_bytes())?; + self.writer.flush()?; + + let mut response_line = String::new(); + let read = self.reader.read_line(&mut response_line)?; + if read == 0 { + bail!("hephd closed the connection"); + } + let response: Response = serde_json::from_str(&response_line)?; + if let Some(err) = response.error { + bail!("rpc error {}: {}", err.code, err.message); + } + Ok(response.result.unwrap_or(Value::Null)) + } +} diff --git a/crates/hephd/src/clock.rs b/crates/hephd/src/clock.rs new file mode 100644 index 0000000..602215c --- /dev/null +++ b/crates/hephd/src/clock.rs @@ -0,0 +1,20 @@ +//! The real system clock. +//! +//! `heph-core` never reads the ambient wall clock (tech-spec §2) — the daemon +//! injects it here. This is the one place a real `SystemTime::now()` lives. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use heph_core::Clock; + +/// A [`Clock`] backed by the OS wall clock. +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs new file mode 100644 index 0000000..f54e0f6 --- /dev/null +++ b/crates/hephd/src/lib.rs @@ -0,0 +1,40 @@ +//! `hephd` — the Hephaestus per-device daemon. +//! +//! One binary, three modes (`local`/`server`/`client`); **slice 6 implements +//! `local`**. It owns the local SQLite handle (via [`heph_core::LocalStore`]), +//! takes the file's exclusive [lock](lock::LockGuard), and serves surfaces a +//! line-delimited JSON-RPC API over a unix socket ([`server::Daemon`]). The +//! query/mutation logic all lives in `heph-core`; this crate is transport, +//! locking, and (later) sync/auth. + +pub mod client; +pub mod clock; +pub mod lock; +pub mod rpc; +pub mod server; + +use std::path::PathBuf; + +pub use client::Client; +pub use clock::SystemClock; +pub use lock::LockGuard; +pub use server::Daemon; + +/// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to +/// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3). +pub fn default_socket_path() -> PathBuf { + let base = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join("heph").join("hephd.sock") +} + +/// Default store path: `$XDG_DATA_HOME/heph/heph.db`, falling back to +/// `$HOME/.local/share/heph/heph.db`. +pub fn default_db_path() -> PathBuf { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("heph").join("heph.db") +} diff --git a/crates/hephd/src/lock.rs b/crates/hephd/src/lock.rs new file mode 100644 index 0000000..e087406 --- /dev/null +++ b/crates/hephd/src/lock.rs @@ -0,0 +1,91 @@ +//! Exclusive lock on a store's SQLite file (tech-spec §3.1). +//! +//! A `local` or `server` process takes the file's exclusive lock on open, so +//! **only one can own a given DB file at a time**. Kill the owner → the lock +//! releases → another process can open the same file (the "lock handoff"). A +//! `client` never opens the file, so it never contends. +//! +//! Implemented as an advisory `flock` on a sidecar `.lock` file (held for +//! the process lifetime). POSIX treats two opens of the same file in one +//! process independently, so a second [`acquire`](LockGuard::acquire) on the +//! same path fails just as a second process would. + +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use fs4::fs_std::FileExt; + +/// Holds the exclusive lock for as long as it lives; drops release it. +pub struct LockGuard { + _file: File, + path: PathBuf, +} + +impl LockGuard { + /// Acquire the exclusive lock for the store at `db_path`. Errors if another + /// `local`/`server` process already holds it. + pub fn acquire(db_path: &Path) -> Result { + let path = lock_path_for(db_path); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("opening lock file {}", path.display()))?; + + // A failure here (typically `WouldBlock`) means another process holds + // the lock. + file.try_lock_exclusive().map_err(|e| { + anyhow::anyhow!( + "store {} is already locked by another hephd process: {e}", + db_path.display() + ) + })?; + Ok(LockGuard { _file: file, path }) + } + + /// The sidecar lock file path. + pub fn path(&self) -> &Path { + &self.path + } +} + +fn lock_path_for(db_path: &Path) -> PathBuf { + let mut name = db_path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + name.push(".lock"); + db_path.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn second_acquire_on_same_path_fails_then_succeeds_after_release() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + + let first = LockGuard::acquire(&db).expect("first acquire"); + assert!( + LockGuard::acquire(&db).is_err(), + "second concurrent acquire must fail" + ); + + drop(first); + // Once released, a new acquire succeeds (the handoff). + let _again = LockGuard::acquire(&db).expect("acquire after release"); + } + + #[test] + fn lock_path_is_a_sidecar() { + assert_eq!( + lock_path_for(Path::new("/data/heph.db")), + PathBuf::from("/data/heph.db.lock") + ); + } +} diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs new file mode 100644 index 0000000..5e0c1c1 --- /dev/null +++ b/crates/hephd/src/main.rs @@ -0,0 +1,61 @@ +//! `hephd` binary — starts the daemon in `local` mode (slice 6). + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use tokio::net::UnixListener; + +use heph_core::LocalStore; +use hephd::{default_db_path, default_socket_path, Daemon, LockGuard, SystemClock}; + +/// The Hephaestus per-device daemon. +#[derive(Parser, Debug)] +#[command(name = "hephd", version, about)] +struct Cli { + /// Path to the SQLite store file. + #[arg(long)] + db: Option, + + /// Path to the unix socket to listen on. + #[arg(long)] + socket: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + let db = cli.db.unwrap_or_else(default_db_path); + let socket = cli.socket.unwrap_or_else(default_socket_path); + + if let Some(parent) = db.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating store dir {}", parent.display()))?; + } + if let Some(parent) = socket.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating socket dir {}", parent.display()))?; + } + + // Take the exclusive lock before opening the store (tech-spec §3.1). + let _lock = LockGuard::acquire(&db)?; + let store = LocalStore::open(&db, Box::new(SystemClock))?; + + // Replace any stale socket from a previous run, then bind. + if socket.exists() { + std::fs::remove_file(&socket) + .with_context(|| format!("removing stale socket {}", socket.display()))?; + } + let listener = UnixListener::bind(&socket) + .with_context(|| format!("binding socket {}", socket.display()))?; + + tracing::info!(db = %db.display(), socket = %socket.display(), "hephd local mode listening"); + Daemon::new(store).serve(listener).await +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs new file mode 100644 index 0000000..ee87c7c --- /dev/null +++ b/crates/hephd/src/rpc.rs @@ -0,0 +1,227 @@ +//! JSON-RPC request/response types and the synchronous method dispatcher. +//! +//! The daemon speaks **line-delimited JSON-RPC** over a unix socket (tech-spec +//! §6, §10): one JSON object per line. [`dispatch`] is the pure, synchronous +//! heart — it maps a method name + params onto a [`heph_core::Store`] call and +//! is what the async transport runs on the blocking pool. The daemon is +//! **mode-agnostic**: Tactical/Strategic/Organizational are plugin-side +//! compositions of these primitives, not daemon concepts. + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use heph_core::{Attention, NewNode, NewTask, Store, TaskState}; + +/// A JSON-RPC request line. +#[derive(Debug, Deserialize)] +pub struct Request { + /// Correlation id, echoed in the response (any JSON value). + #[serde(default)] + pub id: Value, + /// The method name (e.g. `task.create`). + pub method: String, + /// Method parameters (an object); defaults to null when omitted. + #[serde(default)] + pub params: Value, +} + +/// A JSON-RPC response line — exactly one of `result`/`error` is present. +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + /// The request id this answers. + pub id: Value, + /// The successful result. + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + /// The error, if the call failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + /// A success response. + pub fn ok(id: Value, result: Value) -> Response { + Response { + id, + result: Some(result), + error: None, + } + } + + /// An error response. + pub fn failed(id: Value, error: RpcError) -> Response { + Response { + id, + result: None, + error: Some(error), + } + } +} + +/// A JSON-RPC error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + /// Machine-readable code (JSON-RPC conventions where applicable). + pub code: i64, + /// Human-readable message. + pub message: String, +} + +// Standard JSON-RPC codes plus a couple of app codes. +/// The request line was not valid JSON. +pub const PARSE_ERROR: i64 = -32700; +/// Params failed to deserialize for the method. +pub const INVALID_PARAMS: i64 = -32602; +/// No such method. +pub const METHOD_NOT_FOUND: i64 = -32601; +/// A store/internal failure. +pub const INTERNAL_ERROR: i64 = -32603; +/// A referenced node was not found. +pub const NOT_FOUND: i64 = -32004; + +impl RpcError { + fn new(code: i64, message: impl Into) -> RpcError { + RpcError { + code, + message: message.into(), + } + } +} + +impl From for RpcError { + fn from(e: heph_core::Error) -> RpcError { + match e { + heph_core::Error::NodeNotFound(_) => RpcError::new(NOT_FOUND, e.to_string()), + other => RpcError::new(INTERNAL_ERROR, other.to_string()), + } + } +} + +fn parse(params: Value) -> Result { + serde_json::from_value(params).map_err(|e| RpcError::new(INVALID_PARAMS, e.to_string())) +} + +#[derive(Deserialize)] +struct IdParam { + id: String, +} + +#[derive(Deserialize)] +struct UpdateParams { + id: String, + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, +} + +#[derive(Deserialize)] +struct SetStateParams { + id: String, + state: TaskState, +} + +#[derive(Deserialize)] +struct SetAttentionParams { + id: String, + attention: Attention, +} + +#[derive(Deserialize)] +struct NextParams { + #[serde(default)] + scope: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Deserialize)] +struct LinkParams { + id: String, +} + +#[derive(Deserialize)] +struct LogAppendParams { + task_id: String, + text: String, +} + +#[derive(Deserialize)] +struct LogTailParams { + task_id: String, + #[serde(default)] + n: Option, +} + +/// Default `next`/`list` result size (tech-spec §6). +const DEFAULT_LIMIT: usize = 5; +/// Default `log.tail` size. +const DEFAULT_TAIL: usize = 10; + +/// Dispatch one method call against `store`. Synchronous — the transport runs +/// this on a blocking pool. +pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + Ok(match method { + "node.get" => { + let p: IdParam = parse(params)?; + json!(store.get_node(&p.id)?) + } + "node.create" => { + let p: NewNode = parse(params)?; + json!(store.create_node(p)?) + } + "node.update" => { + let p: UpdateParams = parse(params)?; + json!(store.update_node(&p.id, p.title, p.body)?) + } + "node.tombstone" => { + let p: IdParam = parse(params)?; + store.tombstone_node(&p.id)?; + json!({ "ok": true }) + } + "task.create" => { + let p: NewTask = parse(params)?; + json!(store.create_task(p)?) + } + "task.set_state" => { + let p: SetStateParams = parse(params)?; + json!(store.set_task_state(&p.id, p.state)?) + } + "task.set_attention" => { + let p: SetAttentionParams = parse(params)?; + json!(store.set_task_attention(&p.id, p.attention)?) + } + "task.skip" => { + let p: IdParam = parse(params)?; + json!(store.skip_recurrence(&p.id)?) + } + "next" => { + let p: NextParams = parse(params)?; + json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?) + } + "links.outgoing" => { + let p: LinkParams = parse(params)?; + json!(store.outgoing_links(&p.id)?) + } + "links.backlinks" => { + let p: LinkParams = parse(params)?; + json!(store.backlinks(&p.id)?) + } + "log.append" => { + let p: LogAppendParams = parse(params)?; + store.log_append(&p.task_id, &p.text)?; + json!({ "ok": true }) + } + "log.tail" => { + let p: LogTailParams = parse(params)?; + json!(store.log_tail(&p.task_id, p.n.unwrap_or(DEFAULT_TAIL))?) + } + other => { + return Err(RpcError::new( + METHOD_NOT_FOUND, + format!("unknown method: {other}"), + )) + } + }) +} diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs new file mode 100644 index 0000000..f4c81b8 --- /dev/null +++ b/crates/hephd/src/server.rs @@ -0,0 +1,101 @@ +//! The async daemon: accepts unix-socket connections and serves the JSON-RPC +//! API by running [`rpc::dispatch`] on tokio's blocking pool. +//! +//! `heph-core` is synchronous and its SQLite handle is single-writer, so the +//! store sits behind an `Arc>`; each request locks it inside a +//! `spawn_blocking` task (DB calls never run on an async worker, tech-spec §3). + +use std::sync::{Arc, Mutex}; + +use anyhow::Result; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; + +use heph_core::LocalStore; + +use crate::rpc::{self, Request, Response, RpcError, PARSE_ERROR}; + +/// A running daemon over a shared local store. +pub struct Daemon { + store: Arc>, +} + +impl Daemon { + /// Wrap an opened store. + pub fn new(store: LocalStore) -> Daemon { + Daemon { + store: Arc::new(Mutex::new(store)), + } + } + + /// Serve connections on `listener` until the task is cancelled. Each + /// connection is handled concurrently; all share the one store. + pub async fn serve(&self, listener: UnixListener) -> Result<()> { + loop { + let (stream, _addr) = listener.accept().await?; + let store = self.store.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, store).await { + tracing::debug!("connection closed: {e}"); + } + }); + } + } +} + +async fn handle_connection(stream: UnixStream, store: Arc>) -> Result<()> { + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let response = process_line(&line, &store).await; + let mut out = serde_json::to_string(&response)?; + out.push('\n'); + write_half.write_all(out.as_bytes()).await?; + write_half.flush().await?; + } + Ok(()) +} + +async fn process_line(line: &str, store: &Arc>) -> Response { + let request: Request = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => { + return Response::failed( + Value::Null, + RpcError { + code: PARSE_ERROR, + message: e.to_string(), + }, + ) + } + }; + + let id = request.id.clone(); + let store = store.clone(); + let method = request.method; + let params = request.params; + + // DB work runs on the blocking pool; the store mutex is held only there. + let dispatched = tokio::task::spawn_blocking(move || { + let mut guard = store.lock().expect("store mutex poisoned"); + rpc::dispatch(&mut *guard, &method, params) + }) + .await; + + match dispatched { + Ok(Ok(result)) => Response::ok(id, result), + Ok(Err(rpc_err)) => Response::failed(id, rpc_err), + Err(join_err) => Response::failed( + id, + RpcError { + code: rpc::INTERNAL_ERROR, + message: format!("dispatch task failed: {join_err}"), + }, + ), + } +} diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs new file mode 100644 index 0000000..8951167 --- /dev/null +++ b/crates/hephd/tests/rpc_socket.rs @@ -0,0 +1,215 @@ +//! End-to-end daemon tests (tech-spec §9): a real `hephd` over a real unix +//! socket against a temp SQLite file, exercised by the sync client. Time is +//! clock-injected (FixedClock) so assertions are deterministic. + +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::net::UnixListener; + +use heph_core::{FixedClock, LocalStore}; +use hephd::{Client, Daemon}; + +const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z +const ONE_DAY: i64 = 86_400_000; +const NOW: i64 = JAN1 + ONE_DAY / 2; + +/// Start a daemon on its own thread+runtime against a temp DB and socket. +/// Returns the socket path; the returned `TempDir` keeps the files alive. +fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + let socket = dir.path().join("d.sock"); + + let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); + let socket_for_thread = socket.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = UnixListener::bind(&socket_for_thread).unwrap(); + let _ = Daemon::new(store).serve(listener).await; + }); + }); + + // Wait for the socket to appear. + for _ in 0..200 { + if socket.exists() { + break; + } + thread::sleep(Duration::from_millis(5)); + } + (socket, dir) +} + +fn client(socket: &Path) -> Client { + Client::connect(socket).unwrap() +} + +#[test] +fn node_create_and_get_round_trip_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let created = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Roof log", "body": "# Roof" }), + ) + .unwrap(); + assert_eq!(created["kind"], "doc"); + assert_eq!(created["title"], "Roof log"); + // Clock injection: created_at is the daemon's FixedClock value. + assert_eq!(created["created_at"], NOW); + + let id = created["id"].as_str().unwrap(); + let fetched = c.call("node.get", json!({ "id": id })).unwrap(); + assert_eq!(fetched, created); + + // A missing node is JSON null, not an error. + let missing = c.call("node.get", json!({ "id": "nope" })).unwrap(); + assert_eq!(missing, Value::Null); +} + +#[test] +fn task_create_appears_in_next_with_context_link() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let task = c + .call( + "task.create", + json!({ "title": "Fix the roof leak", "attention": "red" }), + ) + .unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + + let ranked = c.call("next", json!({ "limit": 5 })).unwrap(); + let arr = ranked.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["node_id"], task_id); + assert_eq!(arr[0]["attention"], "red"); + assert!(arr[0]["canonical_context_id"].is_string()); + + // The canonical-context link is present and points at a doc. + let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); + let ctx = links + .as_array() + .unwrap() + .iter() + .find(|l| l["link_type"] == "canonical-context") + .expect("canonical-context link"); + let doc = c.call("node.get", json!({ "id": ctx["dst_id"] })).unwrap(); + assert_eq!(doc["kind"], "doc"); +} + +#[test] +fn errors_are_reported_as_rpc_errors() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + // Unknown method. + let err = c.call("does.not.exist", json!({})).unwrap_err(); + assert!(err.to_string().contains("unknown method"), "{err}"); + + // set_state on a non-existent task → NotFound error. + let err = c + .call( + "task.set_state", + json!({ "id": "missing", "state": "done" }), + ) + .unwrap_err(); + assert!(err.to_string().contains("not found"), "{err}"); + + // Bad params (missing a genuinely-required field) → invalid params. + // (`node.create` needs `kind`; `NewTask` defaults all fields, so an empty + // `task.create` is valid, not an error.) + let err = c + .call("node.create", json!({ "title": "no kind" })) + .unwrap_err(); + assert!(err.to_string().contains("missing field"), "{err}"); +} + +#[test] +fn recurring_task_rolls_forward_over_rpc() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let task = c + .call( + "task.create", + json!({ + "title": "Morning routine", + "do_date": JAN1, + "recurrence": "FREQ=DAILY", + }), + ) + .unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + + // Find the canonical context doc and put a checked checklist in it. + let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); + let doc_id = links + .as_array() + .unwrap() + .iter() + .find(|l| l["link_type"] == "canonical-context") + .unwrap()["dst_id"] + .as_str() + .unwrap() + .to_string(); + c.call( + "node.update", + json!({ "id": doc_id, "body": "- [x] brush teeth\n- [x] coffee\n" }), + ) + .unwrap(); + + // Complete the occurrence → rolls forward. + let rolled = c + .call("task.set_state", json!({ "id": task_id, "state": "done" })) + .unwrap(); + assert_eq!(rolled["state"], "outstanding"); + assert_eq!(rolled["do_date"], JAN1 + ONE_DAY); + + // Fresh checklist; completion did not carry forward. + let doc = c.call("node.get", json!({ "id": doc_id })).unwrap(); + assert_eq!(doc["body"], "- [ ] brush teeth\n- [ ] coffee\n"); + + // The completion is in the log. + let log = c + .call("log.tail", json!({ "task_id": task_id, "n": 10 })) + .unwrap(); + assert_eq!(log.as_array().unwrap().len(), 1); +} + +#[test] +fn multiple_clients_concurrently_create_tasks() { + let (socket, _dir) = spawn_daemon(); + const N: usize = 8; + + let handles: Vec<_> = (0..N) + .map(|i| { + let socket = socket.clone(); + thread::spawn(move || { + let mut c = Client::connect(&socket).unwrap(); + c.call( + "task.create", + json!({ "title": format!("task {i}"), "attention": "orange" }), + ) + .unwrap(); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + // A fresh client sees all N tasks ranked. + let mut c = client(&socket); + let ranked = c.call("next", json!({ "limit": 100 })).unwrap(); + assert_eq!(ranked.as_array().unwrap().len(), N); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 2ac879e..c0a213f 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -5,4 +5,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent). - "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite. - Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging. +- `hephd` daemon, local mode (§3, §6): exclusive file lock (handoff-ready), line-delimited JSON-RPC over a unix socket exposing the node/task/next/links/log methods, with DB work on tokio's blocking pool. Synchronous client for surfaces/CLI. Model types are serde-serializable. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From 739214bd0767c3136153d2d7a7fc6937c6da4e47 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 20:33:59 -0700 Subject: [PATCH 07/91] heph CLI + export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 7 (tech-spec §1, §5, §9). - Export (heph-core): render each non-tombstoned node to `/.md` with YAML frontmatter (id, kind, title, timestamps, task scalars, aliases, outgoing links) + body. One-way snapshot; `Store::export` writes the tree; tombstones excluded. Added `export` RPC method and Error::Io. - `heph` CLI (clap): thin client of hephd over the socket — `next` (concise ranked rows), `task`, `doc`, `get`, `export`. Never touches SQLite directly. Tests: 3 export render unit + 2 export round-trip integration + 3 CLI process tests driving the real `heph` binary against a real daemon (task→next, empty-store message, export writes files). 70 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- Cargo.lock | 14 ++ Cargo.toml | 2 +- crates/heph-core/Cargo.toml | 1 + crates/heph-core/src/error.rs | 4 + crates/heph-core/src/export.rs | 185 +++++++++++++++++++++++ crates/heph-core/src/lib.rs | 2 + crates/heph-core/src/sqlite/exporter.rs | 51 +++++++ crates/heph-core/src/sqlite/mod.rs | 5 + crates/heph-core/src/sqlite/nodes.rs | 7 + crates/heph-core/src/store.rs | 4 + crates/heph-core/tests/export.rs | 58 +++++++ crates/heph/Cargo.toml | 24 +++ crates/heph/src/main.rs | 158 +++++++++++++++++++ crates/heph/tests/cli.rs | 97 ++++++++++++ crates/hephd/src/rpc.rs | 12 ++ docs/changelog.d/v1-prototype.feature.md | 1 + 17 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 crates/heph-core/src/export.rs create mode 100644 crates/heph-core/src/sqlite/exporter.rs create mode 100644 crates/heph-core/tests/export.rs create mode 100644 crates/heph/Cargo.toml create mode 100644 crates/heph/src/main.rs create mode 100644 crates/heph/tests/cli.rs diff --git a/AGENTS.md b/AGENTS.md index 01016dd..687a2eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too ./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction, # recurrence, "what is next?" ranking, op-log/HLC/CRDT sync ./crates/hephd/ # daemon: local mode done (JSON-RPC over unix socket + file lock); server/client modes planned -./crates/heph/ # CLI (planned): export, scripting, `heph conflicts` +./crates/heph/ # CLI: next/task/doc/get/export (thin client of hephd); `heph conflicts` planned ./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim ./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content ./docs/changelog.d/ # towncrier fragments for noteworthy changes diff --git a/Cargo.lock b/Cargo.lock index 20bf208..35fc93e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,19 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heph" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "heph-core", + "hephd", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "heph-core" version = "0.0.0" @@ -350,6 +363,7 @@ dependencies = [ "rrule", "rusqlite", "serde", + "tempfile", "thiserror 2.0.18", "ulid", ] diff --git a/Cargo.toml b/Cargo.toml index 8e20550..d3a6d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/heph-core", "crates/hephd"] +members = ["crates/heph-core", "crates/hephd", "crates/heph"] [workspace.package] edition = "2021" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index 3cb94c7..718de84 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -19,3 +19,4 @@ serde.workspace = true [dev-dependencies] proptest = "1" +tempfile = "3" diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs index e69957b..79cf597 100644 --- a/crates/heph-core/src/error.rs +++ b/crates/heph-core/src/error.rs @@ -7,6 +7,10 @@ pub enum Error { #[error("sqlite: {0}")] Sqlite(#[from] rusqlite::Error), + /// A filesystem failure (e.g. during `export`). + #[error("io: {0}")] + Io(#[from] std::io::Error), + /// The DB file is already locked by another `local`/`server` process. #[error("store is already locked by another process: {0}")] Locked(String), diff --git a/crates/heph-core/src/export.rs b/crates/heph-core/src/export.rs new file mode 100644 index 0000000..2173cc1 --- /dev/null +++ b/crates/heph-core/src/export.rs @@ -0,0 +1,185 @@ +//! Export — materialize the store as a directory tree of `.md` files +//! (tech-spec §5). +//! +//! A faithful, **one-way** portable snapshot: each non-tombstoned node becomes +//! `/.md` with YAML frontmatter (id, kind, title, timestamps, task +//! scalars, aliases, outgoing links) plus its markdown body. There is no import +//! in v1 — SQLite remains the source of truth. + +use std::fmt::Write as _; + +use crate::model::{Link, Node, Task}; + +/// One file to write during export. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportFile { + /// Path relative to the export root (e.g. `task/01ARZ….md`). + pub path: String, + /// Full file contents (frontmatter + body). + pub content: String, +} + +/// Everything needed to render one node's export file. +pub struct NodeExport<'a> { + /// The node itself. + pub node: &'a Node, + /// Its task scalars, if it is a task. + pub task: Option<&'a Task>, + /// Its aliases (wiki-link names), if any. + pub aliases: &'a [String], + /// Its non-tombstoned outgoing links. + pub links: &'a [Link], +} + +/// The relative path for a node's export file: `/.md`. +pub fn file_path(node: &Node) -> String { + format!("{}/{}.md", node.kind.as_str(), node.id) +} + +/// Render a node to its export file (frontmatter + body). +pub fn render(export: &NodeExport) -> ExportFile { + let n = export.node; + let mut fm = String::new(); + let _ = writeln!(fm, "---"); + let _ = writeln!(fm, "id: {}", n.id); + let _ = writeln!(fm, "kind: {}", n.kind.as_str()); + let _ = writeln!(fm, "title: {}", yaml_string(&n.title)); + let _ = writeln!(fm, "created_at: {}", n.created_at); + let _ = writeln!(fm, "modified_at: {}", n.modified_at); + + if let Some(task) = export.task { + let _ = writeln!(fm, "state: {}", task.state.as_str()); + if let Some(a) = task.attention { + let _ = writeln!(fm, "attention: {}", a.as_str()); + } + if let Some(d) = task.do_date { + let _ = writeln!(fm, "do_date: {d}"); + } + if let Some(l) = task.late_on { + let _ = writeln!(fm, "late_on: {l}"); + } + if let Some(r) = &task.recurrence { + let _ = writeln!(fm, "recurrence: {}", yaml_string(r)); + } + } + + if !export.aliases.is_empty() { + let _ = writeln!(fm, "aliases:"); + for alias in export.aliases { + let _ = writeln!(fm, " - {}", yaml_string(alias)); + } + } + + if !export.links.is_empty() { + let _ = writeln!(fm, "links:"); + for link in export.links { + let _ = writeln!( + fm, + " - {{ type: {}, dst: {} }}", + link.link_type.as_str(), + link.dst_id + ); + } + } + + let _ = writeln!(fm, "---"); + + let body = n.body.as_deref().unwrap_or(""); + let mut content = fm; + content.push_str(body); + if !content.ends_with('\n') { + content.push('\n'); + } + + ExportFile { + path: file_path(n), + content, + } +} + +/// Quote a scalar for YAML when needed; always quoting is safe and simplest. +fn yaml_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Attention, LinkType, NodeKind, TaskState}; + + fn node(kind: NodeKind, id: &str, title: &str, body: Option<&str>) -> Node { + Node { + id: id.into(), + owner_id: "u".into(), + kind, + title: title.into(), + body: body.map(str::to_string), + created_at: 100, + modified_at: 200, + hlc: "0".into(), + tombstoned: false, + } + } + + #[test] + fn doc_renders_frontmatter_and_body() { + let n = node(NodeKind::Doc, "D1", "Roof log", Some("# Roof\n\nnotes")); + let f = render(&NodeExport { + node: &n, + task: None, + aliases: &[], + links: &[], + }); + assert_eq!(f.path, "doc/D1.md"); + assert!(f.content.starts_with("---\nid: D1\nkind: doc\n")); + assert!(f.content.contains("title: \"Roof log\"\n")); + assert!(f.content.ends_with("# Roof\n\nnotes\n")); + } + + #[test] + fn task_renders_scalars_and_links() { + let n = node(NodeKind::Task, "T1", "Fix roof", None); + let task = Task { + node_id: "T1".into(), + attention: Some(Attention::Orange), + do_date: Some(555), + late_on: None, + state: TaskState::Outstanding, + recurrence: Some("FREQ=DAILY".into()), + }; + let links = vec![Link { + id: "L1".into(), + src_id: "T1".into(), + dst_id: "D9".into(), + link_type: LinkType::CanonicalContext, + created_at: 1, + tombstoned: false, + }]; + let f = render(&NodeExport { + node: &n, + task: Some(&task), + aliases: &[], + links: &links, + }); + assert!(f.content.contains("state: outstanding\n")); + assert!(f.content.contains("attention: orange\n")); + assert!(f.content.contains("do_date: 555\n")); + assert!(!f.content.contains("late_on")); + assert!(f.content.contains("recurrence: \"FREQ=DAILY\"\n")); + assert!(f + .content + .contains("- { type: canonical-context, dst: D9 }\n")); + } + + #[test] + fn title_quotes_are_escaped() { + let n = node(NodeKind::Doc, "D2", "She said \"hi\"", Some("")); + let f = render(&NodeExport { + node: &n, + task: None, + aliases: &[], + links: &[], + }); + assert!(f.content.contains(r#"title: "She said \"hi\"""#)); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 40d431a..d3c2b15 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod clock; pub mod error; +pub mod export; pub mod extract; pub mod model; pub mod ranking; @@ -19,6 +20,7 @@ pub mod store; pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; +pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/sqlite/exporter.rs b/crates/heph-core/src/sqlite/exporter.rs new file mode 100644 index 0000000..02b0bbe --- /dev/null +++ b/crates/heph-core/src/sqlite/exporter.rs @@ -0,0 +1,51 @@ +//! `export` — write the store to a directory tree of `.md` files (tech-spec §5). + +use std::fs; +use std::path::Path; + +use rusqlite::Connection; + +use super::{links, nodes, tasks}; +use crate::error::Result; +use crate::export::{render, NodeExport}; +use crate::model::NodeKind; + +/// Materialize every non-tombstoned node for `owner` under `dir`, returning the +/// count written. One-way snapshot; SQLite stays the source of truth. +pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result { + let ids: Vec = { + let mut stmt = conn.prepare( + "SELECT id FROM nodes WHERE owner_id = ?1 AND tombstoned = 0 ORDER BY created_at, id", + )?; + let rows = stmt.query_map([owner], |r| r.get(0))?; + rows.collect::>>()? + }; + + let mut count = 0; + for id in ids { + let Some(node) = nodes::get(conn, &id)? else { + continue; + }; + let task = if node.kind == NodeKind::Task { + tasks::get(conn, &id)? + } else { + None + }; + let aliases = nodes::aliases(conn, &id)?; + let outgoing = links::outgoing(conn, &id)?; + + let file = render(&NodeExport { + node: &node, + task: task.as_ref(), + aliases: &aliases, + links: &outgoing, + }); + let path = dir.join(&file.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, file.content)?; + count += 1; + } + Ok(count) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 323304b..f29b3f0 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -9,6 +9,7 @@ //! as free functions over a `&Connection`; the [`Store`] impl here is a thin //! delegating layer so a transaction can span several of them. +mod exporter; mod links; mod log; mod migrations; @@ -177,6 +178,10 @@ impl Store for LocalStore { fn log_tail(&self, task_id: &str, n: usize) -> Result> { log::tail(&self.conn, task_id, n) } + + fn export(&self, dir: &std::path::Path) -> Result { + exporter::export(&self.conn, &self.owner_id, dir) + } } #[cfg(test)] diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 1f68eac..04ee6a1 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -136,6 +136,13 @@ pub(super) fn update( Ok(node) } +/// A node's aliases (wiki-link names), sorted. Empty until aliases are written. +pub(super) fn aliases(conn: &Connection, id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; + let rows = stmt.query_map([id], |r| r.get(0))?; + Ok(rows.collect::>>()?) +} + /// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge /// monotonic (tech-spec §4.3). pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 260c60a..6b251e2 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -82,4 +82,8 @@ pub trait Store { /// The task's latest `n` log entries (oldest→newest); empty if it has none. fn log_tail(&self, task_id: &str, n: usize) -> Result>; + + /// Export every non-tombstoned node to a `.md` directory tree under `dir`, + /// returning the count written (tech-spec §5). One-way; no import. + fn export(&self, dir: &std::path::Path) -> Result; } diff --git a/crates/heph-core/tests/export.rs b/crates/heph-core/tests/export.rs new file mode 100644 index 0000000..f0f0f60 --- /dev/null +++ b/crates/heph-core/tests/export.rs @@ -0,0 +1,58 @@ +//! `Store::export` writes a faithful .md tree (tech-spec §5, slice 7). + +use heph_core::{FixedClock, LocalStore, NewNode, NewTask, Store}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn export_writes_a_file_per_node_with_frontmatter_and_body() { + let dir = tempfile::tempdir().unwrap(); + let mut s = store(); + + let doc = s + .create_node(NewNode::doc("Roof log", "# Roof\n\nCalled contractor.")) + .unwrap(); + let task = s + .create_task(NewTask { + title: "Fix roof".into(), + ..Default::default() + }) + .unwrap(); + + // task.create also makes a canonical context doc → 3 nodes total. + let count = s.export(dir.path()).unwrap(); + assert_eq!(count, 3); + + // The doc file exists with frontmatter and its body. + let doc_file = dir.path().join(format!("doc/{}.md", doc.id)); + let doc_text = std::fs::read_to_string(&doc_file).unwrap(); + assert!(doc_text.starts_with("---\n")); + assert!(doc_text.contains(&format!("id: {}\n", doc.id))); + assert!(doc_text.contains("kind: doc\n")); + assert!(doc_text.contains("title: \"Roof log\"\n")); + assert!(doc_text.ends_with("# Roof\n\nCalled contractor.\n")); + + // The task file carries its scalars and the canonical-context link. + let task_file = dir.path().join(format!("task/{}.md", task.node_id)); + let task_text = std::fs::read_to_string(&task_file).unwrap(); + assert!(task_text.contains("kind: task\n")); + assert!(task_text.contains("state: outstanding\n")); + assert!(task_text.contains("type: canonical-context")); +} + +#[test] +fn export_excludes_tombstoned_nodes() { + let dir = tempfile::tempdir().unwrap(); + let mut s = store(); + + let keep = s.create_node(NewNode::doc("Keep", "kept")).unwrap(); + let gone = s.create_node(NewNode::doc("Gone", "gone")).unwrap(); + s.tombstone_node(&gone.id).unwrap(); + + let count = s.export(dir.path()).unwrap(); + assert_eq!(count, 1); + assert!(dir.path().join(format!("doc/{}.md", keep.id)).exists()); + assert!(!dir.path().join(format!("doc/{}.md", gone.id)).exists()); +} diff --git a/crates/heph/Cargo.toml b/crates/heph/Cargo.toml new file mode 100644 index 0000000..dabed91 --- /dev/null +++ b/crates/heph/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "heph" +description = "Hephaestus CLI: a thin client of the local hephd daemon (utility/admin surface)." +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "heph" +path = "src/main.rs" + +[dependencies] +heph-core = { path = "../heph-core" } +hephd = { path = "../hephd" } +clap.workspace = true +serde_json.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tempfile = "3" +tokio.workspace = true diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs new file mode 100644 index 0000000..3ebb54c --- /dev/null +++ b/crates/heph/src/main.rs @@ -0,0 +1,158 @@ +//! `heph` — the CLI surface (tech-spec §1). A thin client of the local +//! `hephd`: it never touches SQLite, only the daemon socket. Secondary to +//! `heph.nvim`; for scripting, admin, smoke tests, and `export`. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use serde_json::{json, Value}; + +use heph_core::{Node, RankedTask, Task}; +use hephd::{default_socket_path, Client}; + +#[derive(Parser, Debug)] +#[command(name = "heph", version, about)] +struct Cli { + /// Path to the hephd unix socket (defaults to the standard runtime path). + #[arg(long, global = true)] + socket: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Show the Tactical "what is next?" ranking. + Next { + /// Restrict to a project node id. + #[arg(long)] + scope: Option, + /// Maximum rows (red items always show). + #[arg(long, default_value_t = 5)] + limit: usize, + }, + /// Create a committed task (auto-creates its canonical context doc). + Task { + /// The task title. + title: String, + /// Attention-state: white|orange|red|blue. + #[arg(long)] + attention: Option, + /// Earliest-actionable date, epoch ms. + #[arg(long)] + do_date: Option, + /// Lateness-problem marker, epoch ms. + #[arg(long)] + late_on: Option, + /// Project node id to file it under. + #[arg(long)] + project: Option, + /// RFC-5545 RRULE for a recurring task. + #[arg(long)] + recurrence: Option, + }, + /// Create a document node. + Doc { + /// The document title. + title: String, + /// Markdown body. + #[arg(long)] + body: Option, + }, + /// Fetch a node by id and print it as JSON. + Get { + /// Node id. + id: String, + }, + /// Export the store to a directory tree of .md files. + Export { + /// Destination directory (created if needed). + dir: PathBuf, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = cli.socket.unwrap_or_else(default_socket_path); + let mut client = Client::connect(&socket)?; + + match cli.command { + Command::Next { scope, limit } => { + let result = client.call("next", json!({ "scope": scope, "limit": limit }))?; + let tasks: Vec = serde_json::from_value(result)?; + if tasks.is_empty() { + println!("Nothing actionable right now."); + } + for t in &tasks { + println!("{}", format_row(t)); + } + } + Command::Task { + title, + attention, + do_date, + late_on, + project, + recurrence, + } => { + let result = client.call( + "task.create", + json!({ + "title": title, + "attention": attention, + "do_date": do_date, + "late_on": late_on, + "project_id": project, + "recurrence": recurrence, + }), + )?; + let task: Task = serde_json::from_value(result)?; + println!("Created task {} \"{title}\"", task.node_id); + } + Command::Doc { title, body } => { + let result = client.call( + "node.create", + json!({ "kind": "doc", "title": title, "body": body }), + )?; + let node: Node = serde_json::from_value(result)?; + println!("Created doc {} \"{}\"", node.id, node.title); + } + Command::Get { id } => { + let result = client.call("node.get", json!({ "id": id }))?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + Command::Export { dir } => { + let path = dir + .to_str() + .context("export path is not valid UTF-8")? + .to_string(); + let result = client.call("export", json!({ "path": path }))?; + let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); + println!("Exported {count} nodes to {}", dir.display()); + } + } + Ok(()) +} + +/// One concise Tactical row: attention tag, title, and do/late context. +fn format_row(t: &RankedTask) -> String { + let tag = t + .attention + .map(|a| format!("[{}]", serde_json::to_value(a).unwrap().as_str().unwrap())) + .unwrap_or_else(|| "[ ]".to_string()); + let mut extra = Vec::new(); + if let Some(d) = t.do_date { + extra.push(format!("do:{d}")); + } + if let Some(l) = t.late_on { + extra.push(format!("late:{l}")); + } + let suffix = if extra.is_empty() { + String::new() + } else { + format!(" ({})", extra.join(", ")) + }; + format!("{tag} {}{suffix}", t.title) +} diff --git a/crates/heph/tests/cli.rs b/crates/heph/tests/cli.rs new file mode 100644 index 0000000..8a552b4 --- /dev/null +++ b/crates/heph/tests/cli.rs @@ -0,0 +1,97 @@ +//! CLI tests (tech-spec §9): run the real `heph` binary against a real `hephd` +//! over a unix socket, and assert output + side effects (export files). + +use std::path::PathBuf; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use tokio::net::UnixListener; + +use heph_core::{FixedClock, LocalStore}; +use hephd::Daemon; + +const NOW: i64 = 1_704_067_200_000; + +/// Spawn a daemon on its own thread+runtime; return (socket, tempdir). +fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + let socket = dir.path().join("d.sock"); + + let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); + let socket_for_thread = socket.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = UnixListener::bind(&socket_for_thread).unwrap(); + let _ = Daemon::new(store).serve(listener).await; + }); + }); + for _ in 0..200 { + if socket.exists() { + break; + } + thread::sleep(Duration::from_millis(5)); + } + (socket, dir) +} + +/// Run the `heph` binary against `socket`; return (stdout, success). +fn heph(socket: &std::path::Path, args: &[&str]) -> (String, bool) { + let out = Command::new(env!("CARGO_BIN_EXE_heph")) + .arg("--socket") + .arg(socket) + .args(args) + .output() + .expect("run heph"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.success(), + ) +} + +#[test] +fn task_then_next_shows_the_task() { + let (socket, _dir) = spawn_daemon(); + + let (out, ok) = heph(&socket, &["task", "Buy milk", "--attention", "red"]); + assert!(ok, "task create failed: {out}"); + assert!(out.contains("Created task"), "{out}"); + + let (out, ok) = heph(&socket, &["next"]); + assert!(ok); + assert!(out.contains("[red]"), "{out}"); + assert!(out.contains("Buy milk"), "{out}"); +} + +#[test] +fn next_on_empty_store_is_friendly() { + let (socket, _dir) = spawn_daemon(); + let (out, ok) = heph(&socket, &["next"]); + assert!(ok); + assert!(out.contains("Nothing actionable"), "{out}"); +} + +#[test] +fn export_writes_markdown_files() { + let (socket, dir) = spawn_daemon(); + heph(&socket, &["doc", "Roof log", "--body", "# Roof"]); + + let export_dir = dir.path().join("export"); + let (out, ok) = heph(&socket, &["export", export_dir.to_str().unwrap()]); + assert!(ok, "export failed: {out}"); + assert!(out.contains("Exported 1 nodes"), "{out}"); + + // The doc landed as a .md file under doc/. + let docs: Vec<_> = std::fs::read_dir(export_dir.join("doc")) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(docs.len(), 1); + let text = std::fs::read_to_string(docs[0].path()).unwrap(); + assert!(text.contains("title: \"Roof log\""), "{text}"); +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index ee87c7c..981388b 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -7,6 +7,8 @@ //! **mode-agnostic**: Tactical/Strategic/Organizational are plugin-side //! compositions of these primitives, not daemon concepts. +use std::path::Path; + use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -154,6 +156,11 @@ struct LogTailParams { n: Option, } +#[derive(Deserialize)] +struct ExportParams { + path: String, +} + /// Default `next`/`list` result size (tech-spec §6). const DEFAULT_LIMIT: usize = 5; /// Default `log.tail` size. @@ -217,6 +224,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ExportParams = parse(params)?; + let count = store.export(Path::new(&p.path))?; + json!({ "count": count }) + } other => { return Err(RpcError::new( METHOD_NOT_FOUND, diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index c0a213f..f1275ba 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -6,4 +6,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite. - Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging. - `hephd` daemon, local mode (§3, §6): exclusive file lock (handoff-ready), line-delimited JSON-RPC over a unix socket exposing the node/task/next/links/log methods, with DB work on tokio's blocking pool. Synchronous client for surfaces/CLI. Model types are serde-serializable. +- `heph` CLI (§1) — a thin client of the daemon: `next`, `task`, `doc`, `get`, `export`. Export materializes the store to a `/.md` tree with YAML frontmatter + body (§5), one-way, tombstones excluded. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. -- 2.50.1 (Apple Git-155) From d749c2a428e5e48a8b3d515793c1e8d96864b165 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 20:40:33 -0700 Subject: [PATCH 08/91] =?UTF-8?q?heph-core:=20Organizational=20list,=20hea?= =?UTF-8?q?lth,=20journal=20(=C2=A76,=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the local query surface ahead of the distributed layer: - list(scope, attention, include_blue): the Organizational survey — outstanding committed tasks incl. backlog, with project/attention filters and an include_blue toggle. - health(): working-set tensions surfaced honestly — orange / active (white+orange+red) / on-deck (blue) counts; conflict_count + sync_status reserved for sync. - journal.open_or_create(date): deterministic id in (owner, ISO-date) (§3.1) so offline replicas converge; idempotent; rejects non-ISO dates. - Exposed over RPC (list / health / journal.open_or_create). 6 integration tests; 76 total green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/lib.rs | 5 +- crates/heph-core/src/model.rs | 23 +++++ crates/heph-core/src/sqlite/mod.rs | 20 ++++- crates/heph-core/src/sqlite/nodes.rs | 46 +++++++++- crates/heph-core/src/sqlite/tasks.rs | 81 ++++++++++++++++- crates/heph-core/src/store.rs | 18 +++- crates/heph-core/tests/query_surface.rs | 111 ++++++++++++++++++++++++ crates/hephd/src/rpc.rs | 29 +++++++ 8 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 crates/heph-core/tests/query_surface.rs diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index d3c2b15..3315777 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -22,7 +22,10 @@ pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; -pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; +pub use model::{ + deterministic_id, Attention, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, + TaskState, +}; pub use ranking::{rank, Dimension, RankedTask, RANKING}; pub use recurrence::{next_occurrence, reset_checkboxes}; pub use sqlite::LocalStore; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 28f8054..728db08 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -248,6 +248,29 @@ pub struct NewTask { pub project_id: Option, } +/// Working-set health — the §6.2 tensions, surfaced honestly (tech-spec §7). +/// Never masks overload nor manufactures calm. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Health { + /// Outstanding `orange` tasks (target ≤ 6). + pub orange_count: usize, + /// Outstanding white+orange+red — the working set (target ≤ ~30). + pub active_count: usize, + /// Outstanding `blue` tasks — on-deck/backlog (target < 100). + pub on_deck_count: usize, + /// Open merge conflicts (0 until sync lands). + pub conflict_count: usize, + /// Sync indicator (`"local"` until sync lands). + pub sync_status: String, +} + +/// Deterministic id for key-unique kinds (`journal`/`tag`) so two offline +/// replicas that independently create the same logical singleton converge +/// (tech-spec §3.1, [[design]] §3.1). Content nodes use random ULIDs instead. +pub fn deterministic_id(owner_id: &str, kind: NodeKind, key: &str) -> String { + format!("{}:{owner_id}:{key}", kind.as_str()) +} + /// Input for creating a node. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewNode { diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index f29b3f0..f21e820 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -25,7 +25,7 @@ use ulid::Ulid; use crate::clock::Clock; use crate::error::Result; -use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::ranking::RankedTask; use crate::store::Store; @@ -154,6 +154,24 @@ impl Store for LocalStore { tasks::next(&self.conn, &self.owner_id, now, scope, limit) } + fn list( + &self, + scope: Option<&str>, + attention: Option, + include_blue: bool, + ) -> Result> { + tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue) + } + + fn health(&self) -> Result { + tasks::health(&self.conn, &self.owner_id) + } + + fn journal_open_or_create(&mut self, date: &str) -> Result { + let now = self.clock.now_ms(); + nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) + } + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { let now = self.clock.now_ms(); links::add(&self.conn, now, src_id, dst_id, link_type) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 04ee6a1..5cfd48e 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; use super::{hlc_for, links, new_id}; use crate::error::{Error, Result}; -use crate::model::{NewNode, Node, NodeKind}; +use crate::model::{deterministic_id, NewNode, Node, NodeKind}; /// The `nodes` columns in a fixed order, shared by every SELECT here. pub(super) const COLUMNS: &str = @@ -74,6 +74,50 @@ pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) - Ok(node) } +/// Open today's (or `date`'s) journal node, creating it if absent. The id is +/// **deterministic** in `(owner, date)` so independent offline creations +/// converge (tech-spec §3.1). `date` must be an ISO `YYYY-MM-DD`. +pub(super) fn open_or_create_journal( + conn: &Connection, + owner: &str, + now: i64, + date: &str, +) -> Result { + if !is_iso_date(date) { + return Err(Error::Integrity(format!( + "journal date must be YYYY-MM-DD, got {date:?}" + ))); + } + let id = deterministic_id(owner, NodeKind::Journal, date); + if let Some(existing) = get(conn, &id)? { + return Ok(existing); + } + let node = Node { + id, + owner_id: owner.to_string(), + kind: NodeKind::Journal, + title: date.to_string(), + body: Some(String::new()), + created_at: now, + modified_at: now, + hlc: hlc_for(now), + tombstoned: false, + }; + insert(conn, &node)?; + Ok(node) +} + +fn is_iso_date(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 10 + && b[4] == b'-' + && b[7] == b'-' + && b.iter().enumerate().all(|(i, c)| match i { + 4 | 7 => *c == b'-', + _ => c.is_ascii_digit(), + }) +} + /// Fetch a node by id (tombstoned rows included). pub(super) fn get(conn: &Connection, id: &str) -> Result> { let node = conn diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index be836bd..dfbdb0c 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -7,7 +7,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; use super::{hlc_for, links, log, nodes}; use crate::error::{Error, Result}; -use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; +use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::ranking::{self, RankedTask}; use crate::recurrence; @@ -215,6 +215,85 @@ pub(super) fn next( Ok(ranking::rank(candidates, now, scope, limit)) } +/// Enumerate outstanding committed tasks for the Organizational view (the whole +/// set incl. backlog, tech-spec §6). Optional `scope` (project) and `attention` +/// filters; `include_blue` keeps on-deck items (default true for `list`). +pub(super) fn list( + conn: &Connection, + owner: &str, + scope: Option<&str>, + attention: Option, + include_blue: bool, +) -> Result> { + let sql = " + SELECT t.node_id, t.attention, t.do_date, t.late_on, t.state, t.recurrence, + (SELECT dst_id FROM links + WHERE src_id = t.node_id AND type = 'in-project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS project_id + FROM tasks t JOIN nodes n ON n.id = t.node_id + WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding' + ORDER BY n.created_at, n.id"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([owner], |row| { + let task = from_row(row)?; + let project: Option = row.get("project_id")?; + Ok((task, project)) + })?; + + let mut out = Vec::new(); + for row in rows { + let (task, project) = row?; + if let Some(s) = scope { + if project.as_deref() != Some(s) { + continue; + } + } + if let Some(a) = attention { + if task.attention != Some(a) { + continue; + } + } + if !include_blue && task.attention == Some(Attention::Blue) { + continue; + } + out.push(task); + } + Ok(out) +} + +/// Working-set health counts (tech-spec §7) — surfaced honestly. +pub(super) fn health(conn: &Connection, owner: &str) -> Result { + let mut stmt = conn.prepare( + "SELECT t.attention FROM tasks t JOIN nodes n ON n.id = t.node_id + WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'", + )?; + let attentions: Vec> = stmt + .query_map([owner], |r| r.get(0))? + .collect::>>()?; + + let mut orange_count = 0; + let mut active_count = 0; + let mut on_deck_count = 0; + for a in attentions.iter().flatten() { + match a.as_str() { + "orange" => { + orange_count += 1; + active_count += 1; + } + "red" | "white" => active_count += 1, + "blue" => on_deck_count += 1, + _ => {} + } + } + Ok(Health { + orange_count, + active_count, + on_deck_count, + conflict_count: 0, + sync_status: "local".to_string(), + }) +} + /// Load every non-tombstoned committed task for `owner` as a ranking candidate, /// joining in its project and canonical-context link targets. fn load_candidates(conn: &Connection, owner: &str) -> Result> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 6b251e2..8d2b2a2 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,7 +5,7 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; -use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::ranking::RankedTask; /// A backend that can store and retrieve nodes, tasks, and links. @@ -63,6 +63,22 @@ pub trait Store { /// node id; `red` items always appear regardless of `limit`. fn next(&self, scope: Option<&str>, limit: usize) -> Result>; + /// Enumerate outstanding committed tasks for the Organizational view — the + /// whole set incl. backlog (tech-spec §6). `include_blue` keeps on-deck. + fn list( + &self, + scope: Option<&str>, + attention: Option, + include_blue: bool, + ) -> Result>; + + /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). + fn health(&self) -> Result; + + /// Open (creating if absent) the journal node for an ISO `date`. The id is + /// deterministic in `(owner, date)` so offline replicas converge (§3.1). + fn journal_open_or_create(&mut self, date: &str) -> Result; + // --- links --- /// Add a typed link between two nodes. diff --git a/crates/heph-core/tests/query_surface.rs b/crates/heph-core/tests/query_surface.rs new file mode 100644 index 0000000..3ebe0d6 --- /dev/null +++ b/crates/heph-core/tests/query_surface.rs @@ -0,0 +1,111 @@ +//! list / health / journal — the Organizational + working-set surface (§6, §7). + +use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +fn task(s: &mut LocalStore, title: &str, attention: Attention) -> String { + s.create_task(NewTask { + title: title.into(), + attention: Some(attention), + ..Default::default() + }) + .unwrap() + .node_id +} + +#[test] +fn list_enumerates_outstanding_including_blue_by_default() { + let mut s = store(); + task(&mut s, "white", Attention::White); + task(&mut s, "blue", Attention::Blue); + let done = task(&mut s, "done", Attention::Red); + s.set_task_state(&done, TaskState::Done).unwrap(); + + // Default list: outstanding only, blue included; done excluded. + let all = s.list(None, None, true).unwrap(); + let titles: Vec<_> = all.iter().map(|t| t.attention.unwrap()).collect::>(); + assert_eq!(all.len(), 2); + assert!(titles.contains(&Attention::White)); + assert!(titles.contains(&Attention::Blue)); +} + +#[test] +fn list_can_exclude_blue_and_filter_by_attention() { + let mut s = store(); + task(&mut s, "white", Attention::White); + task(&mut s, "blue", Attention::Blue); + task(&mut s, "orange1", Attention::Orange); + task(&mut s, "orange2", Attention::Orange); + + assert_eq!(s.list(None, None, false).unwrap().len(), 3); // blue excluded + assert_eq!( + s.list(None, Some(Attention::Orange), true).unwrap().len(), + 2 + ); +} + +#[test] +fn list_scopes_to_a_project() { + let mut s = store(); + let project = s + .create_node(heph_core::NewNode { + kind: heph_core::NodeKind::Project, + title: "Work".into(), + body: None, + }) + .unwrap(); + s.create_task(NewTask { + title: "work task".into(), + attention: Some(Attention::White), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + task(&mut s, "life task", Attention::White); + + let scoped = s.list(Some(&project.id), None, true).unwrap(); + assert_eq!(scoped.len(), 1); +} + +#[test] +fn health_counts_the_working_set_honestly() { + let mut s = store(); + task(&mut s, "r", Attention::Red); + task(&mut s, "o1", Attention::Orange); + task(&mut s, "o2", Attention::Orange); + task(&mut s, "w", Attention::White); + task(&mut s, "b1", Attention::Blue); + task(&mut s, "b2", Attention::Blue); + + let h = s.health().unwrap(); + assert_eq!(h.orange_count, 2); + assert_eq!(h.active_count, 4); // red + 2 orange + white + assert_eq!(h.on_deck_count, 2); // 2 blue + assert_eq!(h.conflict_count, 0); + assert_eq!(h.sync_status, "local"); +} + +#[test] +fn journal_open_or_create_is_idempotent_with_deterministic_id() { + let mut s = store(); + let a = s.journal_open_or_create("2026-05-31").unwrap(); + let b = s.journal_open_or_create("2026-05-31").unwrap(); + assert_eq!(a.id, b.id); + assert_eq!(a.kind, heph_core::NodeKind::Journal); + assert_eq!(a.title, "2026-05-31"); + // The id is deterministic in (owner, date). + assert_eq!( + a.id, + heph_core::deterministic_id(s.owner_id(), heph_core::NodeKind::Journal, "2026-05-31") + ); +} + +#[test] +fn journal_rejects_non_iso_dates() { + let mut s = store(); + assert!(s.journal_open_or_create("May 31").is_err()); + assert!(s.journal_open_or_create("2026-5-1").is_err()); +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 981388b..9b28aa2 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -138,6 +138,26 @@ struct NextParams { limit: Option, } +#[derive(Deserialize)] +struct ListParams { + #[serde(default)] + scope: Option, + #[serde(default)] + attention: Option, + /// Keep on-deck (blue) items; defaults to true for the survey view. + #[serde(default = "default_true")] + include_blue: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize)] +struct JournalParams { + date: String, +} + #[derive(Deserialize)] struct LinkParams { id: String, @@ -207,6 +227,15 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ListParams = parse(params)?; + json!(store.list(p.scope.as_deref(), p.attention, p.include_blue)?) + } + "health" => json!(store.health()?), + "journal.open_or_create" => { + let p: JournalParams = parse(params)?; + json!(store.journal_open_or_create(&p.date)?) + } "links.outgoing" => { let p: LinkParams = parse(params)?; json!(store.outgoing_links(&p.id)?) -- 2.50.1 (Apple Git-155) From 5d8ec45c5595989c27bc3ed9c1decc3f0dbf1356 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 20:43:05 -0700 Subject: [PATCH 09/91] heph-core: full-text search (FTS5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice query-surface, part 2 (tech-spec §6). Migration v2 adds an FTS5 external-content table over nodes(title, body), kept in sync by insert/update/delete triggers (with a backfill for existing rows). - Store::search(query): owner-scoped, tombstones excluded, best-match first (FTS5 MATCH + rank). Exposed over RPC; `heph search` and `heph journal` CLI commands added. 3 search integration tests (title/body match, edits reflected via trigger, tombstone exclusion, all insert paths indexed). 79 tests green. This completes the local feature surface; the remaining slices are the distributed/auth/nvim layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/sqlite/migrations.rs | 24 ++++++++- crates/heph-core/src/sqlite/mod.rs | 4 ++ crates/heph-core/src/sqlite/nodes.rs | 19 +++++++ crates/heph-core/src/store.rs | 4 ++ crates/heph-core/tests/search.rs | 65 +++++++++++++++++++++++ crates/heph/src/main.rs | 25 +++++++++ crates/hephd/src/rpc.rs | 9 ++++ 7 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 crates/heph-core/tests/search.rs diff --git a/crates/heph-core/src/sqlite/migrations.rs b/crates/heph-core/src/sqlite/migrations.rs index 62a7e55..a6ac4e2 100644 --- a/crates/heph-core/src/sqlite/migrations.rs +++ b/crates/heph-core/src/sqlite/migrations.rs @@ -10,7 +10,7 @@ use rusqlite::Connection; /// The ordered list of migrations. Never reorder or mutate a shipped entry — /// only append. -const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_0001)]; +const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_0001), (2, MIGRATION_0002)]; /// v1 — the base node graph, identity, and sync scaffolding (tech-spec §4.5). const MIGRATION_0001: &str = r#" @@ -93,6 +93,28 @@ CREATE TABLE conflicts ( ); "#; +/// v2 — full-text search over title + body via FTS5 (external content over +/// `nodes`), kept in sync by triggers (tech-spec §4.5). +const MIGRATION_0002: &str = r#" +CREATE VIRTUAL TABLE nodes_fts USING fts5( + title, body, content='nodes', content_rowid='rowid' +); + +-- Index any rows that already exist. +INSERT INTO nodes_fts(rowid, title, body) SELECT rowid, title, body FROM nodes; + +CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body); +END; +CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body); +END; +CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body); + INSERT INTO nodes_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body); +END; +"#; + /// Apply all pending migrations to `conn`. pub fn migrate(conn: &Connection) -> Result<()> { let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index f21e820..65abc82 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -167,6 +167,10 @@ impl Store for LocalStore { tasks::health(&self.conn, &self.owner_id) } + fn search(&self, query: &str) -> Result> { + nodes::search(&self.conn, &self.owner_id, query) + } + fn journal_open_or_create(&mut self, date: &str) -> Result { let now = self.clock.now_ms(); nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 5cfd48e..09de2be 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -180,6 +180,25 @@ pub(super) fn update( Ok(node) } +/// Full-text search over title + body, owner-scoped, excluding tombstoned +/// nodes, best-match first (tech-spec §6). `query` is FTS5 MATCH syntax. +pub(super) fn search(conn: &Connection, owner: &str, query: &str) -> Result> { + let sql = format!( + "SELECT {} FROM nodes n + JOIN nodes_fts f ON f.rowid = n.rowid + WHERE nodes_fts MATCH ?1 AND n.owner_id = ?2 AND n.tombstoned = 0 + ORDER BY rank", + COLUMNS + .split(", ") + .map(|c| format!("n.{c}")) + .collect::>() + .join(", ") + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map((query, owner), from_row)?; + Ok(rows.collect::>>()?) +} + /// A node's aliases (wiki-link names), sorted. Empty until aliases are written. pub(super) fn aliases(conn: &Connection, id: &str) -> Result> { let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 8d2b2a2..2893565 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -75,6 +75,10 @@ pub trait Store { /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; + /// Full-text search over title + body (FTS5), owner-scoped, best-match + /// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax. + fn search(&self, query: &str) -> Result>; + /// Open (creating if absent) the journal node for an ISO `date`. The id is /// deterministic in `(owner, date)` so offline replicas converge (§3.1). fn journal_open_or_create(&mut self, date: &str) -> Result; diff --git a/crates/heph-core/tests/search.rs b/crates/heph-core/tests/search.rs new file mode 100644 index 0000000..dac586c --- /dev/null +++ b/crates/heph-core/tests/search.rs @@ -0,0 +1,65 @@ +//! Full-text search over title + body via FTS5 (tech-spec §6, slice query-surface). + +use heph_core::{FixedClock, LocalStore, NewNode, NodeKind, Store}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn search_matches_title_and_body() { + let mut s = store(); + let roof = s + .create_node(NewNode::doc( + "Roof repair", + "Called the contractor about shingles.", + )) + .unwrap(); + s.create_node(NewNode::doc("Garden", "Plant tomatoes in spring.")) + .unwrap(); + + // Body term. + let hits = s.search("contractor").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, roof.id); + + // Title term. + let hits = s.search("roof").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, roof.id); + + // No match. + assert!(s.search("nonexistentword").unwrap().is_empty()); +} + +#[test] +fn search_reflects_edits_and_excludes_tombstoned() { + let mut s = store(); + let n = s.create_node(NewNode::doc("Notes", "alpha")).unwrap(); + + assert_eq!(s.search("alpha").unwrap().len(), 1); + assert!(s.search("bravo").unwrap().is_empty()); + + // Edit the body → FTS index follows via the update trigger. + s.update_node(&n.id, None, Some("bravo charlie".into())) + .unwrap(); + assert!(s.search("alpha").unwrap().is_empty()); + assert_eq!(s.search("bravo").unwrap().len(), 1); + + // Tombstoned nodes drop out of results. + s.tombstone_node(&n.id).unwrap(); + assert!(s.search("bravo").unwrap().is_empty()); +} + +#[test] +fn search_indexes_all_node_insert_paths() { + // Nodes created through paths other than `create_node` (here a journal) + // are indexed too, since the FTS triggers fire on every nodes insert. + let mut s = store(); + let j = s.journal_open_or_create("2026-05-31").unwrap(); + s.update_node(&j.id, None, Some("dentist appointment".into())) + .unwrap(); + let hits = s.search("dentist").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].kind, NodeKind::Journal); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 3ebb54c..ddccc11 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -66,6 +66,16 @@ enum Command { /// Node id. id: String, }, + /// Full-text search over titles and bodies. + Search { + /// FTS5 query. + query: String, + }, + /// Open (or create) the journal for an ISO date (YYYY-MM-DD). + Journal { + /// The ISO date. + date: String, + }, /// Export the store to a directory tree of .md files. Export { /// Destination directory (created if needed). @@ -123,6 +133,21 @@ fn main() -> Result<()> { let result = client.call("node.get", json!({ "id": id }))?; println!("{}", serde_json::to_string_pretty(&result)?); } + Command::Search { query } => { + let result = client.call("search", json!({ "query": query }))?; + let nodes: Vec = serde_json::from_value(result)?; + if nodes.is_empty() { + println!("No matches."); + } + for n in &nodes { + println!("{} [{}] {}", n.id, n.kind.as_str(), n.title); + } + } + Command::Journal { date } => { + let result = client.call("journal.open_or_create", json!({ "date": date }))?; + let node: Node = serde_json::from_value(result)?; + println!("Journal {} ({})", node.title, node.id); + } Command::Export { dir } => { let path = dir .to_str() diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 9b28aa2..29c5b3f 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -158,6 +158,11 @@ struct JournalParams { date: String, } +#[derive(Deserialize)] +struct SearchParams { + query: String, +} + #[derive(Deserialize)] struct LinkParams { id: String, @@ -232,6 +237,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!(store.health()?), + "search" => { + let p: SearchParams = parse(params)?; + json!(store.search(&p.query)?) + } "journal.open_or_create" => { let p: JournalParams = parse(params)?; json!(store.journal_open_or_create(&p.date)?) -- 2.50.1 (Apple Git-155) From c81d45a291cf2417e8b030c9f336af6c1e3839a1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 21:13:55 -0700 Subject: [PATCH 10/91] heph-core: real HLC + persistent device origin (sync 8a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First sync slice. Ratifies yrs for body merge in the tech-spec. - hlc module: Hlc (physical, counter, origin) with a fixed-width encoding whose lexical order equals causal order; HlcClock generator (tick/update) — clock-injected, strictly monotonic. Unit + 2 proptests. - meta table (migration v3) holds the stable per-device `origin` and the last HLC. next_hlc() does a read-modify-write inside the caller's transaction (store is single-writer, so no race), replacing the timestamp placeholder. Every node write is now stamped with a real, monotonic, causally-ordered HLC. 4 stamping integration tests (monotonic under stalled/regressed clock; origin shared + persists across reopen; HLC resumes from persisted state). 89 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/hlc.rs | 222 ++++++++++++++++++++++ crates/heph-core/src/lib.rs | 2 + crates/heph-core/src/sqlite/log.rs | 7 +- crates/heph-core/src/sqlite/migrations.rs | 15 +- crates/heph-core/src/sqlite/mod.rs | 64 ++++++- crates/heph-core/src/sqlite/nodes.rs | 21 +- crates/heph-core/src/sqlite/tasks.rs | 17 +- crates/heph-core/tests/hlc_stamping.rs | 86 +++++++++ docs/reference/tech-spec.md | 2 +- 9 files changed, 416 insertions(+), 20 deletions(-) create mode 100644 crates/heph-core/src/hlc.rs create mode 100644 crates/heph-core/tests/hlc_stamping.rs diff --git a/crates/heph-core/src/hlc.rs b/crates/heph-core/src/hlc.rs new file mode 100644 index 0000000..9f80b9c --- /dev/null +++ b/crates/heph-core/src/hlc.rs @@ -0,0 +1,222 @@ +//! Hybrid Logical Clock (tech-spec §12). +//! +//! Every operation is stamped with an [`Hlc`] so concurrent edits across +//! offline devices have a deterministic causal order. An HLC blends physical +//! wall-clock ms (from the injected clock — never read ambiently here) with a +//! logical counter that breaks ties when physical time doesn't advance, plus +//! the originating device id as a final tiebreak. +//! +//! The encoded form is fixed-width so lexical string order equals causal order +//! — that's what the `hlc` TEXT columns and op-log cursor rely on. + +use crate::error::{Error, Result}; + +/// A hybrid logical clock timestamp. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hlc { + /// Physical time, epoch ms. + pub physical: i64, + /// Logical counter (ties when physical time repeats). + pub counter: u32, + /// Originating device id (final tiebreak; keeps the order total). + pub origin: String, +} + +impl Hlc { + /// The zero clock for `origin` (precedes every real event). + pub fn zero(origin: impl Into) -> Hlc { + Hlc { + physical: 0, + counter: 0, + origin: origin.into(), + } + } + + /// Encode to a fixed-width, lexically-sortable string + /// `physical(20):counter(10):origin`. + pub fn encode(&self) -> String { + format!("{:020}:{:010}:{}", self.physical, self.counter, self.origin) + } + + /// Parse the [`encode`](Hlc::encode) form. Zero-padding parses fine. + pub fn parse(s: &str) -> Result { + let mut parts = s.splitn(3, ':'); + let physical: i64 = parts + .next() + .and_then(|p| p.parse().ok()) + .ok_or_else(|| Error::Integrity(format!("bad hlc physical in {s:?}")))?; + let counter: u32 = parts + .next() + .and_then(|p| p.parse().ok()) + .ok_or_else(|| Error::Integrity(format!("bad hlc counter in {s:?}")))?; + let origin = parts + .next() + .ok_or_else(|| Error::Integrity(format!("missing hlc origin in {s:?}")))? + .to_string(); + Ok(Hlc { + physical, + counter, + origin, + }) + } +} + +/// A per-device HLC generator. Holds the device `origin` and the last emitted +/// clock; deterministic given the injected physical time. +#[derive(Clone, Debug)] +pub struct HlcClock { + origin: String, + last: Hlc, +} + +impl HlcClock { + /// Start a generator for `origin`, resuming from `last` (use [`Hlc::zero`] + /// for a fresh device). + pub fn new(origin: impl Into, last: Hlc) -> HlcClock { + HlcClock { + origin: origin.into(), + last, + } + } + + /// This device's id. + pub fn origin(&self) -> &str { + &self.origin + } + + /// The last clock this generator emitted. + pub fn last(&self) -> &Hlc { + &self.last + } + + /// Stamp a **local** event at physical time `now_ms`. Strictly greater than + /// every clock this generator has emitted. + pub fn tick(&mut self, now_ms: i64) -> Hlc { + let physical = now_ms.max(self.last.physical); + let counter = if physical == self.last.physical { + self.last.counter + 1 + } else { + 0 + }; + let hlc = Hlc { + physical, + counter, + origin: self.origin.clone(), + }; + self.last = hlc.clone(); + hlc + } + + /// Stamp the **receipt** of a remote event `remote` at physical time + /// `now_ms`, advancing past both our last clock and the remote's. + pub fn update(&mut self, remote: &Hlc, now_ms: i64) -> Hlc { + let physical = now_ms.max(self.last.physical).max(remote.physical); + let counter = if physical == self.last.physical && physical == remote.physical { + self.last.counter.max(remote.counter) + 1 + } else if physical == self.last.physical { + self.last.counter + 1 + } else if physical == remote.physical { + remote.counter + 1 + } else { + 0 + }; + let hlc = Hlc { + physical, + counter, + origin: self.origin.clone(), + }; + self.last = hlc.clone(); + hlc + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn encode_is_lexically_sortable_by_causal_order() { + let a = Hlc { + physical: 5, + counter: 9, + origin: "z".into(), + }; + let b = Hlc { + physical: 5, + counter: 10, + origin: "a".into(), + }; + // b is causally after a (higher counter) despite a smaller origin. + assert!(b > a); + assert!(b.encode() > a.encode()); + } + + #[test] + fn encode_parse_round_trips() { + let h = Hlc { + physical: 1_700_000_000_000, + counter: 42, + origin: "dev-1".into(), + }; + assert_eq!(Hlc::parse(&h.encode()).unwrap(), h); + // Zero parses back too. + let z = Hlc::zero("dev-1"); + assert_eq!(Hlc::parse(&z.encode()).unwrap(), z); + } + + #[test] + fn tick_is_strictly_monotonic_even_when_clock_stalls() { + let mut c = HlcClock::new("d", Hlc::zero("d")); + let a = c.tick(100); + let b = c.tick(100); // same physical → counter bumps + let d = c.tick(50); // clock went backwards → still advances via physical=max + assert!(b > a); + assert!(d > b); + assert_eq!(a.physical, 100); + assert_eq!(b.counter, a.counter + 1); + } + + #[test] + fn update_absorbs_a_remote_clock() { + let mut c = HlcClock::new("local", Hlc::zero("local")); + c.tick(10); + let remote = Hlc { + physical: 1000, + counter: 3, + origin: "other".into(), + }; + let got = c.update(&remote, 20); + assert!(got > remote); + assert_eq!(got.physical, 1000); + assert_eq!(got.counter, 4); + assert_eq!(got.origin, "local"); + } + + proptest! { + /// A run of ticks is strictly increasing for any physical-time sequence. + #[test] + fn ticks_strictly_increase(times in proptest::collection::vec(0i64..1_000_000, 1..50)) { + let mut c = HlcClock::new("d", Hlc::zero("d")); + let mut prev: Option = None; + for t in times { + let h = c.tick(t); + if let Some(p) = &prev { + prop_assert!(h > *p); + } + prev = Some(h); + } + } + + /// Encoding preserves order for any two clocks. + #[test] + fn encode_preserves_order( + p1 in 0i64..1_000_000_000_000, c1 in 0u32..100_000, o1 in "[a-z]{1,4}", + p2 in 0i64..1_000_000_000_000, c2 in 0u32..100_000, o2 in "[a-z]{1,4}", + ) { + let a = Hlc { physical: p1, counter: c1, origin: o1 }; + let b = Hlc { physical: p2, counter: c2, origin: o2 }; + prop_assert_eq!(a.cmp(&b), a.encode().cmp(&b.encode())); + } + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 3315777..dd673f3 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod clock; pub mod error; pub mod export; pub mod extract; +pub mod hlc; pub mod model; pub mod ranking; pub mod recurrence; @@ -22,6 +23,7 @@ pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; +pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState, diff --git a/crates/heph-core/src/sqlite/log.rs b/crates/heph-core/src/sqlite/log.rs index 847b7f6..f44944b 100644 --- a/crates/heph-core/src/sqlite/log.rs +++ b/crates/heph-core/src/sqlite/log.rs @@ -9,7 +9,7 @@ use rusqlite::Connection; -use super::{hlc_for, links, nodes}; +use super::{links, next_hlc, nodes}; use crate::error::{Error, Result}; use crate::model::{LinkType, NodeKind}; @@ -25,9 +25,11 @@ pub(super) fn ensure_doc( } let task = nodes::get(conn, task_id)?.ok_or_else(|| Error::NodeNotFound(task_id.to_string()))?; + let hlc = next_hlc(conn, now)?; let doc = nodes::build( owner, now, + &hlc, NodeKind::Doc, format!("{} — log", task.title), Some(String::new()), @@ -48,9 +50,10 @@ pub(super) fn append( let doc_id = ensure_doc(conn, owner, now, task_id)?; let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?; let new_body = append_line(doc.body.as_deref().unwrap_or(""), text); + let hlc = next_hlc(conn, now)?; conn.execute( "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", - (&new_body, now, hlc_for(now), &doc_id), + (&new_body, now, hlc, &doc_id), )?; Ok(()) } diff --git a/crates/heph-core/src/sqlite/migrations.rs b/crates/heph-core/src/sqlite/migrations.rs index a6ac4e2..b0f6bb2 100644 --- a/crates/heph-core/src/sqlite/migrations.rs +++ b/crates/heph-core/src/sqlite/migrations.rs @@ -10,7 +10,11 @@ use rusqlite::Connection; /// The ordered list of migrations. Never reorder or mutate a shipped entry — /// only append. -const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_0001), (2, MIGRATION_0002)]; +const MIGRATIONS: &[(i64, &str)] = &[ + (1, MIGRATION_0001), + (2, MIGRATION_0002), + (3, MIGRATION_0003), +]; /// v1 — the base node graph, identity, and sync scaffolding (tech-spec §4.5). const MIGRATION_0001: &str = r#" @@ -115,6 +119,15 @@ CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN END; "#; +/// v3 — per-store key/value metadata (e.g. this device's stable `origin` id for +/// HLC stamping and sync, tech-spec §12). +const MIGRATION_0003: &str = r#" +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +"#; + /// Apply all pending migrations to `conn`. pub fn migrate(conn: &Connection) -> Result<()> { let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 65abc82..a72b17f 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -24,7 +24,8 @@ use rusqlite::{Connection, OptionalExtension}; use ulid::Ulid; use crate::clock::Clock; -use crate::error::Result; +use crate::error::{Error, Result}; +use crate::hlc::Hlc; use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::ranking::RankedTask; use crate::store::Store; @@ -52,6 +53,7 @@ impl LocalStore { fn init(conn: Connection, clock: Box) -> Result { conn.execute_batch("PRAGMA foreign_keys = ON;")?; migrations::migrate(&conn)?; + ensure_origin(&conn)?; let owner_id = ensure_local_user(&conn, clock.as_ref())?; Ok(LocalStore { conn, @@ -67,6 +69,12 @@ impl LocalStore { pub fn owner_id(&self) -> &str { &self.owner_id } + + /// This device's stable `origin` id (HLC/sync identity). + pub fn origin(&self) -> Result { + meta_get(&self.conn, "origin")? + .ok_or_else(|| Error::Integrity("missing device origin".into())) + } } /// A fresh ULID, as a string id. @@ -74,10 +82,56 @@ pub(crate) fn new_id() -> String { Ulid::new().to_string() } -/// Placeholder HLC string until the real hybrid logical clock lands (§12). -/// Zero-padded epoch ms keeps it lexically sortable in the meantime. -pub(crate) fn hlc_for(now_ms: i64) -> String { - format!("{now_ms:016}") +/// Read a `meta` value. +pub(super) fn meta_get(conn: &Connection, key: &str) -> Result> { + Ok(conn + .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) + .optional()?) +} + +/// Upsert a `meta` value. +pub(super) fn meta_set(conn: &Connection, key: &str, value: &str) -> Result<()> { + conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + )?; + Ok(()) +} + +/// Ensure this store has a stable device `origin` id. +fn ensure_origin(conn: &Connection) -> Result<()> { + if meta_get(conn, "origin")?.is_none() { + meta_set(conn, "origin", &new_id())?; + } + Ok(()) +} + +/// Generate the next local [`Hlc`] (encoded), advancing the persisted clock in +/// `meta`. Strictly greater than every previously-generated stamp; the +/// read-modify-write runs inside the caller's connection/transaction and the +/// store is single-writer, so there is no race. +pub(super) fn next_hlc(conn: &Connection, now_ms: i64) -> Result { + let origin = meta_get(conn, "origin")? + .ok_or_else(|| Error::Integrity("missing device origin".into()))?; + let last = match meta_get(conn, "last_hlc")? { + Some(s) => Hlc::parse(&s)?, + None => Hlc::zero(&origin), + }; + let physical = now_ms.max(last.physical); + let counter = if physical == last.physical { + last.counter + 1 + } else { + 0 + }; + let encoded = Hlc { + physical, + counter, + origin, + } + .encode(); + meta_set(conn, "last_hlc", &encoded)?; + Ok(encoded) } /// Ensure a single local user exists, returning its id. diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 09de2be..1eae0b3 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -2,7 +2,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; -use super::{hlc_for, links, new_id}; +use super::{links, new_id, next_hlc}; use crate::error::{Error, Result}; use crate::model::{deterministic_id, NewNode, Node, NodeKind}; @@ -10,10 +10,12 @@ use crate::model::{deterministic_id, NewNode, Node, NodeKind}; pub(super) const COLUMNS: &str = "id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned"; -/// Build an in-memory [`Node`] (not yet persisted). +/// Build an in-memory [`Node`] (not yet persisted) stamped with `hlc`. The +/// caller generates the HLC (it needs the connection) and passes it in. pub(super) fn build( owner: &str, now: i64, + hlc: &str, kind: NodeKind, title: String, body: Option, @@ -26,7 +28,7 @@ pub(super) fn build( body, created_at: now, modified_at: now, - hlc: hlc_for(now), + hlc: hlc.to_string(), tombstoned: false, } } @@ -69,7 +71,8 @@ pub(super) fn from_row(row: &Row) -> rusqlite::Result { /// Create and persist a node. pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) -> Result { - let node = build(owner, now, input.kind, input.title, input.body); + let hlc = next_hlc(conn, now)?; + let node = build(owner, now, &hlc, input.kind, input.title, input.body); insert(conn, &node)?; Ok(node) } @@ -100,7 +103,7 @@ pub(super) fn open_or_create_journal( body: Some(String::new()), created_at: now, modified_at: now, - hlc: hlc_for(now), + hlc: next_hlc(conn, now)?, tombstoned: false, }; insert(conn, &node)?; @@ -154,9 +157,9 @@ pub(super) fn update( None => false, }; node.modified_at = now; - node.hlc = hlc_for(now); let tx = conn.transaction()?; + node.hlc = next_hlc(&tx, now)?; tx.execute( "UPDATE nodes SET title = ?1, body = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", ( @@ -209,9 +212,10 @@ pub(super) fn aliases(conn: &Connection, id: &str) -> Result> { /// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge /// monotonic (tech-spec §4.3). pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> { + let hlc = next_hlc(conn, now)?; let updated = conn.execute( "UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3", - (now, hlc_for(now), id), + (now, hlc, id), )?; if updated == 0 { return Err(Error::NodeNotFound(id.to_string())); @@ -222,9 +226,10 @@ pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> { /// Bump `modified_at`/`hlc` on a node (used when a task scalar field changes so /// the node's modified time reflects the mutation for sync ordering). pub(super) fn touch(conn: &Connection, now: i64, id: &str) -> Result<()> { + let hlc = next_hlc(conn, now)?; conn.execute( "UPDATE nodes SET modified_at = ?1, hlc = ?2 WHERE id = ?3", - (now, hlc_for(now), id), + (now, hlc, id), )?; Ok(()) } diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index dfbdb0c..343d675 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -5,7 +5,7 @@ use rusqlite::{Connection, OptionalExtension, Row}; -use super::{hlc_for, links, log, nodes}; +use super::{links, log, next_hlc, nodes}; use crate::error::{Error, Result}; use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::ranking::{self, RankedTask}; @@ -47,7 +47,15 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas let tx = conn.transaction()?; - let task_node = nodes::build(owner, now, NodeKind::Task, input.title.clone(), None); + let task_hlc = next_hlc(&tx, now)?; + let task_node = nodes::build( + owner, + now, + &task_hlc, + NodeKind::Task, + input.title.clone(), + None, + ); nodes::insert(&tx, &task_node)?; tx.execute( "INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence) @@ -63,9 +71,11 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas )?; // The canonical context doc (the task's jumping-off point / checklist body). + let doc_hlc = next_hlc(&tx, now)?; let doc = nodes::build( owner, now, + &doc_hlc, NodeKind::Doc, input.title.clone(), Some(String::new()), @@ -139,9 +149,10 @@ fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Re let body = doc.body.unwrap_or_default(); let reset = recurrence::reset_checkboxes(&body); if reset != body { + let hlc = next_hlc(&tx, now)?; tx.execute( "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", - (&reset, now, hlc_for(now), &doc_id), + (&reset, now, hlc, &doc_id), )?; links::sync_wiki_links(&tx, owner, &doc_id, &reset, now)?; } diff --git a/crates/heph-core/tests/hlc_stamping.rs b/crates/heph-core/tests/hlc_stamping.rs new file mode 100644 index 0000000..0206d3e --- /dev/null +++ b/crates/heph-core/tests/hlc_stamping.rs @@ -0,0 +1,86 @@ +//! The store stamps every node write with a real, monotonic HLC, and the +//! device origin persists across reopens (tech-spec §12, slice 8a). + +use heph_core::{Clock, Hlc, LocalStore, NewNode, Store}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +#[derive(Clone)] +struct StepClock(Arc); +impl StepClock { + fn new(ms: i64) -> Self { + StepClock(Arc::new(AtomicI64::new(ms))) + } + fn set(&self, ms: i64) { + self.0.store(ms, Ordering::SeqCst); + } +} +impl Clock for StepClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +#[test] +fn node_hlcs_strictly_increase_even_when_the_clock_stalls_or_regresses() { + let clock = StepClock::new(1000); + let mut s = LocalStore::open_in_memory(Box::new(clock.clone())).unwrap(); + + let a = s.create_node(NewNode::doc("a", "")).unwrap(); + // Same wall-clock instant → the logical counter must still advance. + let b = s.create_node(NewNode::doc("b", "")).unwrap(); + // Clock regresses → HLC must not go backwards. + clock.set(500); + let c = s.create_node(NewNode::doc("c", "")).unwrap(); + + let ha = Hlc::parse(&a.hlc).unwrap(); + let hb = Hlc::parse(&b.hlc).unwrap(); + let hc = Hlc::parse(&c.hlc).unwrap(); + assert!(hb > ha, "{hb:?} !> {ha:?}"); + assert!(hc > hb, "{hc:?} !> {hb:?}"); + // Lexical order of the encoded strings matches causal order. + assert!(a.hlc < b.hlc && b.hlc < c.hlc); +} + +#[test] +fn all_hlcs_share_this_devices_origin() { + let mut s = LocalStore::open_in_memory(Box::new(StepClock::new(0))).unwrap(); + let origin = s.origin().unwrap(); + let n = s.create_node(NewNode::doc("x", "")).unwrap(); + assert_eq!(Hlc::parse(&n.hlc).unwrap().origin, origin); +} + +#[test] +fn origin_persists_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + + let origin1 = { + let s = LocalStore::open(&db, Box::new(StepClock::new(1))).unwrap(); + s.origin().unwrap() + }; + let origin2 = { + let s = LocalStore::open(&db, Box::new(StepClock::new(2))).unwrap(); + s.origin().unwrap() + }; + assert_eq!(origin1, origin2); + assert!(!origin1.is_empty()); +} + +#[test] +fn hlc_keeps_advancing_after_reopen() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + + let first_hlc = { + let mut s = LocalStore::open(&db, Box::new(StepClock::new(1000))).unwrap(); + s.create_node(NewNode::doc("a", "")).unwrap().hlc + }; + // Reopen with an earlier wall clock; the next stamp must still exceed the + // persisted one (the clock resumes from the stored last_hlc). + let next_hlc = { + let mut s = LocalStore::open(&db, Box::new(StepClock::new(500))).unwrap(); + s.create_node(NewNode::doc("b", "")).unwrap().hlc + }; + assert!(next_hlc > first_hlc, "{next_hlc} !> {first_hlc}"); +} diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 24736bf..60000b6 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -272,7 +272,7 @@ All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts **Added for v1 client/server + auth (some to confirm at kickoff):** -- **Text CRDT (body merge):** `yrs` (Rust Yjs) — *leaning*; alternative `automerge`. Used for `doc`/`journal`/log bodies. Structured fields use a bespoke op-log + HLC (no library needed). +- **Text CRDT (body merge):** ✅ **`yrs` (Rust Yjs)** (ratified at the Phase 1 sync kickoff, 2026-05-31; `automerge` was the alternative). Used for `doc`/`journal`/log bodies. Structured fields use a bespoke op-log + HLC (no library needed). - **HLC:** small bespoke hybrid-logical-clock (or a crate) — deterministic, clock-injected. - **Hub network transport:** `axum` (HTTP/JSON) for the sync endpoint — *leaning* (reuses the eventual web-UI server); `reqwest` on the client side. - **OIDC:** `openidconnect` crate for the Authentik device-code flow; tokens cached in the OS keychain (`keyring`) / 1Password. -- 2.50.1 (Apple Git-155) From b05ddf4bb543b10498e9ba27aef503047db9c42c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 21:29:20 -0700 Subject: [PATCH 11/91] heph-core: op-log recording + merge/apply engine (sync 8b/8c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conceptual core of sync: every mutation records an Op, and foreign ops are applied with merge rules to converge replicas (tech-spec §12). Recording (8b): each node/task/link mutation appends an oplog Op stamped with its HLC — node.create/set/tombstone, task.create/set, link.add/ remove. `Store::ops_since(cursor)` is the push cursor. Merge/apply (8c): `Store::apply_op` replays a foreign op idempotently — - bodies/titles + task scalars: last-writer-wins by HLC; a discarded cross-device value is recorded in `conflicts` (surfaced, not dropped); - links: OR-set add/remove by link id; - tombstones: monotonic. The local clock absorbs each applied HLC. `conflicts_list`/ `conflicts_resolve` expose the queue. `adopt_owner` rewrites a replica to a canonical user id (basic §13 adoption) so replicas can share data. 13 tests: HLC stamping (4) + 6-case two-replica convergence (round-trip, idempotency, scalar LWW + conflict, body LWW, link OR-set, monotonic tombstone). 102 tests green. Body merge is LWW for now; the yrs text CRDT (8d) upgrades concurrent body edits to auto-merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/heph-core/Cargo.toml | 1 + crates/heph-core/src/lib.rs | 6 +- crates/heph-core/src/model.rs | 25 ++ crates/heph-core/src/oplog.rs | 49 ++++ crates/heph-core/src/sqlite/apply.rs | 340 ++++++++++++++++++++++++++ crates/heph-core/src/sqlite/links.rs | 23 +- crates/heph-core/src/sqlite/log.rs | 18 +- crates/heph-core/src/sqlite/mod.rs | 73 +++++- crates/heph-core/src/sqlite/nodes.rs | 70 ++++-- crates/heph-core/src/sqlite/ops.rs | 97 ++++++++ crates/heph-core/src/sqlite/tasks.rs | 85 ++++++- crates/heph-core/src/store.rs | 28 ++- crates/heph-core/tests/convergence.rs | 207 ++++++++++++++++ 14 files changed, 983 insertions(+), 40 deletions(-) create mode 100644 crates/heph-core/src/oplog.rs create mode 100644 crates/heph-core/src/sqlite/apply.rs create mode 100644 crates/heph-core/src/sqlite/ops.rs create mode 100644 crates/heph-core/tests/convergence.rs diff --git a/Cargo.lock b/Cargo.lock index 35fc93e..ddad7a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,6 +363,7 @@ dependencies = [ "rrule", "rusqlite", "serde", + "serde_json", "tempfile", "thiserror 2.0.18", "ulid", diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index 718de84..6701f42 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -16,6 +16,7 @@ pulldown-cmark.workspace = true rrule.workspace = true chrono.workspace = true serde.workspace = true +serde_json.workspace = true [dev-dependencies] proptest = "1" diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index dd673f3..9124ac5 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod export; pub mod extract; pub mod hlc; pub mod model; +pub mod oplog; pub mod ranking; pub mod recurrence; pub mod sqlite; @@ -25,9 +26,10 @@ pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; pub use hlc::{Hlc, HlcClock}; pub use model::{ - deterministic_id, Attention, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, - TaskState, + deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, + NodeKind, Task, TaskState, }; +pub use oplog::Op; pub use ranking::{rank, Dimension, RankedTask, RANKING}; pub use recurrence::{next_occurrence, reset_checkboxes}; pub use sqlite::LocalStore; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 728db08..879e0cf 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -264,6 +264,31 @@ pub struct Health { pub sync_status: String, } +/// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec +/// §12). The winning value is already in the store; this records what was +/// dropped so `heph conflicts` can show and settle it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Conflict { + /// Conflict id. + pub id: String, + /// The node the conflict is about. + pub node_id: String, + /// Which field / region (`body`, `do_date`, `state`, …). + pub field: String, + /// The local value at merge time. + pub local_val: Option, + /// The incoming remote value. + pub remote_val: Option, + /// HLC of the local value. + pub local_hlc: String, + /// HLC of the remote value. + pub remote_hlc: String, + /// `open` or `resolved`. + pub status: String, + /// When recorded, epoch ms. + pub created_at: i64, +} + /// Deterministic id for key-unique kinds (`journal`/`tag`) so two offline /// replicas that independently create the same logical singleton converge /// (tech-spec §3.1, [[design]] §3.1). Content nodes use random ULIDs instead. diff --git a/crates/heph-core/src/oplog.rs b/crates/heph-core/src/oplog.rs new file mode 100644 index 0000000..926be35 --- /dev/null +++ b/crates/heph-core/src/oplog.rs @@ -0,0 +1,49 @@ +//! The operation log — the unit of sync (tech-spec §12). +//! +//! Every local mutation appends an append-only [`Op`] describing the change, +//! stamped with the writer's [`Hlc`](crate::hlc::Hlc). Sync exchanges ops by +//! HLC cursor; a peer applies foreign ops with the merge rules (LWW for +//! scalars, OR-set for links, CRDT for bodies). Local ops are recorded already +//! `applied` (the materialized tables were written in the same transaction). + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Op type discriminators (the `op_type` column). +pub mod op_type { + /// A node was created. Payload: `{kind, title, body, created_at}`. + pub const NODE_CREATE: &str = "node.create"; + /// A node's title/body was set (LWW). Payload: `{title, body}`. + pub const NODE_SET: &str = "node.set"; + /// A node was tombstoned. Payload: `{}`. + pub const NODE_TOMBSTONE: &str = "node.tombstone"; + /// A task row was created. Payload: the task scalars. + pub const TASK_CREATE: &str = "task.create"; + /// One or more task scalars were set (LWW). Payload: the changed scalars. + pub const TASK_SET: &str = "task.set"; + /// A link was added (OR-set add). Payload: `{src, dst, type}`. + pub const LINK_ADD: &str = "link.add"; + /// A link was removed/tombstoned (OR-set remove). Payload: `{}`. + pub const LINK_REMOVE: &str = "link.remove"; +} + +/// One operation-log entry (a row of `oplog`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Op { + /// ULID id of the op. + pub id: String, + /// Owning user. + pub owner_id: String, + /// HLC stamp (encoded) — the causal-order key. + pub hlc: String, + /// Originating device id. + pub origin: String, + /// Op type (see [`op_type`]). + pub op_type: String, + /// The node/link this op targets. + pub target_id: String, + /// Type-specific JSON payload. + pub payload: Value, + /// Whether this op has been applied to the materialized tables. + pub applied: bool, +} diff --git a/crates/heph-core/src/sqlite/apply.rs b/crates/heph-core/src/sqlite/apply.rs new file mode 100644 index 0000000..b3be5ff --- /dev/null +++ b/crates/heph-core/src/sqlite/apply.rs @@ -0,0 +1,340 @@ +//! Applying foreign ops — the merge engine (tech-spec §12). +//! +//! A peer's ops arrive in HLC order; [`apply`] replays each one idempotently +//! with the merge rules: +//! +//! - **node bodies / titles, task scalars:** last-writer-wins by HLC. A +//! *discarded* value from a different device is recorded in `conflicts` +//! (surfaced, not silently dropped). +//! - **links:** OR-set add/remove keyed by the link's own id → no conflicts. +//! - **tombstones:** monotonic — once set, they stay. +//! +//! Idempotent: an op whose id we've already stored is a no-op. The local clock +//! absorbs each op's HLC so future local stamps stay ahead. + +use rusqlite::{Connection, OptionalExtension}; +use serde_json::Value; + +use super::{absorb_remote_hlc, new_id, nodes, ops}; +use crate::error::Result; +use crate::hlc::Hlc; +use crate::model::Conflict; +use crate::oplog::{op_type, Op}; + +/// Open conflicts for `owner`, newest first. +pub(super) fn list_conflicts(conn: &Connection, owner: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, node_id, field, local_val, remote_val, local_hlc, remote_hlc, status, created_at + FROM conflicts WHERE owner_id = ?1 AND status = 'open' ORDER BY created_at DESC, id", + )?; + let rows = stmt.query_map([owner], |r| { + Ok(Conflict { + id: r.get(0)?, + node_id: r.get(1)?, + field: r.get(2)?, + local_val: r.get(3)?, + remote_val: r.get(4)?, + local_hlc: r.get(5)?, + remote_hlc: r.get(6)?, + status: r.get(7)?, + created_at: r.get(8)?, + }) + })?; + Ok(rows.collect::>>()?) +} + +/// Settle a conflict. v1 records the user's choice by marking it resolved; the +/// LWW winner is already materialized (choosing the loser's value is a +/// follow-up — see [[design]]). +pub(super) fn resolve_conflict(conn: &Connection, id: &str, _choice: &str) -> Result<()> { + conn.execute( + "UPDATE conflicts SET status = 'resolved' WHERE id = ?1", + [id], + )?; + Ok(()) +} + +/// Apply a foreign op. Returns `true` if newly applied, `false` if already seen. +pub(super) fn apply(conn: &mut Connection, op: &Op) -> Result { + if ops::exists(conn, &op.id)? { + return Ok(false); + } + let tx = conn.transaction()?; + // Ensure the op's owner exists so node FKs hold even before owner adoption + // (tech-spec §13). In practice replicas share a canonical owner. + tx.execute( + "INSERT OR IGNORE INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'remote', 0)", + [&op.owner_id], + )?; + let applied = match op.op_type.as_str() { + op_type::NODE_CREATE => { + node_upsert(&tx, op)?; + true + } + op_type::NODE_SET => { + node_upsert(&tx, op)?; + true + } + op_type::NODE_TOMBSTONE => { + node_tombstone(&tx, op)?; + true + } + op_type::TASK_CREATE | op_type::TASK_SET => { + task_set(&tx, op)?; + true + } + op_type::LINK_ADD => { + link_add(&tx, op)?; + true + } + op_type::LINK_REMOVE => { + link_remove(&tx, op)?; + true + } + // Unknown op types are stored but not applied (forward compatibility). + _ => false, + }; + ops::insert_op(&tx, op, applied)?; + absorb_remote_hlc(&tx, &op.hlc)?; + tx.commit()?; + Ok(true) +} + +fn str_field<'a>(payload: &'a Value, key: &str) -> Option<&'a str> { + payload.get(key).and_then(Value::as_str) +} + +fn i64_field(payload: &Value, key: &str) -> Option { + payload.get(key).and_then(Value::as_i64) +} + +/// The op's physical time (for `modified_at`/conflict timestamps). +fn op_physical(op: &Op) -> i64 { + Hlc::parse(&op.hlc).map(|h| h.physical).unwrap_or(0) +} + +/// Did a *different* device write the current value (so a divergence is real)? +fn cross_origin(op: &Op, current_hlc: &str) -> bool { + Hlc::parse(current_hlc) + .map(|h| h.origin != op.origin) + .unwrap_or(false) +} + +/// Create a node (if absent) or LWW its title/body. +fn node_upsert(tx: &Connection, op: &Op) -> Result<()> { + let p = &op.payload; + match nodes::get(tx, &op.target_id)? { + None => { + let kind = str_field(p, "kind").unwrap_or("doc"); + let title = str_field(p, "title").unwrap_or(""); + let body = str_field(p, "body"); + let created_at = i64_field(p, "created_at").unwrap_or_else(|| op_physical(op)); + tx.execute( + "INSERT INTO nodes + (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 0)", + ( + &op.target_id, + &op.owner_id, + kind, + title, + body, + created_at, + &op.hlc, + ), + )?; + } + Some(existing) => { + let new_title = str_field(p, "title"); + let new_body = str_field(p, "body"); + // Conflict: a different device's body differs from ours. + if cross_origin(op, &existing.hlc) { + if let Some(nb) = new_body { + if Some(nb) != existing.body.as_deref() { + record_conflict( + tx, + op, + "body", + existing.body.as_deref(), + Some(nb), + &existing.hlc, + )?; + } + } + } + if op.hlc.as_str() > existing.hlc.as_str() { + let title = new_title.unwrap_or(&existing.title).to_string(); + let body = new_body.map(str::to_string).or(existing.body.clone()); + tx.execute( + "UPDATE nodes SET title = ?1, body = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", + (&title, &body, op_physical(op), &op.hlc, &op.target_id), + )?; + } + } + } + Ok(()) +} + +fn node_tombstone(tx: &Connection, op: &Op) -> Result<()> { + // Monotonic: tombstone always wins. Bump hlc only if this op is newer. + if let Some(existing) = nodes::get(tx, &op.target_id)? { + let hlc = if op.hlc.as_str() > existing.hlc.as_str() { + op.hlc.clone() + } else { + existing.hlc.clone() + }; + tx.execute( + "UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3", + (op_physical(op), hlc, &op.target_id), + )?; + } + Ok(()) +} + +fn task_set(tx: &Connection, op: &Op) -> Result<()> { + // The backing node must already exist (ordered delivery: create precedes). + let Some(node) = nodes::get(tx, &op.target_id)? else { + return Ok(()); + }; + let p = &op.payload; + let task_exists: bool = tx + .query_row( + "SELECT 1 FROM tasks WHERE node_id = ?1", + [&op.target_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + let op_wins = op.hlc.as_str() > node.hlc.as_str(); + + // Conflict: a different device's scalar snapshot differs from ours. + if task_exists && cross_origin(op, &node.hlc) { + let cur: Option<(Option, String)> = tx + .query_row( + "SELECT do_date, state FROM tasks WHERE node_id = ?1", + [&op.target_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + if let Some((cur_do, cur_state)) = cur { + let op_do = i64_field(p, "do_date"); + let op_state = str_field(p, "state").unwrap_or("outstanding"); + if cur_do != op_do { + record_conflict( + tx, + op, + "do_date", + cur_do.map(|v| v.to_string()).as_deref(), + op_do.map(|v| v.to_string()).as_deref(), + &node.hlc, + )?; + } + if cur_state != op_state { + record_conflict(tx, op, "state", Some(&cur_state), Some(op_state), &node.hlc)?; + } + } + } + + if !task_exists || op_wins { + let attention = str_field(p, "attention"); + let do_date = i64_field(p, "do_date"); + let late_on = i64_field(p, "late_on"); + let state = str_field(p, "state").unwrap_or("outstanding"); + let recurrence = str_field(p, "recurrence"); + if task_exists { + tx.execute( + "UPDATE tasks SET attention = ?1, do_date = ?2, late_on = ?3, + state = ?4, recurrence = ?5 WHERE node_id = ?6", + ( + attention, + do_date, + late_on, + state, + recurrence, + &op.target_id, + ), + )?; + } else { + tx.execute( + "INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ( + &op.target_id, + attention, + do_date, + late_on, + state, + recurrence, + ), + )?; + } + if op_wins { + tx.execute( + "UPDATE nodes SET modified_at = ?1, hlc = ?2 WHERE id = ?3", + (op_physical(op), &op.hlc, &op.target_id), + )?; + } + } + Ok(()) +} + +fn link_add(tx: &Connection, op: &Op) -> Result<()> { + let exists: bool = tx + .query_row("SELECT 1 FROM links WHERE id = ?1", [&op.target_id], |_| { + Ok(()) + }) + .optional()? + .is_some(); + if !exists { + let p = &op.payload; + tx.execute( + "INSERT INTO links (id, src_id, dst_id, type, created_at, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, 0)", + ( + &op.target_id, + str_field(p, "src").unwrap_or(""), + str_field(p, "dst").unwrap_or(""), + str_field(p, "type").unwrap_or("wiki"), + i64_field(p, "created_at").unwrap_or_else(|| op_physical(op)), + ), + )?; + } + Ok(()) +} + +fn link_remove(tx: &Connection, op: &Op) -> Result<()> { + tx.execute( + "UPDATE links SET tombstoned = 1 WHERE id = ?1", + [&op.target_id], + )?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn record_conflict( + tx: &Connection, + op: &Op, + field: &str, + local_val: Option<&str>, + remote_val: Option<&str>, + local_hlc: &str, +) -> Result<()> { + tx.execute( + "INSERT INTO conflicts + (id, owner_id, node_id, field, local_val, remote_val, + local_hlc, remote_hlc, status, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'open', ?9)", + ( + new_id(), + &op.owner_id, + &op.target_id, + field, + local_val, + remote_val, + local_hlc, + &op.hlc, + op_physical(op), + ), + )?; + Ok(()) +} diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 7e5c46a..b29d3f2 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -4,10 +4,12 @@ use std::collections::HashSet; use rusqlite::{Connection, OptionalExtension, Row}; -use super::new_id; +use super::{new_id, next_hlc, ops}; use crate::error::Result; use crate::extract::extract; use crate::model::{Link, LinkType}; +use crate::oplog::op_type; +use serde_json::json; const COLUMNS: &str = "id, src_id, dst_id, type, created_at, tombstoned"; @@ -26,6 +28,7 @@ fn from_row(row: &Row) -> rusqlite::Result { /// Add a typed link. pub(super) fn add( conn: &Connection, + owner: &str, now: i64, src_id: &str, dst_id: &str, @@ -50,6 +53,20 @@ pub(super) fn add( link.created_at, ), )?; + let hlc = next_hlc(conn, now)?; + ops::record( + conn, + owner, + &hlc, + op_type::LINK_ADD, + &link.id, + json!({ + "src": link.src_id, + "dst": link.dst_id, + "type": link.link_type.as_str(), + "created_at": link.created_at, + }), + )?; Ok(link) } @@ -128,12 +145,14 @@ pub(super) fn sync_wiki_links( for (link_id, dst) in &existing { if !desired_set.contains(dst) { conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; + let hlc = next_hlc(conn, now)?; + ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?; } } // Add links for newly-referenced targets. for dst in &desired { if !existing_dsts.contains(dst.as_str()) { - add(conn, now, src_id, dst, LinkType::Wiki)?; + add(conn, owner, now, src_id, dst, LinkType::Wiki)?; } } Ok(()) diff --git a/crates/heph-core/src/sqlite/log.rs b/crates/heph-core/src/sqlite/log.rs index f44944b..2088225 100644 --- a/crates/heph-core/src/sqlite/log.rs +++ b/crates/heph-core/src/sqlite/log.rs @@ -9,9 +9,12 @@ use rusqlite::Connection; -use super::{links, next_hlc, nodes}; +use serde_json::json; + +use super::{links, next_hlc, nodes, ops}; use crate::error::{Error, Result}; use crate::model::{LinkType, NodeKind}; +use crate::oplog::op_type; /// The task's log doc id, creating (and linking) it on first use. pub(super) fn ensure_doc( @@ -35,7 +38,8 @@ pub(super) fn ensure_doc( Some(String::new()), ); nodes::insert(conn, &doc)?; - links::add(conn, now, task_id, &doc.id, LinkType::LogOf)?; + nodes::record_create(conn, owner, &doc)?; + links::add(conn, owner, now, task_id, &doc.id, LinkType::LogOf)?; Ok(doc.id) } @@ -53,7 +57,15 @@ pub(super) fn append( let hlc = next_hlc(conn, now)?; conn.execute( "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", - (&new_body, now, hlc, &doc_id), + (&new_body, now, &hlc, &doc_id), + )?; + ops::record( + conn, + owner, + &hlc, + op_type::NODE_SET, + &doc_id, + json!({ "title": doc.title, "body": new_body }), )?; Ok(()) } diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index a72b17f..93103b4 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -9,11 +9,13 @@ //! as free functions over a `&Connection`; the [`Store`] impl here is a thin //! delegating layer so a transaction can span several of them. +mod apply; mod exporter; mod links; mod log; mod migrations; mod nodes; +mod ops; mod tasks; pub use migrations::latest_version; @@ -26,7 +28,10 @@ use ulid::Ulid; use crate::clock::Clock; use crate::error::{Error, Result}; use crate::hlc::Hlc; -use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::model::{ + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState, +}; +use crate::oplog::Op; use crate::ranking::RankedTask; use crate::store::Store; @@ -99,6 +104,20 @@ pub(super) fn meta_set(conn: &Connection, key: &str, value: &str) -> Result<()> Ok(()) } +/// Advance the persisted clock past a remote `hlc` (encoded), so future local +/// stamps exceed everything we've seen (tech-spec §12). Encoded HLCs sort by +/// causal order, so a string compare suffices. +pub(super) fn absorb_remote_hlc(conn: &Connection, remote: &str) -> Result<()> { + let bump = match meta_get(conn, "last_hlc")? { + Some(last) => remote > last.as_str(), + None => true, + }; + if bump { + meta_set(conn, "last_hlc", remote)?; + } + Ok(()) +} + /// Ensure this store has a stable device `origin` id. fn ensure_origin(conn: &Connection) -> Result<()> { if meta_get(conn, "origin")?.is_none() { @@ -176,7 +195,7 @@ impl Store for LocalStore { fn tombstone_node(&mut self, id: &str) -> Result<()> { let now = self.clock.now_ms(); - nodes::tombstone(&self.conn, now, id) + nodes::tombstone(&self.conn, &self.owner_id, now, id) } fn create_task(&mut self, input: NewTask) -> Result { @@ -195,12 +214,12 @@ impl Store for LocalStore { fn skip_recurrence(&mut self, node_id: &str) -> Result { let now = self.clock.now_ms(); - tasks::skip(&self.conn, now, node_id) + tasks::skip(&self.conn, &self.owner_id, now, node_id) } fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result { let now = self.clock.now_ms(); - tasks::set_attention(&self.conn, now, node_id, attention) + tasks::set_attention(&self.conn, &self.owner_id, now, node_id, attention) } fn next(&self, scope: Option<&str>, limit: usize) -> Result> { @@ -232,7 +251,7 @@ impl Store for LocalStore { fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { let now = self.clock.now_ms(); - links::add(&self.conn, now, src_id, dst_id, link_type) + links::add(&self.conn, &self.owner_id, now, src_id, dst_id, link_type) } fn outgoing_links(&self, id: &str) -> Result> { @@ -258,6 +277,50 @@ impl Store for LocalStore { fn export(&self, dir: &std::path::Path) -> Result { exporter::export(&self.conn, &self.owner_id, dir) } + + fn ops_since(&self, after: Option<&str>) -> Result> { + ops::since(&self.conn, &self.owner_id, after) + } + + fn apply_op(&mut self, op: &Op) -> Result { + apply::apply(&mut self.conn, op) + } + + fn adopt_owner(&mut self, canonical: &str) -> Result<()> { + if self.owner_id == canonical { + return Ok(()); + } + let old = self.owner_id.clone(); + let tx = self.conn.transaction()?; + tx.execute( + "INSERT OR IGNORE INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'user', 0)", + [canonical], + )?; + tx.execute( + "UPDATE nodes SET owner_id = ?1 WHERE owner_id = ?2", + (canonical, &old), + )?; + tx.execute( + "UPDATE oplog SET owner_id = ?1 WHERE owner_id = ?2", + (canonical, &old), + )?; + tx.execute( + "UPDATE conflicts SET owner_id = ?1 WHERE owner_id = ?2", + (canonical, &old), + )?; + tx.execute("DELETE FROM users WHERE id = ?1", [&old])?; + tx.commit()?; + self.owner_id = canonical.to_string(); + Ok(()) + } + + fn conflicts_list(&self) -> Result> { + apply::list_conflicts(&self.conn, &self.owner_id) + } + + fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()> { + apply::resolve_conflict(&self.conn, id, choice) + } } #[cfg(test)] diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 1eae0b3..fde9876 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -2,9 +2,22 @@ use rusqlite::{Connection, OptionalExtension, Row}; -use super::{links, new_id, next_hlc}; +use serde_json::json; + +use super::{links, new_id, next_hlc, ops}; use crate::error::{Error, Result}; use crate::model::{deterministic_id, NewNode, Node, NodeKind}; +use crate::oplog::op_type; + +/// Op payload describing a node's identity/content for `node.create`. +fn create_payload(node: &Node) -> serde_json::Value { + json!({ + "kind": node.kind.as_str(), + "title": node.title, + "body": node.body, + "created_at": node.created_at, + }) +} /// The `nodes` columns in a fixed order, shared by every SELECT here. pub(super) const COLUMNS: &str = @@ -33,6 +46,19 @@ pub(super) fn build( } } +/// Record a `node.create` op for an already-inserted node (used when a caller +/// builds + inserts a node directly, e.g. `task.create`). +pub(super) fn record_create(conn: &Connection, owner: &str, node: &Node) -> Result<()> { + ops::record( + conn, + owner, + &node.hlc, + op_type::NODE_CREATE, + &node.id, + create_payload(node), + ) +} + /// Insert a fully-formed [`Node`] row. pub(super) fn insert(conn: &Connection, node: &Node) -> Result<()> { conn.execute( @@ -69,11 +95,19 @@ pub(super) fn from_row(row: &Row) -> rusqlite::Result { }) } -/// Create and persist a node. +/// Create and persist a node, recording a `node.create` op. pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) -> Result { let hlc = next_hlc(conn, now)?; let node = build(owner, now, &hlc, input.kind, input.title, input.body); insert(conn, &node)?; + ops::record( + conn, + owner, + &node.hlc, + op_type::NODE_CREATE, + &node.id, + create_payload(&node), + )?; Ok(node) } @@ -107,6 +141,14 @@ pub(super) fn open_or_create_journal( tombstoned: false, }; insert(conn, &node)?; + ops::record( + conn, + owner, + &node.hlc, + op_type::NODE_CREATE, + &node.id, + create_payload(&node), + )?; Ok(node) } @@ -170,6 +212,14 @@ pub(super) fn update( &node.id, ), )?; + ops::record( + &tx, + owner, + &node.hlc, + op_type::NODE_SET, + &node.id, + json!({ "title": node.title, "body": node.body }), + )?; if body_changed { links::sync_wiki_links( &tx, @@ -211,25 +261,15 @@ pub(super) fn aliases(conn: &Connection, id: &str) -> Result> { /// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge /// monotonic (tech-spec §4.3). -pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> { +pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, id: &str) -> Result<()> { let hlc = next_hlc(conn, now)?; let updated = conn.execute( "UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3", - (now, hlc, id), + (now, &hlc, id), )?; if updated == 0 { return Err(Error::NodeNotFound(id.to_string())); } - Ok(()) -} - -/// Bump `modified_at`/`hlc` on a node (used when a task scalar field changes so -/// the node's modified time reflects the mutation for sync ordering). -pub(super) fn touch(conn: &Connection, now: i64, id: &str) -> Result<()> { - let hlc = next_hlc(conn, now)?; - conn.execute( - "UPDATE nodes SET modified_at = ?1, hlc = ?2 WHERE id = ?3", - (now, hlc, id), - )?; + ops::record(conn, owner, &hlc, op_type::NODE_TOMBSTONE, id, json!({}))?; Ok(()) } diff --git a/crates/heph-core/src/sqlite/ops.rs b/crates/heph-core/src/sqlite/ops.rs new file mode 100644 index 0000000..a4b7f76 --- /dev/null +++ b/crates/heph-core/src/sqlite/ops.rs @@ -0,0 +1,97 @@ +//! `oplog` table operations — recording local ops and reading them by HLC +//! cursor (tech-spec §12). The merge/apply path lives in [`super::apply`]. + +use rusqlite::{Connection, Row}; +use serde_json::Value; + +use super::new_id; +use crate::error::Result; +use crate::hlc::Hlc; +use crate::oplog::Op; + +const COLUMNS: &str = "id, owner_id, hlc, origin, op_type, target_id, payload, applied"; + +/// Record a **local** op (already applied to the materialized tables in this +/// same transaction). The originating device is recovered from `hlc`. +pub(super) fn record( + conn: &Connection, + owner: &str, + hlc: &str, + op_type: &str, + target_id: &str, + payload: Value, +) -> Result<()> { + let origin = Hlc::parse(hlc)?.origin; + conn.execute( + "INSERT INTO oplog (id, owner_id, hlc, origin, op_type, target_id, payload, applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1)", + ( + new_id(), + owner, + hlc, + origin, + op_type, + target_id, + payload.to_string(), + ), + )?; + Ok(()) +} + +fn from_row(row: &Row) -> rusqlite::Result { + let payload_text: String = row.get("payload")?; + let payload: Value = serde_json::from_str(&payload_text).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) + })?; + Ok(Op { + id: row.get("id")?, + owner_id: row.get("owner_id")?, + hlc: row.get("hlc")?, + origin: row.get("origin")?, + op_type: row.get("op_type")?, + target_id: row.get("target_id")?, + payload, + applied: row.get::<_, i64>("applied")? != 0, + }) +} + +/// Ops for `owner` with HLC strictly greater than `after` (None ⇒ all), in +/// causal (HLC) order — the push cursor for sync. +pub(super) fn since(conn: &Connection, owner: &str, after: Option<&str>) -> Result> { + let sql = format!( + "SELECT {COLUMNS} FROM oplog + WHERE owner_id = ?1 AND (?2 IS NULL OR hlc > ?2) + ORDER BY hlc" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map((owner, after), from_row)?; + Ok(rows.collect::>>()?) +} + +/// Store a foreign [`Op`] verbatim (its own id/hlc/origin), marking whether it +/// was applied to the materialized tables. +pub(super) fn insert_op(conn: &Connection, op: &Op, applied: bool) -> Result<()> { + conn.execute( + "INSERT INTO oplog (id, owner_id, hlc, origin, op_type, target_id, payload, applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ( + &op.id, + &op.owner_id, + &op.hlc, + &op.origin, + &op.op_type, + &op.target_id, + op.payload.to_string(), + applied as i64, + ), + )?; + Ok(()) +} + +/// Whether an op with this id has already been recorded (idempotent apply). +pub(super) fn exists(conn: &Connection, op_id: &str) -> Result { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM oplog WHERE id = ?1", [op_id], |r| { + r.get(0) + })?; + Ok(n > 0) +} diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 343d675..771d47a 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -5,12 +5,46 @@ use rusqlite::{Connection, OptionalExtension, Row}; -use super::{links, log, next_hlc, nodes}; +use serde_json::json; + +use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; +use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; use crate::recurrence; +/// JSON payload of a task's scalar fields (for `task.create`/`task.set` ops). +fn scalar_payload(t: &Task) -> serde_json::Value { + json!({ + "attention": t.attention.map(|a| a.as_str()), + "do_date": t.do_date, + "late_on": t.late_on, + "state": t.state.as_str(), + "recurrence": t.recurrence, + }) +} + +/// Bump the task node's hlc/modified_at and record a `task.set` op snapshotting +/// the task's current scalars (LWW unit, tech-spec §12). +fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> { + let task = require(conn, node_id)?; + let hlc = next_hlc(conn, now)?; + conn.execute( + "UPDATE nodes SET modified_at = ?1, hlc = ?2 WHERE id = ?3", + (now, &hlc, node_id), + )?; + ops::record( + conn, + owner, + &hlc, + op_type::TASK_SET, + node_id, + scalar_payload(&task), + )?; + Ok(()) +} + fn from_row(row: &Row) -> rusqlite::Result { let attention = match row.get::<_, Option>("attention")? { Some(s) => Some( @@ -57,6 +91,7 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas None, ); nodes::insert(&tx, &task_node)?; + nodes::record_create(&tx, owner, &task_node)?; tx.execute( "INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", @@ -69,6 +104,19 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas &task.recurrence, ), )?; + let task = Task { + node_id: task_node.id.clone(), + ..task + }; + let task_create_hlc = next_hlc(&tx, now)?; + ops::record( + &tx, + owner, + &task_create_hlc, + op_type::TASK_CREATE, + &task.node_id, + scalar_payload(&task), + )?; // The canonical context doc (the task's jumping-off point / checklist body). let doc_hlc = next_hlc(&tx, now)?; @@ -81,18 +129,30 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas Some(String::new()), ); nodes::insert(&tx, &doc)?; - links::add(&tx, now, &task_node.id, &doc.id, LinkType::CanonicalContext)?; + nodes::record_create(&tx, owner, &doc)?; + links::add( + &tx, + owner, + now, + &task.node_id, + &doc.id, + LinkType::CanonicalContext, + )?; if let Some(project_id) = &input.project_id { - links::add(&tx, now, &task_node.id, project_id, LinkType::InProject)?; + links::add( + &tx, + owner, + now, + &task.node_id, + project_id, + LinkType::InProject, + )?; } tx.commit()?; - Ok(Task { - node_id: task_node.id, - ..task - }) + Ok(task) } /// Fetch a task by node id. @@ -128,7 +188,7 @@ pub(super) fn set_state( "UPDATE tasks SET state = ?1 WHERE node_id = ?2", (state.as_str(), node_id), )?; - nodes::touch(conn, now, node_id)?; + record_set(conn, owner, now, node_id)?; require(conn, node_id) } @@ -168,7 +228,7 @@ fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Re // 3. Advance the do-date (or finally finish a finite series). advance(&tx, now, &task.node_id, rrule, task.do_date)?; - nodes::touch(&tx, now, &task.node_id)?; + record_set(&tx, owner, now, &task.node_id)?; tx.commit()?; require(conn, &task.node_id) @@ -203,14 +263,14 @@ fn advance( /// Skip the current occurrence of a recurring task: advance the do-date the same /// way as completion but **without** logging a completion (tech-spec §4.4). -pub(super) fn skip(conn: &Connection, now: i64, node_id: &str) -> Result { +pub(super) fn skip(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result { let task = require(conn, node_id)?; let rrule = task .recurrence .as_deref() .ok_or_else(|| Error::Integrity(format!("skip on non-recurring task {node_id}")))?; advance(conn, now, node_id, rrule, task.do_date)?; - nodes::touch(conn, now, node_id)?; + record_set(conn, owner, now, node_id)?; require(conn, node_id) } @@ -348,6 +408,7 @@ fn load_candidates(conn: &Connection, owner: &str) -> Result> { /// Set a task's attention-state. pub(super) fn set_attention( conn: &Connection, + owner: &str, now: i64, node_id: &str, attention: Attention, @@ -359,6 +420,6 @@ pub(super) fn set_attention( if updated == 0 { return Err(Error::NodeNotFound(node_id.to_string())); } - nodes::touch(conn, now, node_id)?; + record_set(conn, owner, now, node_id)?; require(conn, node_id) } diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 2893565..c4aaea6 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,7 +5,10 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; -use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; +use crate::model::{ + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState, +}; +use crate::oplog::Op; use crate::ranking::RankedTask; /// A backend that can store and retrieve nodes, tasks, and links. @@ -106,4 +109,27 @@ pub trait Store { /// Export every non-tombstoned node to a `.md` directory tree under `dir`, /// returning the count written (tech-spec §5). One-way; no import. fn export(&self, dir: &std::path::Path) -> Result; + + // --- sync (op-log) --- + + /// Ops for this owner with HLC strictly greater than `after` (None ⇒ all), + /// in causal order — the push cursor for sync (tech-spec §12). + fn ops_since(&self, after: Option<&str>) -> Result>; + + /// Apply a foreign op with the merge rules (LWW / OR-set / tombstone), + /// idempotently. Returns `true` if newly applied. Ops should be applied in + /// HLC order (tech-spec §12). + fn apply_op(&mut self, op: &Op) -> Result; + + /// Rewrite this replica's `owner_id` to a canonical user id — the one-time, + /// pre-first-sync adoption of tech-spec §13. After this all data is owned by + /// `canonical`; replicas that adopt the same id can sync. (Full adoption, + /// incl. owner-embedded deterministic ids, is refined with auth.) + fn adopt_owner(&mut self, canonical: &str) -> Result<()>; + + /// Open merge conflicts surfaced for the user (`heph conflicts`). + fn conflicts_list(&self) -> Result>; + + /// Settle a conflict by the user's choice (`"local"`/`"remote"`). + fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>; } diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs new file mode 100644 index 0000000..a445771 --- /dev/null +++ b/crates/heph-core/tests/convergence.rs @@ -0,0 +1,207 @@ +//! Op-log + merge convergence (tech-spec §12, slice 8b/8c). Two local replicas +//! (distinct origins) exchange ops; we assert they converge and that ambiguous +//! merges surface as conflicts. Body merge here is LWW; the yrs text CRDT lands +//! in a follow-up. No network yet — we hand ops across directly. + +use heph_core::{Attention, Clock, LocalStore, NewNode, NewTask, Op, Store, TaskState}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +#[derive(Clone)] +struct StepClock(Arc); +impl StepClock { + fn new(ms: i64) -> Self { + StepClock(Arc::new(AtomicI64::new(ms))) + } + fn set(&self, ms: i64) { + self.0.store(ms, Ordering::SeqCst); + } +} +impl Clock for StepClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +/// The shared canonical user both replicas adopt — the §13 model where every +/// device of one human carries the same owner id. +const OWNER: &str = "canonical-user"; + +fn replica(now: i64) -> (LocalStore, StepClock) { + let c = StepClock::new(now); + let mut s = LocalStore::open_in_memory(Box::new(c.clone())).unwrap(); + s.adopt_owner(OWNER).unwrap(); + (s, c) +} + +/// Push every op from `src` (after `cursor`) into `dst`, in HLC order. +fn sync_one_way(src: &dyn Store, dst: &mut dyn Store, cursor: Option<&str>) -> Option { + let ops: Vec = src.ops_since(cursor).unwrap(); + let mut last = cursor.map(str::to_string); + for op in &ops { + dst.apply_op(op).unwrap(); + last = Some(op.hlc.clone()); + } + last +} + +#[test] +fn online_round_trip_propagates_a_node() { + let (mut a, _ca) = replica(1000); + let (mut b, _cb) = replica(1000); + + let n = a + .create_node(NewNode::doc("Roof", "shingles need work")) + .unwrap(); + sync_one_way(&a, &mut b, None); + + let on_b = b.get_node(&n.id).unwrap().expect("node reached B"); + assert_eq!(on_b.title, "Roof"); + assert_eq!(on_b.body.as_deref(), Some("shingles need work")); +} + +#[test] +fn apply_is_idempotent() { + let (mut a, _ca) = replica(1000); + let (mut b, _cb) = replica(1000); + let n = a.create_node(NewNode::doc("X", "y")).unwrap(); + + // Apply all of A's ops to B twice — second pass is a no-op. + sync_one_way(&a, &mut b, None); + let again: Vec = a.ops_since(None).unwrap(); + for op in &again { + assert!( + !b.apply_op(op).unwrap(), + "re-applying {} mutated B", + op.op_type + ); + } + assert_eq!(b.get_node(&n.id).unwrap().unwrap().title, "X"); +} + +#[test] +fn offline_divergent_scalar_edits_converge_with_a_conflict() { + // A creates a task; B learns it. Then both go offline and set a different + // do_date. After exchanging, both converge to the higher-HLC value and each + // records a conflict for the discarded value. + let (mut a, ca) = replica(1000); + let (mut b, cb) = replica(1000); + + let task = a + .create_task(NewTask { + title: "Renew passport".into(), + attention: Some(Attention::Orange), + ..Default::default() + }) + .unwrap(); + sync_one_way(&a, &mut b, None); + + // Divergent offline edits. B's edit is later (higher physical time) → wins. + ca.set(2000); + a.set_task_state(&task.node_id, TaskState::Done).unwrap(); + cb.set(3000); + b.set_task_attention(&task.node_id, Attention::Red).unwrap(); + // Give each a distinct do_date too (the conflicting field). + // (set_task_* above already produced task.set ops snapshotting scalars.) + + // Exchange the divergent ops. + sync_one_way(&a, &mut b, None); + sync_one_way(&b, &mut a, None); + + // Both replicas converge to identical task scalars. + let ta = a.get_task(&task.node_id).unwrap().unwrap(); + let tb = b.get_task(&task.node_id).unwrap().unwrap(); + assert_eq!(ta, tb, "replicas did not converge: {ta:?} vs {tb:?}"); + // B wrote last (t=3000) → its attention=red wins on both. + assert_eq!(ta.attention, Some(Attention::Red)); + + // Each replica surfaced the divergence as a conflict (not silently merged). + assert!( + !a.conflicts_list().unwrap().is_empty(), + "A recorded no conflict" + ); + assert!( + !b.conflicts_list().unwrap().is_empty(), + "B recorded no conflict" + ); +} + +#[test] +fn concurrent_body_edits_converge_lww() { + let (mut a, ca) = replica(1000); + let (mut b, cb) = replica(1000); + + let n = a.create_node(NewNode::doc("Note", "base")).unwrap(); + sync_one_way(&a, &mut b, None); + + ca.set(2000); + a.update_node(&n.id, None, Some("A's edit".into())).unwrap(); + cb.set(2500); + b.update_node(&n.id, None, Some("B's edit".into())).unwrap(); + + sync_one_way(&a, &mut b, None); + sync_one_way(&b, &mut a, None); + + let ba = a.get_node(&n.id).unwrap().unwrap().body; + let bb = b.get_node(&n.id).unwrap().unwrap().body; + assert_eq!(ba, bb, "bodies did not converge"); + assert_eq!(ba.as_deref(), Some("B's edit")); // later HLC wins +} + +#[test] +fn links_are_an_or_set() { + // A and B each add a different link to the same node; both survive. + let (mut a, ca) = replica(1000); + let (mut b, cb) = replica(1000); + + let src = a.create_node(NewNode::doc("src", "")).unwrap(); + let d1 = a.create_node(NewNode::doc("d1", "")).unwrap(); + sync_one_way(&a, &mut b, None); + let d2 = b.create_node(NewNode::doc("d2", "")).unwrap(); + sync_one_way(&b, &mut a, None); + + ca.set(2000); + a.add_link(&src.id, &d1.id, heph_core::LinkType::Blocks) + .unwrap(); + cb.set(2000); + b.add_link(&src.id, &d2.id, heph_core::LinkType::Blocks) + .unwrap(); + + sync_one_way(&a, &mut b, None); + sync_one_way(&b, &mut a, None); + + let dsts_a: Vec = a + .outgoing_links(&src.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == heph_core::LinkType::Blocks) + .map(|l| l.dst_id) + .collect(); + let mut dsts_b: Vec = b + .outgoing_links(&src.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == heph_core::LinkType::Blocks) + .map(|l| l.dst_id) + .collect(); + let mut sorted_a = dsts_a.clone(); + sorted_a.sort(); + dsts_b.sort(); + assert_eq!(sorted_a, dsts_b, "link sets did not converge"); + assert!(dsts_b.contains(&d1.id) && dsts_b.contains(&d2.id)); +} + +#[test] +fn tombstones_propagate_and_are_monotonic() { + let (mut a, _ca) = replica(1000); + let (mut b, _cb) = replica(1000); + let n = a.create_node(NewNode::doc("doomed", "")).unwrap(); + sync_one_way(&a, &mut b, None); + + a.tombstone_node(&n.id).unwrap(); + sync_one_way(&a, &mut b, None); + + assert!(b.get_node(&n.id).unwrap().unwrap().tombstoned); + // Tombstoned nodes drop out of search/next on B. + assert!(b.search("doomed").unwrap().is_empty()); +} -- 2.50.1 (Apple Git-155) From 662a360da8fc29113e392354e1e4b660ed0b7160 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 05:23:43 -0700 Subject: [PATCH 12/91] =?UTF-8?q?docs:=20Phase=201=20progress=20tracker=20?= =?UTF-8?q?(design=20roadmap=20+=20tech-spec=20=C2=A714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record what's built and the resume order for the next context session: heph-core + hephd local mode + CLI/export + local query surface + the sync engine (HLC, op-log, converging merge/conflict-queue) are done; resume at yrs body-CRDT → network sync → OIDC auth → heph.nvim. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/explanation/design.md | 7 +++++++ docs/reference/tech-spec.md | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/explanation/design.md b/docs/explanation/design.md index c8cb070..ff7b711 100644 --- a/docs/explanation/design.md +++ b/docs/explanation/design.md @@ -387,6 +387,13 @@ Reuse the established blumeops patterns (🔒 confirmed by repo conventions): - **Phase 0 — Design** (this document + [[tech-spec]]): done enough to build. - **Phase 1 — v1 prototype (a single C1 effort, deliberately — a one-shot test of delivering a high-complexity prototype from the spec; built in TDD slices):** `heph-core` (model, schema, extraction, recurrence, "what is next", `Store` trait, op-log/HLC/CRDT merge) → `hephd` **local mode** → **server + client modes (+ lock handoff)** → **offline sync + conflict queue** → **OIDC/Authentik auth + per-user isolation** → `heph.nvim` + `heph` CLI. Local-only works standalone; runnable client/server + offline sync on the tailnet. The build order doubles as the cross-session resume tracker (next un-green slice = where to resume). C2/Mikado is *not* used: it sequences prerequisites against existing code under test, and this is greenfield delivery from a complete spec; follow-up C1s or a C2 refactor come later as needed. + + **Phase 1 progress** (branch `feature/v1-prototype`, PR #1; 102 tests green as of 2026-06-01 — see [[tech-spec]] §14 for the per-area tracker): + - [x] `heph-core` library — schema/`Store`/`LocalStore`, extraction, tasks/links/canonical-context + wiki-link materialization, "what is next?" ranking, recurrence roll-forward + per-task logs. + - [x] `hephd` **local mode** — file lock + JSON-RPC over a unix socket (tokio blocking pool); `heph` CLI + `export`. + - [x] Local query surface — `list`, `health`, `journal`, `search` (FTS5). + - [x] **Sync engine (no network yet)** — HLC + device origin; op-log recording; `apply_op` merge (LWW + conflict queue, OR-set links, monotonic tombstones, idempotent); two-replica convergence proven; `adopt_owner` (basic §13). + - [ ] **Resume here →** yrs text-CRDT for bodies (upgrade body merge from LWW); then **server/client modes + network push/pull** (transport over the existing engine); then **OIDC/Authentik auth**; then **`heph.nvim`**. - **Phase 2 — k3s deployment:** Dagger→Zot image, ArgoCD app + Kustomize manifests, external-secrets; hub on blumeops. - **Phase 3 — Web UI** on the hub. - **Phase 4 (later, optional)** — calendar integration (careful CalDAV); migration from ZK / Todoist; iOS / Apple Watch voice capture; Hermes-style planning mode. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 60000b6..e6e7e32 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -324,6 +324,29 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - **Local trust:** the local unix-socket RPC trusts the OS user (file-permission-scoped socket); app-level auth is for the **network** boundary (device ↔ hub). - **At-rest:** plain SQLite in v1 (no encryption) — security boundary is auth + (eventually) network restriction. May revisit (see [[design]]). +## 14. Implementation status (Phase 1 tracker) + +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **102 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). + +**Done** + +- ✅ **Data model + schema (§4):** migrations v1–v3 (base graph, FTS5, `meta`). Node kinds, tasks, links, aliases, users, oplog, sync_state, conflicts, meta. `Store` trait + `LocalStore`. +- ✅ **Markdown handling (§5):** wiki-link + checkbox extraction (pure, idempotent, code-aware); `update_node` materializes/reconciles `wiki` links; `export` to a `/.md` tree. +- ✅ **Recurrence (§4.4):** roll-forward in place — fresh checklist, logged occurrence, advance-skipping-misses; completion never carries forward (proptest). Per-task logs; `skip`. +- ✅ **Ranking (§7):** pure two-stage filter + reorderable named dimensions; proptest total order. +- ✅ **Daemon RPC (§6) — local subset:** node.get/create/update/tombstone, task.create/set_state/set_attention/skip, next, list, health, journal.open_or_create, search, links.outgoing/backlinks, log.append/tail, export, conflicts.list/resolve, sync (ops_since/apply_op). Line-delimited JSON-RPC over a unix socket; sync `Client`. +- ✅ **Runtime modes (§3.1) — `local` only:** exclusive file-lock handoff via `LockGuard`. +- ✅ **Sync engine (§12) minus network:** HLC (clock-injected, monotonic) + persistent device `origin`; op-log per mutation; `apply_op` merge — **LWW** scalars/bodies with a **conflict queue**, **OR-set** links, monotonic tombstones, idempotent; two-replica convergence proven. `adopt_owner` = basic §13 canonical-owner adoption. +- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. +- ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). + +**Not yet done (resume order)** + +1. ⏳ **Body CRDT (§5, §12):** replace body LWW with the **yrs** text CRDT — `body_crdt` BLOB, diff whole-body writes into the yrs doc, merge yrs updates in `apply`. (CRDT lib ratified = `yrs`.) +2. ⏳ **`server`/`client` modes + network sync (§3.1, §6.1, §12):** `RemoteStore`; hub network endpoint; push/pull by HLC cursor (the merge logic already exists); background sync; `sync.now`/`sync.status` RPC; multi-replica-over-real-sockets tests. Open: transport (`axum` HTTP/JSON vs gRPC), propagation cadence, device-id/hub registration. +3. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub endpoint, full per-user isolation, adoption-with-deterministic-ids. +4. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). + ## Related - [[design]] — full design document with rationale and decision history -- 2.50.1 (Apple Git-155) From 16b552abd56629a271470eeb76cbfe113a7cb0e4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 05:26:18 -0700 Subject: [PATCH 13/91] docs: rewrite README for the hephaestus application Replace the template-residue body (docs-scaffolding focus) with content about the actual project: what heph is, the "what is next?" discipline, current Phase 1 status table, the workspace/crate architecture, build & run instructions (hephd + heph CLI), and development conventions. Keep the All-Rights-Reserved license. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 107 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 79 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8394baf..3ca86c6 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,94 @@ # hephaestus -A personal context management system — a unified, self-hosted application that fuses a wiki-style knowledge base (Zettelkasten) with task management. Built in Rust, offline-capable, and syncs to a central instance hosted in blumeops. +A personal context management system — a single, self-hosted Rust application that fuses a wiki-style knowledge base (Zettelkasten) with task management. Notes and tasks are **first-class, cross-linkable entities** in one database, so a task like "Fix the roof leak" stays coupled to the home-repair log and contractor-call notes that give it context. -See [the project design document](docs/explanation/design.md) for goals, architecture, and the development roadmap. +It is **offline-first**: fully useful on a laptop with no network, and (when the distributed layer lands) auto-syncing to a central instance hosted in blumeops. The primary surface will be a Neovim plugin (`heph.nvim`, an obsidian.nvim replacement); a CLI (`heph`) is the utility/scripting surface. -## What's Included +> **Why "what is next?" is the flagship.** heph is organized around concise, honest answers to recurring questions — above all *"what do I do right now?"* — built from ~10 years of the owner's lived prioritization discipline: projects-as-contexts; attention colors (white/orange/red/blue, where **red = a consequence exists if late**, not importance); **do-dates, not due-dates** (earliest-actionable, never a deadline alarm); and working-set tensions surfaced honestly (no "fake happy", no overwhelm). -- **Documentation** — [Diataxis](https://diataxis.fr/)-structured docs built with [Quartz](https://quartz.jzhao.xyz/) -- **Changelog** — [Towncrier](https://towncrier.readthedocs.io/) fragment-based changelog -- **CI/CD** — [Dagger](https://dagger.io/) pipelines + Forgejo `build` and `release` workflows -- **Pre-commit hooks** — [prek](https://github.com/dustinblackman/prek) with linting, formatting, secret detection -- **AI assistance** — `AGENTS.md` + structured docs for Claude Code (C0/C1/C2 change process, Mikado method) -- **Task runner** — [mise](https://mise.jdx.dev/) tasks for docs validation, Mikado chain management, release preview, and runner inspection +See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision and rationale, and **[docs/reference/tech-spec.md](docs/reference/tech-spec.md)** for the implementation-facing specification. -## Getting Started +## Status + +**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. The **local-only system is feature-complete and the offline-sync merge engine converges**; remaining work is the network transport, auth, and the Neovim plugin. Built test-first (102 tests at last update). The canonical tracker is **tech-spec §14**. + +| Area | State | +|---|---| +| Data model, markdown extraction, wiki-links, export | ✅ done | +| Tasks, links, "what is next?" ranking, recurrence, per-task logs | ✅ done | +| `hephd` daemon — **local mode** (file lock + JSON-RPC over a unix socket) | ✅ done | +| `heph` CLI; `list` / `health` / `journal` / full-text `search` (FTS5) | ✅ done | +| Sync engine — HLC, op-log, converging merge + conflict queue (no network yet) | ✅ done | +| yrs text-CRDT for body merge | ⏳ next | +| `server`/`client` modes + network push/pull sync | ⏳ | +| OIDC/Authentik auth + per-user isolation | ⏳ | +| `heph.nvim` (primary surface) | ⏳ | + +## Architecture + +A Cargo workspace, layered so the same core runs from a laptop to a hub: + +- **`crates/heph-core`** — the library: data model, the `Store` trait + SQLite store, markdown parsing/extraction, recurrence, the "what is next?" engine, and the sync engine (op-log, hybrid logical clocks, CRDT/LWW merge, conflict detection). Synchronous and clock-injected (no ambient wall-clock reads) so ranking and merge are deterministic. +- **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica), **`server`** (also a network endpoint + sync hub), **`client`** (thin, remote) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and (later) background sync. +- **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). +- **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. + +**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth** *(planned)*: OIDC against Authentik, with per-user isolation. + +## Build & run + +Requires a Rust toolchain (stable). The build is a standard Cargo workspace: ```bash -# Install git hooks -prek install && prek install --hook-type commit-msg - -# Run all pre-commit checks -prek run --all-files - -# List available tasks -mise tasks - -# Build docs (requires Dagger) -dagger call build-docs --src=. --version=dev export --path=./docs-dev.tar.gz +cargo build # build all crates +cargo test --all # run the full test suite +cargo clippy --all-targets -- -D warnings ``` -## Project Structure +Run the daemon in local mode, then drive it with the CLI: + +```bash +# Terminal 1 — start the daemon (creates ~/.local/share/heph/heph.db and a socket) +cargo run -p hephd + +# Terminal 2 — talk to it +cargo run -p heph -- task "Fix the roof leak" --attention red +cargo run -p heph -- next # the "what is next?" ranking +cargo run -p heph -- doc "Roof log" --body "Called the contractor." +cargo run -p heph -- search roof # full-text search +cargo run -p heph -- journal 2026-06-01 # open/create today's journal +cargo run -p heph -- export ./snapshot # write the store as a .md tree +``` + +The daemon takes an exclusive lock on its DB file; `--db` and `--socket` override the defaults. + +## Development + +- **Test-driven.** Every feature has tests at the appropriate layer(s) — unit, property tests, real-socket daemon integration, and CLI process tests. No feature is "done" without them. +- **Change process.** Changes are classified **C0 / C1 / C2** (quick fix / human-review PR / Mikado chain). The v1 prototype is a single long-lived **C1**. See [docs/how-to/agent-change-process.md](docs/how-to/agent-change-process.md). +- **Git hooks & tooling.** [prek](https://github.com/dustinblackman/prek) runs formatting, linting, and secret detection; [mise](https://mise.jdx.dev/) runs repo automation; CI (Forgejo) runs `prek` plus `cargo fmt`/`clippy`/`test` via `.forgejo/scripts/build`. + +```bash +prek install && prek install --hook-type commit-msg # set up hooks +prek run --all-files # run all checks +mise tasks # list automation tasks +mise run ai-docs # docs AI agents read first +``` + +- **Documentation** is [Diataxis](https://diataxis.fr/)-structured and built with [Quartz](https://quartz.jzhao.xyz/); changelog fragments use [Towncrier](https://towncrier.readthedocs.io/) under `docs/changelog.d/`. Working agents should read **[AGENTS.md](AGENTS.md)** first. + +## Repository layout ``` -./docs/ # documentation (Diataxis, Quartz) -./docs/changelog.d/ # leave only .gitkeep in the template; generated repos add towncrier fragments here -./.dagger/ # Dagger module backing docs builds and releases -./.forgejo/workflows/ # generic build/release workflows for generated repos -./.forgejo/scripts/ # optional per-project hooks consumed by those workflows -./mise-tasks/ # scripts via `mise run` +./Cargo.toml # workspace manifest +./crates/heph-core/ # core library: model, store, extraction, recurrence, ranking, sync +./crates/hephd/ # daemon: local mode (JSON-RPC over a unix socket); server/client planned +./crates/heph/ # CLI: thin client of the daemon +./heph.nvim/ # Neovim plugin (planned) +./docs/ # Diataxis docs (design, tech-spec, how-to), Quartz config +./.forgejo/ # CI build + release workflows and hooks +./.dagger/ # Dagger module backing docs builds/releases +./mise-tasks/ # repo automation via `mise run` ``` ## License -- 2.50.1 (Apple Git-155) From 455f172a548ecdbe83285f5e74b379ca81ac676d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 09:06:17 -0700 Subject: [PATCH 14/91] heph-core: body text-CRDT via yrs (sync 8d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace last-writer-wins for node bodies with the yrs text CRDT, so concurrent edits to different regions of a body merge instead of one clobbering the other (tech-spec §5, §12). - New crate::crdt module wraps yrs: a device authors under a stable client_id derived from its sync origin; a whole-buffer write is diffed (common prefix/suffix, char-boundary safe) into the doc and the yrs delta is captured; merge is commutative/idempotent. - nodes::create/update/journal maintain the body_crdt BLOB and put the yrs delta in the node.create/node.set op payload (body_crdt field). Recurrence's local checklist reset goes through the same path to keep body and body_crdt consistent (still records no op, as before). - apply::node_upsert merges the body delta through the CRDT regardless of HLC order and drops body-conflict recording; titles + task scalars stay LWW with the conflict queue. - convergence test now asserts disjoint concurrent body edits both survive and enqueue no conflict. 97 tests green; clippy -D warnings + fmt + prek clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 157 +++++++++++++++++ Cargo.toml | 1 + README.md | 6 +- crates/heph-core/Cargo.toml | 1 + crates/heph-core/src/crdt.rs | 210 +++++++++++++++++++++++ crates/heph-core/src/lib.rs | 1 + crates/heph-core/src/oplog.rs | 6 +- crates/heph-core/src/sqlite/apply.rs | 91 ++++++---- crates/heph-core/src/sqlite/nodes.rs | 138 +++++++++++---- crates/heph-core/src/sqlite/tasks.rs | 6 +- crates/heph-core/tests/convergence.rs | 29 +++- docs/changelog.d/v1-prototype.feature.md | 2 + docs/reference/tech-spec.md | 14 +- 13 files changed, 576 insertions(+), 86 deletions(-) create mode 100644 crates/heph-core/src/crdt.rs diff --git a/Cargo.lock b/Cargo.lock index ddad7a8..2241351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,37 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -224,12 +255,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "errno" version = "0.3.14" @@ -240,6 +300,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -257,6 +338,9 @@ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom", +] [[package]] name = "find-msvc-tools" @@ -311,9 +395,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -367,6 +453,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "ulid", + "yrs", ] [[package]] @@ -469,6 +556,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.30" @@ -531,6 +627,25 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "parse-zoneinfo" version = "0.3.1" @@ -712,6 +827,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -813,6 +937,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -883,6 +1013,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallstr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" +dependencies = [ + "smallvec", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -1352,6 +1491,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "yrs" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89512f2d869f9947e1c58d57ef86c8f4ca1b1e8ccf24d6e1ff8c7cdbd67d54df" +dependencies = [ + "arc-swap", + "async-lock", + "async-trait", + "dashmap", + "fastrand", + "serde", + "serde_json", + "smallstr", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "zerocopy" version = "0.8.50" diff --git a/Cargo.toml b/Cargo.toml index d3a6d85..05610a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ thiserror = "2" anyhow = "1" pulldown-cmark = { version = "0.13", default-features = false } rrule = "0.13" +yrs = "0.26" chrono = { version = "0.4", default-features = false, features = ["clock"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 3ca86c6..51f4dfd 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. The **local-only system is feature-complete and the offline-sync merge engine converges**; remaining work is the network transport, auth, and the Neovim plugin. Built test-first (102 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. The **local-only system is feature-complete and the offline-sync merge engine converges** (including a `yrs` text-CRDT for bodies); remaining work is the network transport, auth, and the Neovim plugin. Built test-first (97 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -19,8 +19,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | `hephd` daemon — **local mode** (file lock + JSON-RPC over a unix socket) | ✅ done | | `heph` CLI; `list` / `health` / `journal` / full-text `search` (FTS5) | ✅ done | | Sync engine — HLC, op-log, converging merge + conflict queue (no network yet) | ✅ done | -| yrs text-CRDT for body merge | ⏳ next | -| `server`/`client` modes + network push/pull sync | ⏳ | +| yrs text-CRDT for body merge | ✅ done | +| `server`/`client` modes + network push/pull sync | ⏳ next | | OIDC/Authentik auth + per-user isolation | ⏳ | | `heph.nvim` (primary surface) | ⏳ | diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index 6701f42..0f0763f 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -14,6 +14,7 @@ ulid.workspace = true thiserror.workspace = true pulldown-cmark.workspace = true rrule.workspace = true +yrs.workspace = true chrono.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/heph-core/src/crdt.rs b/crates/heph-core/src/crdt.rs new file mode 100644 index 0000000..eb47e2f --- /dev/null +++ b/crates/heph-core/src/crdt.rs @@ -0,0 +1,210 @@ +//! Body text-CRDT helpers (tech-spec §5, §12). +//! +//! A node's body merges as a [`yrs`] text CRDT. The `nodes.body_crdt` BLOB is +//! the encoded CRDT state; the `nodes.body` TEXT column is its materialized +//! view. Surfaces only ever send whole-buffer text, so a write is **diffed** +//! into the CRDT (common prefix/suffix) and the resulting yrs update travels in +//! the op payload — peers then *merge* concurrent edits instead of clobbering +//! the loser with last-writer-wins. +//! +//! Offsets are bytes ([`OffsetKind::Bytes`]); the diff aligns its cut points to +//! UTF-8 char boundaries so multibyte text is never split mid-codepoint. Each +//! device authors under a stable [`client_id`] derived from its sync `origin`, +//! so its successive edits extend one yrs client sequence (the Yjs model of one +//! client per actor) rather than minting a fresh client per write. + +use yrs::updates::decoder::Decode; +use yrs::{ + ClientID, Doc, GetString, OffsetKind, Options, ReadTxn, StateVector, Text, Transact, Update, +}; + +/// The shared text field name inside every body doc. +const FIELD: &str = "body"; + +/// A stable yrs `client_id` for a device, derived from its sync `origin` via +/// FNV-1a so a device's edits always extend the same client sequence. yrs +/// restricts client ids to 53 bits (JS-safe-integer range), so we fold the +/// hash into that range and keep it non-zero. +pub(crate) fn client_id(origin: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in origin.as_bytes() { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + (hash & ((1u64 << 53) - 1)) | 1 // 53-bit, never 0 +} + +/// Build a byte-offset doc, optionally seeded from a stored state blob. +fn load(client: u64, state: Option<&[u8]>) -> Doc { + let doc = Doc::with_options(Options { + client_id: ClientID::new(client), + offset_kind: OffsetKind::Bytes, + ..Default::default() + }); + if let Some(bytes) = state { + if let Ok(update) = Update::decode_v1(bytes) { + let mut txn = doc.transact_mut(); + let _ = txn.apply_update(update); + } + } + doc +} + +/// Encode the whole doc state for persistence in `body_crdt`. +fn encode_state(doc: &Doc) -> Vec { + doc.transact() + .encode_state_as_update_v1(&StateVector::default()) +} + +/// Materialize the body text. +fn materialize(doc: &Doc) -> String { + let text = doc.get_or_insert_text(FIELD); + text.get_string(&doc.transact()) +} + +/// The outcome of writing a whole body into the CRDT. +pub(crate) struct BodyWrite { + /// New encoded CRDT state to persist in `body_crdt`. + pub state: Vec, + /// The yrs update describing just this write — travels in the op payload. + pub delta: Vec, + /// The materialized body text after the write. + pub body: String, +} + +/// Diff `new_body` into the CRDT seeded from `prev_state`, authoring under +/// `client`. Returns the new state, the delta update for peers, and the +/// materialized text. +pub(crate) fn write_body(client: u64, prev_state: Option<&[u8]>, new_body: &str) -> BodyWrite { + let doc = load(client, prev_state); + let before = doc.transact().state_vector(); + { + let text = doc.get_or_insert_text(FIELD); + let mut txn = doc.transact_mut(); + let cur = text.get_string(&txn); + let (start, del, ins) = diff(&cur, new_body); + if del > 0 { + text.remove_range(&mut txn, start as u32, del as u32); + } + if !ins.is_empty() { + text.insert(&mut txn, start as u32, ins); + } + } + let delta = doc.transact().encode_state_as_update_v1(&before); + BodyWrite { + state: encode_state(&doc), + delta, + body: materialize(&doc), + } +} + +/// The outcome of merging a peer's delta into the CRDT. +pub(crate) struct BodyMerge { + /// New encoded CRDT state to persist in `body_crdt`. + pub state: Vec, + /// The materialized body text after the merge. + pub body: String, +} + +/// Merge a peer's `delta` update into the CRDT seeded from `prev_state`. The +/// merging doc never authors, so its `client_id` is irrelevant. Commutative and +/// idempotent — applying the same delta twice is a no-op. +pub(crate) fn merge_body(prev_state: Option<&[u8]>, delta: &[u8]) -> BodyMerge { + let doc = load(0, prev_state); + if let Ok(update) = Update::decode_v1(delta) { + let mut txn = doc.transact_mut(); + let _ = txn.apply_update(update); + } + BodyMerge { + state: encode_state(&doc), + body: materialize(&doc), + } +} + +/// Materialize a stored CRDT state blob to its body text. +#[cfg(test)] +pub(crate) fn body_of(state: &[u8]) -> String { + materialize(&load(0, Some(state))) +} + +/// Common prefix/suffix diff over byte indices, cut points aligned to UTF-8 +/// char boundaries. Returns `(start, delete_len, inserted)` such that replacing +/// `cur[start..start+delete_len]` with `inserted` yields `new`. +fn diff<'a>(cur: &str, new: &'a str) -> (usize, usize, &'a str) { + let (cb, nb) = (cur.as_bytes(), new.as_bytes()); + + // Longest common prefix (in bytes); cur and new agree below `start`, so a + // char boundary there is the same in both. + let mut start = 0; + let max_pre = cb.len().min(nb.len()); + while start < max_pre && cb[start] == nb[start] { + start += 1; + } + while start > 0 && !cur.is_char_boundary(start) { + start -= 1; + } + + // Longest common suffix not overlapping the prefix. + let (mut ec, mut en) = (cb.len(), nb.len()); + while ec > start && en > start && cb[ec - 1] == nb[en - 1] { + ec -= 1; + en -= 1; + } + // If a cut landed mid-codepoint, extend the changed span forward to the + // next boundary in both strings. + while ec < cb.len() && (!cur.is_char_boundary(ec) || !new.is_char_boundary(en)) { + ec += 1; + en += 1; + } + + (start, ec - start, &new[start..en]) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: u64 = 0xaaaa; + const B: u64 = 0xbbbb; + + #[test] + fn write_then_materialize_round_trips() { + let w = write_body(A, None, "Hello world"); + assert_eq!(w.body, "Hello world"); + assert_eq!(body_of(&w.state), "Hello world"); + } + + #[test] + fn disjoint_concurrent_edits_merge() { + // A and B share a base, then edit different regions offline. + let base = write_body(A, None, "Hello world"); + let on_b = merge_body(None, &base.delta); // B receives the create + + let edit_a = write_body(A, Some(&base.state), "Hello brave world"); + let edit_b = write_body(B, Some(&on_b.state), "Hello world!"); + + // Each side merges the other's delta; both converge with both edits. + let a_final = merge_body(Some(&edit_a.state), &edit_b.delta); + let b_final = merge_body(Some(&edit_b.state), &edit_a.delta); + assert_eq!(a_final.body, b_final.body, "bodies did not converge"); + assert_eq!(a_final.body, "Hello brave world!"); + } + + #[test] + fn merge_is_idempotent() { + let base = write_body(A, None, "x"); + let edit = write_body(A, Some(&base.state), "xy"); + let once = merge_body(Some(&base.state), &edit.delta); + let twice = merge_body(Some(&once.state), &edit.delta); + assert_eq!(once.body, "xy"); + assert_eq!(twice.body, "xy"); + } + + #[test] + fn multibyte_edit_is_not_split() { + let base = write_body(A, None, "café"); + let edit = write_body(A, Some(&base.state), "café au lait"); + assert_eq!(edit.body, "café au lait"); + assert_eq!(body_of(&edit.state), "café au lait"); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 9124ac5..90f71ac 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -9,6 +9,7 @@ //! deterministic. pub mod clock; +mod crdt; pub mod error; pub mod export; pub mod extract; diff --git a/crates/heph-core/src/oplog.rs b/crates/heph-core/src/oplog.rs index 926be35..466e62d 100644 --- a/crates/heph-core/src/oplog.rs +++ b/crates/heph-core/src/oplog.rs @@ -11,9 +11,11 @@ use serde_json::Value; /// Op type discriminators (the `op_type` column). pub mod op_type { - /// A node was created. Payload: `{kind, title, body, created_at}`. + /// A node was created. Payload: `{kind, title, body, created_at}`, plus + /// `body_crdt` (the yrs CRDT seed) when the node has a body. pub const NODE_CREATE: &str = "node.create"; - /// A node's title/body was set (LWW). Payload: `{title, body}`. + /// A node's title/body was set. Payload: `{title, body}`; a body change also + /// carries `body_crdt` (the yrs delta, merged — not LWW — on apply). pub const NODE_SET: &str = "node.set"; /// A node was tombstoned. Payload: `{}`. pub const NODE_TOMBSTONE: &str = "node.tombstone"; diff --git a/crates/heph-core/src/sqlite/apply.rs b/crates/heph-core/src/sqlite/apply.rs index b3be5ff..980679e 100644 --- a/crates/heph-core/src/sqlite/apply.rs +++ b/crates/heph-core/src/sqlite/apply.rs @@ -3,9 +3,11 @@ //! A peer's ops arrive in HLC order; [`apply`] replays each one idempotently //! with the merge rules: //! -//! - **node bodies / titles, task scalars:** last-writer-wins by HLC. A -//! *discarded* value from a different device is recorded in `conflicts` -//! (surfaced, not silently dropped). +//! - **node bodies:** merged through the **yrs text CRDT** (`body_crdt`) — +//! concurrent edits always merge, never a hard conflict. +//! - **node titles, task scalars:** last-writer-wins by HLC. A *discarded* +//! scalar value from a different device is recorded in `conflicts` (surfaced, +//! not silently dropped). //! - **links:** OR-set add/remove keyed by the link's own id → no conflicts. //! - **tombstones:** monotonic — once set, they stay. //! @@ -16,6 +18,7 @@ use rusqlite::{Connection, OptionalExtension}; use serde_json::Value; use super::{absorb_remote_hlc, new_id, nodes, ops}; +use crate::crdt; use crate::error::Result; use crate::hlc::Hlc; use crate::model::Conflict; @@ -120,55 +123,81 @@ fn cross_origin(op: &Op, current_hlc: &str) -> bool { .unwrap_or(false) } -/// Create a node (if absent) or LWW its title/body. +/// The yrs body delta carried by a node op, if any. +fn body_crdt_field(p: &Value) -> Option> { + p.get("body_crdt") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) +} + +/// Create a node (if absent), or merge its body via the text CRDT and LWW its +/// title. The body **never** produces a conflict — concurrent edits merge. fn node_upsert(tx: &Connection, op: &Op) -> Result<()> { let p = &op.payload; match nodes::get(tx, &op.target_id)? { None => { let kind = str_field(p, "kind").unwrap_or("doc"); let title = str_field(p, "title").unwrap_or(""); - let body = str_field(p, "body"); let created_at = i64_field(p, "created_at").unwrap_or_else(|| op_physical(op)); + // Seed our CRDT from the peer's, so we share its yrs client + // sequence rather than minting our own and duplicating the text. + let (body, body_crdt) = match body_crdt_field(p) { + Some(delta) => { + let m = crdt::merge_body(None, &delta); + (Some(m.body), Some(m.state)) + } + None => (str_field(p, "body").map(str::to_string), None), + }; tx.execute( "INSERT INTO nodes - (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 0)", + (id, owner_id, kind, title, body, body_crdt, created_at, modified_at, hlc, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 0)", ( &op.target_id, &op.owner_id, kind, title, - body, + &body, + &body_crdt, created_at, &op.hlc, ), )?; } Some(existing) => { - let new_title = str_field(p, "title"); - let new_body = str_field(p, "body"); - // Conflict: a different device's body differs from ours. - if cross_origin(op, &existing.hlc) { - if let Some(nb) = new_body { - if Some(nb) != existing.body.as_deref() { - record_conflict( - tx, - op, - "body", - existing.body.as_deref(), - Some(nb), - &existing.hlc, - )?; - } + let op_wins = op.hlc.as_str() > existing.hlc.as_str(); + // Title is a scalar — last-writer-wins by HLC. + let title = if op_wins { + str_field(p, "title").unwrap_or(&existing.title).to_string() + } else { + existing.title.clone() + }; + let hlc = if op_wins { + op.hlc.clone() + } else { + existing.hlc + }; + let modified = if op_wins { + op_physical(op) + } else { + existing.modified_at + }; + // Body merges through the CRDT regardless of HLC order (commutative, + // idempotent); a title-only op carries no delta and leaves it. + match body_crdt_field(p) { + Some(delta) => { + let prev = nodes::get_body_crdt(tx, &op.target_id)?; + let m = crdt::merge_body(prev.as_deref(), &delta); + tx.execute( + "UPDATE nodes SET title = ?1, body = ?2, body_crdt = ?3, modified_at = ?4, hlc = ?5 WHERE id = ?6", + (&title, &m.body, &m.state, modified, &hlc, &op.target_id), + )?; + } + None => { + tx.execute( + "UPDATE nodes SET title = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", + (&title, modified, &hlc, &op.target_id), + )?; } - } - if op.hlc.as_str() > existing.hlc.as_str() { - let title = new_title.unwrap_or(&existing.title).to_string(); - let body = new_body.map(str::to_string).or(existing.body.clone()); - tx.execute( - "UPDATE nodes SET title = ?1, body = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", - (&title, &body, op_physical(op), &op.hlc, &op.target_id), - )?; } } } diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index fde9876..ba04907 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -4,19 +4,43 @@ use rusqlite::{Connection, OptionalExtension, Row}; use serde_json::json; -use super::{links, new_id, next_hlc, ops}; +use super::{links, meta_get, new_id, next_hlc, ops}; +use crate::crdt; use crate::error::{Error, Result}; use crate::model::{deterministic_id, NewNode, Node, NodeKind}; use crate::oplog::op_type; -/// Op payload describing a node's identity/content for `node.create`. -fn create_payload(node: &Node) -> serde_json::Value { - json!({ +/// Op payload describing a node's identity/content for `node.create`. A node +/// with a body carries the CRDT seed (`body_crdt`) so peers adopt our yrs +/// client sequence rather than minting their own and duplicating the text. +fn create_payload(node: &Node, body_crdt: Option<&[u8]>) -> serde_json::Value { + let mut p = json!({ "kind": node.kind.as_str(), "title": node.title, "body": node.body, "created_at": node.created_at, - }) + }); + if let Some(d) = body_crdt { + p["body_crdt"] = json!(d); + } + p +} + +/// This device's stable yrs `client_id`, derived from its sync `origin`. +fn device_client(conn: &Connection) -> Result { + let origin = meta_get(conn, "origin")? + .ok_or_else(|| Error::Integrity("missing device origin".into()))?; + Ok(crdt::client_id(&origin)) +} + +/// Read a node's stored CRDT body state, if any. +pub(super) fn get_body_crdt(conn: &Connection, id: &str) -> Result>> { + let v: Option>> = conn + .query_row("SELECT body_crdt FROM nodes WHERE id = ?1", [id], |r| { + r.get(0) + }) + .optional()?; + Ok(v.flatten()) } /// The `nodes` columns in a fixed order, shared by every SELECT here. @@ -47,7 +71,8 @@ pub(super) fn build( } /// Record a `node.create` op for an already-inserted node (used when a caller -/// builds + inserts a node directly, e.g. `task.create`). +/// builds + inserts a node directly, e.g. `task.create`, whose nodes carry no +/// body and so need no CRDT seed). pub(super) fn record_create(conn: &Connection, owner: &str, node: &Node) -> Result<()> { ops::record( conn, @@ -55,7 +80,7 @@ pub(super) fn record_create(conn: &Connection, owner: &str, node: &Node) -> Resu &node.hlc, op_type::NODE_CREATE, &node.id, - create_payload(node), + create_payload(node, None), ) } @@ -95,22 +120,59 @@ pub(super) fn from_row(row: &Row) -> rusqlite::Result { }) } -/// Create and persist a node, recording a `node.create` op. +/// Create and persist a node, recording a `node.create` op. A node with a body +/// is seeded into the text CRDT (`body_crdt`) and the seed travels in the op. pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) -> Result { let hlc = next_hlc(conn, now)?; - let node = build(owner, now, &hlc, input.kind, input.title, input.body); + let mut node = build(owner, now, &hlc, input.kind, input.title, input.body); + let write = match node.body.as_deref() { + Some(b) => Some(crdt::write_body(device_client(conn)?, None, b)), + None => None, + }; + if let Some(w) = &write { + node.body = Some(w.body.clone()); + } insert(conn, &node)?; + if let Some(w) = &write { + set_body_crdt(conn, &node.id, &w.state)?; + } ops::record( conn, owner, &node.hlc, op_type::NODE_CREATE, &node.id, - create_payload(&node), + create_payload(&node, write.as_ref().map(|w| w.delta.as_slice())), )?; Ok(node) } +/// Persist a node's CRDT body state. +fn set_body_crdt(conn: &Connection, id: &str, state: &[u8]) -> Result<()> { + conn.execute("UPDATE nodes SET body_crdt = ?1 WHERE id = ?2", (state, id))?; + Ok(()) +} + +/// Rewrite a node's body **locally**, diffing it into the text CRDT so `body` +/// and `body_crdt` stay consistent. Records **no** op: used by recurrence +/// roll-forward, whose checklist reset stays device-local (as it did before the +/// CRDT landed). +pub(super) fn rewrite_body_local( + conn: &Connection, + now: i64, + id: &str, + new_body: &str, +) -> Result<()> { + let prev = get_body_crdt(conn, id)?; + let write = crdt::write_body(device_client(conn)?, prev.as_deref(), new_body); + let hlc = next_hlc(conn, now)?; + conn.execute( + "UPDATE nodes SET body = ?1, body_crdt = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", + (&write.body, &write.state, now, &hlc, id), + )?; + Ok(()) +} + /// Open today's (or `date`'s) journal node, creating it if absent. The id is /// **deterministic** in `(owner, date)` so independent offline creations /// converge (tech-spec §3.1). `date` must be an ISO `YYYY-MM-DD`. @@ -140,14 +202,16 @@ pub(super) fn open_or_create_journal( hlc: next_hlc(conn, now)?, tombstoned: false, }; + let write = crdt::write_body(device_client(conn)?, None, ""); insert(conn, &node)?; + set_body_crdt(conn, &node.id, &write.state)?; ops::record( conn, owner, &node.hlc, op_type::NODE_CREATE, &node.id, - create_payload(&node), + create_payload(&node, Some(&write.delta)), )?; Ok(node) } @@ -202,24 +266,40 @@ pub(super) fn update( let tx = conn.transaction()?; node.hlc = next_hlc(&tx, now)?; - tx.execute( - "UPDATE nodes SET title = ?1, body = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", - ( - &node.title, - &node.body, - node.modified_at, - &node.hlc, - &node.id, - ), - )?; - ops::record( - &tx, - owner, - &node.hlc, - op_type::NODE_SET, - &node.id, - json!({ "title": node.title, "body": node.body }), - )?; + // A body change is diffed into the text CRDT; the resulting yrs delta rides + // the op so peers merge it (no last-writer-wins clobber). Title is a scalar + // and stays LWW. + let write = if body_changed { + let prev = get_body_crdt(&tx, &node.id)?; + let w = crdt::write_body( + device_client(&tx)?, + prev.as_deref(), + node.body.as_deref().unwrap_or(""), + ); + node.body = Some(w.body.clone()); + Some(w) + } else { + None + }; + match &write { + Some(w) => { + tx.execute( + "UPDATE nodes SET title = ?1, body = ?2, body_crdt = ?3, modified_at = ?4, hlc = ?5 WHERE id = ?6", + (&node.title, &node.body, &w.state, node.modified_at, &node.hlc, &node.id), + )?; + } + None => { + tx.execute( + "UPDATE nodes SET title = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", + (&node.title, node.modified_at, &node.hlc, &node.id), + )?; + } + } + let payload = match &write { + Some(w) => json!({ "title": node.title, "body": node.body, "body_crdt": w.delta }), + None => json!({ "title": node.title, "body": node.body }), + }; + ops::record(&tx, owner, &node.hlc, op_type::NODE_SET, &node.id, payload)?; if body_changed { links::sync_wiki_links( &tx, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 771d47a..197c1c0 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -209,11 +209,7 @@ fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Re let body = doc.body.unwrap_or_default(); let reset = recurrence::reset_checkboxes(&body); if reset != body { - let hlc = next_hlc(&tx, now)?; - tx.execute( - "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", - (&reset, now, hlc, &doc_id), - )?; + nodes::rewrite_body_local(&tx, now, &doc_id, &reset)?; links::sync_wiki_links(&tx, owner, &doc_id, &reset, now)?; } } diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs index a445771..aa50b64 100644 --- a/crates/heph-core/tests/convergence.rs +++ b/crates/heph-core/tests/convergence.rs @@ -1,7 +1,8 @@ -//! Op-log + merge convergence (tech-spec §12, slice 8b/8c). Two local replicas -//! (distinct origins) exchange ops; we assert they converge and that ambiguous -//! merges surface as conflicts. Body merge here is LWW; the yrs text CRDT lands -//! in a follow-up. No network yet — we hand ops across directly. +//! Op-log + merge convergence (tech-spec §12, slice 8b–8d). Two local replicas +//! (distinct origins) exchange ops; we assert they converge, that ambiguous +//! scalar merges surface as conflicts, and that concurrent body edits *merge* +//! through the yrs text CRDT instead of clobbering with last-writer-wins. No +//! network yet — we hand ops across directly. use heph_core::{Attention, Clock, LocalStore, NewNode, NewTask, Op, Store, TaskState}; use std::sync::atomic::{AtomicI64, Ordering}; @@ -127,17 +128,23 @@ fn offline_divergent_scalar_edits_converge_with_a_conflict() { } #[test] -fn concurrent_body_edits_converge_lww() { +fn concurrent_body_edits_merge_via_crdt() { + // Both replicas edit *different regions* of a shared body offline. The text + // CRDT merges both edits — neither is lost to last-writer-wins, and no + // conflict is enqueued. let (mut a, ca) = replica(1000); let (mut b, cb) = replica(1000); - let n = a.create_node(NewNode::doc("Note", "base")).unwrap(); + let n = a.create_node(NewNode::doc("Note", "Hello world")).unwrap(); sync_one_way(&a, &mut b, None); + // A inserts in the middle; B appends at the end. ca.set(2000); - a.update_node(&n.id, None, Some("A's edit".into())).unwrap(); + a.update_node(&n.id, None, Some("Hello brave world".into())) + .unwrap(); cb.set(2500); - b.update_node(&n.id, None, Some("B's edit".into())).unwrap(); + b.update_node(&n.id, None, Some("Hello world!".into())) + .unwrap(); sync_one_way(&a, &mut b, None); sync_one_way(&b, &mut a, None); @@ -145,7 +152,11 @@ fn concurrent_body_edits_converge_lww() { let ba = a.get_node(&n.id).unwrap().unwrap().body; let bb = b.get_node(&n.id).unwrap().unwrap().body; assert_eq!(ba, bb, "bodies did not converge"); - assert_eq!(ba.as_deref(), Some("B's edit")); // later HLC wins + assert_eq!(ba.as_deref(), Some("Hello brave world!")); // both edits survive + assert!( + a.conflicts_list().unwrap().is_empty() && b.conflicts_list().unwrap().is_empty(), + "body merges must not enqueue conflicts" + ); } #[test] diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index f1275ba..2ecef02 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -7,4 +7,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging. - `hephd` daemon, local mode (§3, §6): exclusive file lock (handoff-ready), line-delimited JSON-RPC over a unix socket exposing the node/task/next/links/log methods, with DB work on tokio's blocking pool. Synchronous client for surfaces/CLI. Model types are serde-serializable. - `heph` CLI (§1) — a thin client of the daemon: `next`, `task`, `doc`, `get`, `export`. Export materializes the store to a `/.md` tree with YAML frontmatter + body (§5), one-way, tombstones excluded. +- Sync engine, local-only (§12): real hybrid logical clock + persistent device `origin`; an append-only op-log per mutation; an idempotent, order-independent merge/apply engine — last-writer-wins task scalars (discards surfaced in a `conflicts` queue), OR-set links, monotonic tombstones. Two-replica convergence proven. +- Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index e6e7e32..f696f70 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -1,6 +1,6 @@ --- title: Technical Specification -modified: 2026-05-31 +modified: 2026-06-01 tags: - reference - design @@ -326,7 +326,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **102 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **97 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -336,16 +336,16 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Ranking (§7):** pure two-stage filter + reorderable named dimensions; proptest total order. - ✅ **Daemon RPC (§6) — local subset:** node.get/create/update/tombstone, task.create/set_state/set_attention/skip, next, list, health, journal.open_or_create, search, links.outgoing/backlinks, log.append/tail, export, conflicts.list/resolve, sync (ops_since/apply_op). Line-delimited JSON-RPC over a unix socket; sync `Client`. - ✅ **Runtime modes (§3.1) — `local` only:** exclusive file-lock handoff via `LockGuard`. -- ✅ **Sync engine (§12) minus network:** HLC (clock-injected, monotonic) + persistent device `origin`; op-log per mutation; `apply_op` merge — **LWW** scalars/bodies with a **conflict queue**, **OR-set** links, monotonic tombstones, idempotent; two-replica convergence proven. `adopt_owner` = basic §13 canonical-owner adoption. +- ✅ **Sync engine (§12) minus network:** HLC (clock-injected, monotonic) + persistent device `origin`; op-log per mutation; `apply_op` merge — **LWW** task scalars + titles with a **conflict queue**, **OR-set** links, monotonic tombstones, idempotent; two-replica convergence proven. `adopt_owner` = basic §13 canonical-owner adoption. +- ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict. - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **Body CRDT (§5, §12):** replace body LWW with the **yrs** text CRDT — `body_crdt` BLOB, diff whole-body writes into the yrs doc, merge yrs updates in `apply`. (CRDT lib ratified = `yrs`.) -2. ⏳ **`server`/`client` modes + network sync (§3.1, §6.1, §12):** `RemoteStore`; hub network endpoint; push/pull by HLC cursor (the merge logic already exists); background sync; `sync.now`/`sync.status` RPC; multi-replica-over-real-sockets tests. Open: transport (`axum` HTTP/JSON vs gRPC), propagation cadence, device-id/hub registration. -3. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub endpoint, full per-user isolation, adoption-with-deterministic-ids. -4. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). +1. ⏳ **`server`/`client` modes + network sync (§3.1, §6.1, §12):** `RemoteStore`; hub network endpoint; push/pull by HLC cursor (the merge logic already exists); background sync; `sync.now`/`sync.status` RPC; multi-replica-over-real-sockets tests. Open: transport (`axum` HTTP/JSON vs gRPC), propagation cadence, device-id/hub registration. +2. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub endpoint, full per-user isolation, adoption-with-deterministic-ids. +3. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). ## Related -- 2.50.1 (Apple Git-155) From 8c25d114c4294c82503d565446f4d72ea53fdf97 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 15:14:20 -0700 Subject: [PATCH 15/91] =?UTF-8?q?hephd:=20network=20sync=20over=20HTTP=20?= =?UTF-8?q?=E2=80=94=20hub=20+=20spoke=20(sync=209a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing merge engine over the network so the everyday config (local + hub_url) syncs through a hub. Transport ratified = axum HTTP/JSON (tech-spec §6.1, §12). - heph-core: SyncCursors model + Store::sync_state/record_sync over the sync_state table (per-peer push/pull HLC cursors). Incremental, so each exchange transfers only the tail. - hephd::sync: the hub router (POST /sync/push, GET /sync/pull?after=) served from the shared LocalStore, and sync_once — a spoke's pull-then- merge, then push-tail exchange, advancing the cursors. Idempotent: a re-pushed op the hub already has is a no-op. - Daemon carries optional hub config; sync.now/sync.status handled at the daemon (they need the hub transport the store can't reach). conflicts. list/resolve now reachable over the unix socket too. - main: --mode local|server, --hub-url, --http-addr. server mode binds the hub HTTP endpoint on the same store; a local+hub_url spoke background- syncs on a 30s interval. - tests/sync_http.rs: two spokes converge through a real-HTTP hub on an ephemeral port — node propagation and a divergent-scalar conflict. Unauthenticated/single-owner for now; OIDC + per-user scoping is slice 10, client mode + RemoteStore is 9b. 100 tests green; clippy -D warnings + fmt + prek clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 606 +++++++++++++++++++++++ Cargo.toml | 5 + README.md | 9 +- crates/heph-core/src/lib.rs | 2 +- crates/heph-core/src/model.rs | 12 + crates/heph-core/src/sqlite/mod.rs | 18 +- crates/heph-core/src/sqlite/syncstate.rs | 47 ++ crates/heph-core/src/store.rs | 13 +- crates/heph-core/tests/convergence.rs | 30 +- crates/hephd/Cargo.toml | 2 + crates/hephd/src/lib.rs | 2 + crates/hephd/src/main.rs | 79 ++- crates/hephd/src/rpc.rs | 13 + crates/hephd/src/server.rs | 122 ++++- crates/hephd/src/sync.rs | 176 +++++++ crates/hephd/tests/sync_http.rs | 135 +++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 9 +- 18 files changed, 1239 insertions(+), 42 deletions(-) create mode 100644 crates/heph-core/src/sqlite/syncstate.rs create mode 100644 crates/hephd/src/sync.rs create mode 100644 crates/hephd/tests/sync_http.rs diff --git a/Cargo.lock b/Cargo.lock index 2241351..296ab67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,12 +119,76 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -290,6 +354,17 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "errno" version = "0.3.14" @@ -354,6 +429,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs4" version = "0.12.0" @@ -364,6 +448,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -461,9 +554,11 @@ name = "hephd" version = "0.0.0" dependencies = [ "anyhow", + "axum", "clap", "fs4", "heph-core", + "reqwest", "serde", "serde_json", "tempfile", @@ -473,6 +568,95 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -497,6 +681,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -556,6 +849,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -580,12 +879,24 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.1" @@ -655,6 +966,12 @@ dependencies = [ "regex", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "phf" version = "0.11.3" @@ -705,6 +1022,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -865,6 +1191,38 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rrule" version = "0.13.0" @@ -937,6 +1295,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -986,6 +1350,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1038,6 +1425,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1055,6 +1448,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1117,6 +1530,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1143,12 +1566,59 @@ dependencies = [ "syn", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1204,6 +1674,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ulid" version = "1.2.1" @@ -1232,6 +1708,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1265,6 +1759,15 @@ dependencies = [ "libc", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1293,6 +1796,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -1325,6 +1838,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1491,6 +2014,35 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "yrs" version = "0.26.0" @@ -1529,6 +2081,60 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 05610a1..a2a291d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,11 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4", features = ["derive"] } fs4 = "0.12" +axum = "0.8" +reqwest = { version = "0.13", default-features = false, features = [ + "json", + "query", +] } [profile.release] lto = "thin" diff --git a/README.md b/README.md index 51f4dfd..f706e25 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. The **local-only system is feature-complete and the offline-sync merge engine converges** (including a `yrs` text-CRDT for bodies); remaining work is the network transport, auth, and the Neovim plugin. Built test-first (97 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. The **local system is feature-complete and replicas now sync through a hub over HTTP** — the offline-first everyday config (`local` + `hub_url`) converges end-to-end, with a `yrs` text-CRDT merging bodies. Remaining: the online-only `client` mode, auth, and the Neovim plugin. Built test-first (100 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -20,7 +20,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | `heph` CLI; `list` / `health` / `journal` / full-text `search` (FTS5) | ✅ done | | Sync engine — HLC, op-log, converging merge + conflict queue (no network yet) | ✅ done | | yrs text-CRDT for body merge | ✅ done | -| `server`/`client` modes + network push/pull sync | ⏳ next | +| `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done | +| `client` mode + `RemoteStore` (online-only, no replica) | ⏳ next | | OIDC/Authentik auth + per-user isolation | ⏳ | | `heph.nvim` (primary surface) | ⏳ | @@ -29,7 +30,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph-core`** — the library: data model, the `Store` trait + SQLite store, markdown parsing/extraction, recurrence, the "what is next?" engine, and the sync engine (op-log, hybrid logical clocks, CRDT/LWW merge, conflict detection). Synchronous and clock-injected (no ambient wall-clock reads) so ranking and merge are deterministic. -- **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica), **`server`** (also a network endpoint + sync hub), **`client`** (thin, remote) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and (later) background sync. +- **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica; a syncing spoke when given `--hub-url`), **`server`** (also the sync hub: an HTTP endpoint others sync against), **`client`** *(planned)* (thin, remote, no replica) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and background sync. - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). - **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. @@ -82,7 +83,7 @@ mise run ai-docs # docs AI agents read firs ``` ./Cargo.toml # workspace manifest ./crates/heph-core/ # core library: model, store, extraction, recurrence, ranking, sync -./crates/hephd/ # daemon: local mode (JSON-RPC over a unix socket); server/client planned +./crates/hephd/ # daemon: local + server (hub) modes — unix-socket RPC + HTTP sync; client planned ./crates/heph/ # CLI: thin client of the daemon ./heph.nvim/ # Neovim plugin (planned) ./docs/ # Diataxis docs (design, tech-spec, how-to), Quartz config diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 90f71ac..72bb5bc 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -28,7 +28,7 @@ pub use extract::{extract, ContextItem, Extraction}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, - NodeKind, Task, TaskState, + NodeKind, SyncCursors, Task, TaskState, }; pub use oplog::Op; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 879e0cf..f6d7a5e 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -289,6 +289,18 @@ pub struct Conflict { pub created_at: i64, } +/// Per-peer sync cursors (a row of `sync_state`, tech-spec §12). Each is the +/// HLC of the last op exchanged with that peer in each direction, so sync only +/// ever transfers the tail. `None` means "nothing yet" (transfer from the +/// beginning). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncCursors { + /// HLC of the last op we pushed to the peer. + pub last_pushed_hlc: Option, + /// HLC of the last op we pulled from the peer. + pub last_pulled_hlc: Option, +} + /// Deterministic id for key-unique kinds (`journal`/`tag`) so two offline /// replicas that independently create the same logical singleton converge /// (tech-spec §3.1, [[design]] §3.1). Content nodes use random ULIDs instead. diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 93103b4..f09821f 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -16,6 +16,7 @@ mod log; mod migrations; mod nodes; mod ops; +mod syncstate; mod tasks; pub use migrations::latest_version; @@ -29,7 +30,8 @@ use crate::clock::Clock; use crate::error::{Error, Result}; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task, + TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -314,6 +316,20 @@ impl Store for LocalStore { Ok(()) } + fn sync_state(&self, peer: &str) -> Result { + syncstate::get(&self.conn, peer) + } + + fn record_sync( + &mut self, + peer: &str, + pushed: Option<&str>, + pulled: Option<&str>, + ) -> Result<()> { + let now = self.clock.now_ms(); + syncstate::record(&self.conn, peer, pushed, pulled, now) + } + fn conflicts_list(&self) -> Result> { apply::list_conflicts(&self.conn, &self.owner_id) } diff --git a/crates/heph-core/src/sqlite/syncstate.rs b/crates/heph-core/src/sqlite/syncstate.rs new file mode 100644 index 0000000..cd72952 --- /dev/null +++ b/crates/heph-core/src/sqlite/syncstate.rs @@ -0,0 +1,47 @@ +//! `sync_state` table operations — per-peer push/pull HLC cursors (tech-spec +//! §12). A spoke tracks, for each hub it syncs with, the HLC of the last op it +//! pushed and the last it pulled, so each exchange transfers only the tail. + +use rusqlite::{Connection, OptionalExtension}; + +use crate::error::Result; +use crate::model::SyncCursors; + +/// Read the cursors for `peer`, or empty cursors if never synced. +pub(super) fn get(conn: &Connection, peer: &str) -> Result { + let row: Option<(Option, Option)> = conn + .query_row( + "SELECT last_pushed_hlc, last_pulled_hlc FROM sync_state WHERE peer = ?1", + [peer], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + Ok(match row { + Some((last_pushed_hlc, last_pulled_hlc)) => SyncCursors { + last_pushed_hlc, + last_pulled_hlc, + }, + None => SyncCursors::default(), + }) +} + +/// Advance `peer`'s cursors. A `None` direction is left unchanged (COALESCE +/// keeps the prior value on update). Upserts the row. +pub(super) fn record( + conn: &Connection, + peer: &str, + pushed: Option<&str>, + pulled: Option<&str>, + now: i64, +) -> Result<()> { + conn.execute( + "INSERT INTO sync_state (peer, last_pushed_hlc, last_pulled_hlc, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(peer) DO UPDATE SET + last_pushed_hlc = COALESCE(?2, last_pushed_hlc), + last_pulled_hlc = COALESCE(?3, last_pulled_hlc), + updated_at = ?4", + (peer, pushed, pulled, now), + )?; + Ok(()) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index c4aaea6..d1fc704 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -6,7 +6,8 @@ use crate::error::Result; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task, + TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -127,6 +128,16 @@ pub trait Store { /// incl. owner-embedded deterministic ids, is refined with auth.) fn adopt_owner(&mut self, canonical: &str) -> Result<()>; + /// The push/pull HLC cursors for a sync `peer` (the hub url). Defaults to + /// empty cursors when this replica has never synced with `peer` (§12). + fn sync_state(&self, peer: &str) -> Result; + + /// Record progress with a sync `peer`: advance the `pushed`/`pulled` HLC + /// cursors (each `None` leaves that direction unchanged). Upserts the + /// `sync_state` row (§12). + fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>) + -> Result<()>; + /// Open merge conflicts surfaced for the user (`heph conflicts`). fn conflicts_list(&self) -> Result>; diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs index aa50b64..f84af1e 100644 --- a/crates/heph-core/tests/convergence.rs +++ b/crates/heph-core/tests/convergence.rs @@ -4,7 +4,9 @@ //! through the yrs text CRDT instead of clobbering with last-writer-wins. No //! network yet — we hand ops across directly. -use heph_core::{Attention, Clock, LocalStore, NewNode, NewTask, Op, Store, TaskState}; +use heph_core::{ + Attention, Clock, LocalStore, NewNode, NewTask, Op, Store, SyncCursors, TaskState, +}; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; @@ -46,6 +48,32 @@ fn sync_one_way(src: &dyn Store, dst: &mut dyn Store, cursor: Option<&str>) -> O last } +#[test] +fn sync_cursors_default_empty_then_advance_per_direction() { + let (mut a, _ca) = replica(1000); + const HUB: &str = "https://hub.example"; + + assert_eq!(a.sync_state(HUB).unwrap(), SyncCursors::default()); + + // Advancing only the push cursor leaves pull untouched, and vice versa. + a.record_sync(HUB, Some("hlc-push-1"), None).unwrap(); + a.record_sync(HUB, None, Some("hlc-pull-1")).unwrap(); + assert_eq!( + a.sync_state(HUB).unwrap(), + SyncCursors { + last_pushed_hlc: Some("hlc-push-1".into()), + last_pulled_hlc: Some("hlc-pull-1".into()), + } + ); + + // A second peer is tracked independently. + a.record_sync("other", Some("x"), None).unwrap(); + assert_eq!( + a.sync_state(HUB).unwrap().last_pushed_hlc.as_deref(), + Some("hlc-push-1") + ); +} + #[test] fn online_round_trip_propagates_a_node() { let (mut a, _ca) = replica(1000); diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index d9f751c..d15d0b8 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -27,6 +27,8 @@ tracing.workspace = true tracing-subscriber.workspace = true clap.workspace = true fs4.workspace = true +axum.workspace = true +reqwest.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index f54e0f6..85ebbfe 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -12,6 +12,7 @@ pub mod clock; pub mod lock; pub mod rpc; pub mod server; +pub mod sync; use std::path::PathBuf; @@ -19,6 +20,7 @@ pub use client::Client; pub use clock::SystemClock; pub use lock::LockGuard; pub use server::Daemon; +pub use sync::{sync_once, SyncReport}; /// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to /// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3). diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index 5e0c1c1..de54dfb 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -1,18 +1,42 @@ -//! `hephd` binary — starts the daemon in `local` mode (slice 6). +//! `hephd` binary — starts the daemon in `local` or `server` mode. +//! +//! Both modes own the local SQLite file (exclusive lock) and serve surfaces +//! over a unix socket. **server** additionally exposes the hub HTTP endpoint for +//! spokes to sync against; a **local** instance given `--hub-url` becomes a +//! syncing spoke that background-exchanges its op-log with that hub (tech-spec +//! §3.1, §6.1, §12). `client` mode (no local replica) is a later slice. use std::path::PathBuf; +use std::time::Duration; use anyhow::{Context, Result}; -use clap::Parser; -use tokio::net::UnixListener; +use clap::{Parser, ValueEnum}; +use tokio::net::{TcpListener, UnixListener}; use heph_core::LocalStore; -use hephd::{default_db_path, default_socket_path, Daemon, LockGuard, SystemClock}; +use hephd::{default_db_path, default_socket_path, sync, Daemon, LockGuard, SystemClock}; + +/// How often a spoke background-syncs with its hub. +const SYNC_INTERVAL: Duration = Duration::from_secs(30); +/// Default hub HTTP bind address in server mode. +const DEFAULT_HTTP_ADDR: &str = "127.0.0.1:8787"; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +enum Mode { + /// Own replica; no inbound network endpoint (syncing spoke if `--hub-url`). + Local, + /// Also a sync hub: exposes the authenticated network endpoint over HTTP. + Server, +} /// The Hephaestus per-device daemon. #[derive(Parser, Debug)] #[command(name = "hephd", version, about)] struct Cli { + /// Runtime mode. + #[arg(long, value_enum, default_value_t = Mode::Local)] + mode: Mode, + /// Path to the SQLite store file. #[arg(long)] db: Option, @@ -20,6 +44,14 @@ struct Cli { /// Path to the unix socket to listen on. #[arg(long)] socket: Option, + + /// Hub to background-sync this replica's op-log with (makes it a spoke). + #[arg(long)] + hub_url: Option, + + /// Address for the hub HTTP endpoint (server mode only). + #[arg(long)] + http_addr: Option, } #[tokio::main] @@ -47,6 +79,41 @@ async fn main() -> Result<()> { // Take the exclusive lock before opening the store (tech-spec §3.1). let _lock = LockGuard::acquire(&db)?; let store = LocalStore::open(&db, Box::new(SystemClock))?; + let daemon = Daemon::new(store).with_hub(cli.hub_url.clone()); + + // server mode: expose the hub HTTP endpoint over the same store. + if cli.mode == Mode::Server { + let addr = cli + .http_addr + .clone() + .unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string()); + let app = sync::router(daemon.store()); + let listener = TcpListener::bind(&addr) + .await + .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; + tracing::info!(%addr, "hub HTTP endpoint listening"); + tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app).await { + tracing::error!("hub HTTP endpoint stopped: {e}"); + } + }); + } + + // spoke: background-sync the op-log with the configured hub. + if let Some(hub) = cli.hub_url.clone() { + let store = daemon.store(); + tokio::spawn(async move { + let http = reqwest::Client::new(); + let mut tick = tokio::time::interval(SYNC_INTERVAL); + loop { + tick.tick().await; + match hephd::sync_once(store.clone(), &hub, &http).await { + Ok(report) => tracing::debug!(?report, "background sync"), + Err(e) => tracing::warn!("background sync failed: {e}"), + } + } + }); + } // Replace any stale socket from a previous run, then bind. if socket.exists() { @@ -56,6 +123,6 @@ async fn main() -> Result<()> { let listener = UnixListener::bind(&socket) .with_context(|| format!("binding socket {}", socket.display()))?; - tracing::info!(db = %db.display(), socket = %socket.display(), "hephd local mode listening"); - Daemon::new(store).serve(listener).await + tracing::info!(db = %db.display(), socket = %socket.display(), mode = ?cli.mode, "hephd listening"); + daemon.serve(listener).await } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 29c5b3f..d6762b7 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -186,6 +186,13 @@ struct ExportParams { path: String, } +#[derive(Deserialize)] +struct ConflictResolveParams { + id: String, + /// `"local"` or `"remote"` — the value the user chooses to keep. + choice: String, +} + /// Default `next`/`list` result size (tech-spec §6). const DEFAULT_LIMIT: usize = 5; /// Default `log.tail` size. @@ -267,6 +274,12 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!(store.conflicts_list()?), + "conflicts.resolve" => { + let p: ConflictResolveParams = parse(params)?; + store.conflicts_resolve(&p.id, &p.choice)?; + json!({ "ok": true }) + } other => { return Err(RpcError::new( METHOD_NOT_FOUND, diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index f4c81b8..8e158f3 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -4,39 +4,69 @@ //! `heph-core` is synchronous and its SQLite handle is single-writer, so the //! store sits behind an `Arc>`; each request locks it inside a //! `spawn_blocking` task (DB calls never run on an async worker, tech-spec §3). +//! +//! Two methods are handled here rather than in [`rpc::dispatch`] because they +//! need transport the store can't reach: `sync.now` / `sync.status` exchange +//! ops with the configured hub (tech-spec §6.1, §12). use std::sync::{Arc, Mutex}; use anyhow::Result; -use serde_json::Value; +use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; -use heph_core::LocalStore; +use heph_core::{LocalStore, Store}; -use crate::rpc::{self, Request, Response, RpcError, PARSE_ERROR}; +use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; +use crate::sync; + +/// The shared, cheaply-cloneable context each connection serves from. +#[derive(Clone)] +struct Ctx { + store: Arc>, + /// The hub this device syncs with, if it is a spoke (`local` + `hub_url`). + hub_url: Option, + http: reqwest::Client, +} /// A running daemon over a shared local store. pub struct Daemon { - store: Arc>, + ctx: Ctx, } impl Daemon { /// Wrap an opened store. pub fn new(store: LocalStore) -> Daemon { Daemon { - store: Arc::new(Mutex::new(store)), + ctx: Ctx { + store: Arc::new(Mutex::new(store)), + hub_url: None, + http: reqwest::Client::new(), + }, } } + /// Configure the hub this device syncs with (`sync.now` targets it). + pub fn with_hub(mut self, hub_url: Option) -> Daemon { + self.ctx.hub_url = hub_url; + self + } + + /// The shared store handle, for code that needs to reach the same store the + /// daemon serves (the hub HTTP router and background sync, tech-spec §6.1). + pub fn store(&self) -> Arc> { + self.ctx.store.clone() + } + /// Serve connections on `listener` until the task is cancelled. Each /// connection is handled concurrently; all share the one store. pub async fn serve(&self, listener: UnixListener) -> Result<()> { loop { let (stream, _addr) = listener.accept().await?; - let store = self.store.clone(); + let ctx = self.ctx.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream, store).await { + if let Err(e) = handle_connection(stream, ctx).await { tracing::debug!("connection closed: {e}"); } }); @@ -44,7 +74,7 @@ impl Daemon { } } -async fn handle_connection(stream: UnixStream, store: Arc>) -> Result<()> { +async fn handle_connection(stream: UnixStream, ctx: Ctx) -> Result<()> { let (read_half, mut write_half) = stream.into_split(); let mut lines = BufReader::new(read_half).lines(); @@ -52,7 +82,7 @@ async fn handle_connection(stream: UnixStream, store: Arc>) -> if line.trim().is_empty() { continue; } - let response = process_line(&line, &store).await; + let response = process_line(&line, &ctx).await; let mut out = serde_json::to_string(&response)?; out.push('\n'); write_half.write_all(out.as_bytes()).await?; @@ -61,7 +91,7 @@ async fn handle_connection(stream: UnixStream, store: Arc>) -> Ok(()) } -async fn process_line(line: &str, store: &Arc>) -> Response { +async fn process_line(line: &str, ctx: &Ctx) -> Response { let request: Request = match serde_json::from_str(line) { Ok(r) => r, Err(e) => { @@ -76,26 +106,70 @@ async fn process_line(line: &str, store: &Arc>) -> Response { }; let id = request.id.clone(); - let store = store.clone(); - let method = request.method; - let params = request.params; + let result = match request.method.as_str() { + // Sync methods need the hub transport, which the store can't reach. + "sync.now" => sync_now(ctx).await, + "sync.status" => sync_status(ctx).await, + // Everything else is a pure store call on the blocking pool. + _ => dispatch_blocking(ctx, request.method, request.params).await, + }; - // DB work runs on the blocking pool; the store mutex is held only there. + match result { + Ok(value) => Response::ok(id, value), + Err(rpc_err) => Response::failed(id, rpc_err), + } +} + +/// Run a store method on the blocking pool (DB never touches an async worker). +async fn dispatch_blocking(ctx: &Ctx, method: String, params: Value) -> Result { + let store = ctx.store.clone(); let dispatched = tokio::task::spawn_blocking(move || { let mut guard = store.lock().expect("store mutex poisoned"); rpc::dispatch(&mut *guard, &method, params) }) .await; - match dispatched { - Ok(Ok(result)) => Response::ok(id, result), - Ok(Err(rpc_err)) => Response::failed(id, rpc_err), - Err(join_err) => Response::failed( - id, - RpcError { - code: rpc::INTERNAL_ERROR, - message: format!("dispatch task failed: {join_err}"), - }, - ), + Ok(inner) => inner, + Err(join_err) => Err(RpcError { + code: INTERNAL_ERROR, + message: format!("dispatch task failed: {join_err}"), + }), } } + +/// `sync.now` — exchange ops with the configured hub once. +async fn sync_now(ctx: &Ctx) -> Result { + let Some(hub_url) = ctx.hub_url.clone() else { + return Err(RpcError { + code: INTERNAL_ERROR, + message: "no hub_url configured; this instance is standalone".into(), + }); + }; + match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http).await { + Ok(report) => Ok(json!(report)), + Err(e) => Err(RpcError { + code: INTERNAL_ERROR, + message: format!("sync failed: {e}"), + }), + } +} + +/// `sync.status` — the hub url and the current per-hub cursors. +async fn sync_status(ctx: &Ctx) -> Result { + let Some(hub_url) = ctx.hub_url.clone() else { + return Ok(json!({ "hub_url": Value::Null })); + }; + let store = ctx.store.clone(); + let hub = hub_url.clone(); + let cursors = tokio::task::spawn_blocking(move || { + let guard = store.lock().expect("store mutex poisoned"); + guard.sync_state(&hub) + }) + .await + .map_err(|e| RpcError { + code: INTERNAL_ERROR, + message: format!("sync.status task failed: {e}"), + })? + .map_err(RpcError::from)?; + Ok(json!({ "hub_url": hub_url, "cursors": cursors })) +} diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs new file mode 100644 index 0000000..a559ac3 --- /dev/null +++ b/crates/hephd/src/sync.rs @@ -0,0 +1,176 @@ +//! Spoke↔hub op-log sync over HTTP (tech-spec §6.1, §12). +//! +//! The merge engine itself lives in `heph-core` (deterministic, transport-free). +//! This module is the **transport**: a [`router`] the **hub** (server mode) +//! mounts, and [`sync_once`] a **spoke** (`local` + `hub_url`) runs to exchange +//! ops with that hub. Both speak JSON over HTTP with two routes: +//! +//! - `POST /sync/push` — the spoke sends its new ops; the hub merges them. +//! - `GET /sync/pull?after=` — the hub returns ops past the spoke's cursor. +//! +//! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]): +//! each side transfers only the tail it hasn't sent/seen. Merge is idempotent, +//! so a re-pushed op the hub already has is a harmless no-op. Auth is deferred to +//! tech-spec §13 (slice 10) — the endpoint is currently unauthenticated and +//! scoped to the hub's single owner. + +use std::sync::{Arc, Mutex}; + +use anyhow::Result; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +use heph_core::{LocalStore, Op, Store}; + +/// The shared store handle a hub serves from. +type SharedStore = Arc>; + +/// A batch of ops in flight (push body / pull response). +#[derive(Debug, Serialize, Deserialize)] +pub struct OpsBody { + /// The ops, applied in HLC order by the receiver. + pub ops: Vec, +} + +/// What one [`sync_once`] exchange moved. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct SyncReport { + /// Ops received from the hub. + pub pulled: usize, + /// Of the pulled ops, how many were newly applied (not already seen). + pub applied: usize, + /// Ops sent to the hub. + pub pushed: usize, +} + +/// Run `f` against the locked store on the blocking pool (DB calls never run on +/// an async worker, tech-spec §3). +async fn with_store(store: &SharedStore, f: F) -> Result +where + F: FnOnce(&mut LocalStore) -> heph_core::Result + Send + 'static, + T: Send + 'static, +{ + let store = store.clone(); + let out = tokio::task::spawn_blocking(move || { + let mut guard = store.lock().expect("store mutex poisoned"); + f(&mut guard) + }) + .await?; + Ok(out?) +} + +/// Apply a batch of ops in HLC order, returning how many were newly applied and +/// the highest HLC seen (the new cursor position). +fn apply_batch( + store: &mut LocalStore, + mut ops: Vec, +) -> heph_core::Result<(usize, Option)> { + ops.sort_by(|a, b| a.hlc.cmp(&b.hlc)); + let mut applied = 0; + let mut max_hlc = None; + for op in &ops { + if store.apply_op(op)? { + applied += 1; + } + max_hlc = Some(op.hlc.clone()); + } + Ok((applied, max_hlc)) +} + +/// The hub's HTTP router (server mode). Mount it on a TCP listener. +pub fn router(store: SharedStore) -> Router { + Router::new() + .route("/sync/pull", get(pull)) + .route("/sync/push", post(push)) + .with_state(store) +} + +#[derive(Debug, Deserialize)] +struct PullQuery { + /// HLC cursor — return ops strictly newer than this (absent ⇒ from the start). + #[serde(default)] + after: Option, +} + +/// `GET /sync/pull?after=` — ops past the caller's cursor, HLC order. +async fn pull( + State(store): State, + Query(q): Query, +) -> Result, StatusCode> { + let ops = with_store(&store, move |s| s.ops_since(q.after.as_deref())) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(OpsBody { ops })) +} + +/// `POST /sync/push` — merge the caller's ops; reply with how many newly applied. +async fn push( + State(store): State, + Json(body): Json, +) -> Result, StatusCode> { + let (applied, _max) = with_store(&store, move |s| apply_batch(s, body.ops)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(SyncReport { + applied, + ..Default::default() + })) +} + +/// Exchange ops with `hub_url` once: pull new ops and merge them, then push our +/// new ops. Advances the per-hub cursors so the next call transfers only the +/// tail. `http` is a shared [`reqwest::Client`]. +pub async fn sync_once( + store: SharedStore, + hub_url: &str, + http: &reqwest::Client, +) -> Result { + let base = hub_url.trim_end_matches('/'); + let mut report = SyncReport::default(); + + let cursors = { + let hub = hub_url.to_string(); + with_store(&store, move |s| s.sync_state(&hub)).await? + }; + + // --- pull then merge --- + let mut req = http.get(format!("{base}/sync/pull")); + if let Some(after) = &cursors.last_pulled_hlc { + req = req.query(&[("after", after)]); + } + let pulled: OpsBody = req.send().await?.error_for_status()?.json().await?; + report.pulled = pulled.ops.len(); + if !pulled.ops.is_empty() { + let (applied, max_pulled) = with_store(&store, move |s| apply_batch(s, pulled.ops)).await?; + report.applied = applied; + if let Some(cursor) = max_pulled { + let hub = hub_url.to_string(); + with_store(&store, move |s| s.record_sync(&hub, None, Some(&cursor))).await?; + } + } + + // --- push our tail --- + let to_push = { + let after = cursors.last_pushed_hlc.clone(); + with_store(&store, move |s| s.ops_since(after.as_deref())).await? + }; + report.pushed = to_push.len(); + if !to_push.is_empty() { + // `ops_since` returns HLC order, so the last is the new cursor. + let max_pushed = to_push.last().map(|o| o.hlc.clone()); + http.post(format!("{base}/sync/push")) + .json(&OpsBody { ops: to_push }) + .send() + .await? + .error_for_status()?; + if let Some(cursor) = max_pushed { + let hub = hub_url.to_string(); + with_store(&store, move |s| s.record_sync(&hub, Some(&cursor), None)).await?; + } + } + + Ok(report) +} diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs new file mode 100644 index 0000000..de4194e --- /dev/null +++ b/crates/hephd/tests/sync_http.rs @@ -0,0 +1,135 @@ +//! Network sync over real HTTP (tech-spec §6.1, §12, slice 9a). A hub (the +//! `sync::router`) runs on an ephemeral TCP port; two spoke replicas exchange +//! ops with it via `sync_once` and converge — exactly the offline-first +//! everyday config (`local` + `hub_url`). The merge logic is `heph-core`'s, +//! proven in its own convergence tests; here we prove the transport carries it. + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; + +use heph_core::{Attention, Clock, LocalStore, NewNode, NewTask, Store, TaskState}; +use hephd::sync; + +/// Every replica + the hub adopt this one canonical owner (tech-spec §13). +const OWNER: &str = "canonical-user"; + +#[derive(Clone)] +struct StepClock(Arc); +impl StepClock { + fn new(ms: i64) -> Self { + StepClock(Arc::new(AtomicI64::new(ms))) + } + fn set(&self, ms: i64) { + self.0.store(ms, Ordering::SeqCst); + } +} +impl Clock for StepClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +type Shared = Arc>; + +/// A replica backed by a temp SQLite file, sharing the canonical owner. The +/// `TempDir` is returned so the caller keeps the file alive. +fn replica(now: i64) -> (Shared, StepClock, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let clock = StepClock::new(now); + let mut store = LocalStore::open(dir.path().join("heph.db"), Box::new(clock.clone())).unwrap(); + store.adopt_owner(OWNER).unwrap(); + (Arc::new(Mutex::new(store)), clock, dir) +} + +/// Start the hub router on an ephemeral port; return its base URL. The serve +/// task and the hub's `TempDir` are leaked for the test's lifetime. +async fn start_hub() -> String { + let (hub, _clock, dir) = replica(1000); + Box::leak(Box::new(dir)); // keep the hub DB file alive + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = sync::router(hub); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn a_node_propagates_a_to_hub_to_b() { + let hub_url = start_hub().await; + let http = reqwest::Client::new(); + let (a, _ca, _da) = replica(1000); + let (b, _cb, _db) = replica(1000); + + let id = { + let mut ga = a.lock().unwrap(); + ga.create_node(NewNode::doc("Roof", "shingles need work")) + .unwrap() + .id + }; + + // A pushes to the hub; B pulls from it. + let up = sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); + assert!(up.pushed > 0, "A pushed nothing"); + let down = sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + assert!(down.applied > 0, "B applied nothing"); + + let on_b = b.lock().unwrap().get_node(&id).unwrap().expect("reached B"); + assert_eq!(on_b.title, "Roof"); + assert_eq!(on_b.body.as_deref(), Some("shingles need work")); +} + +#[tokio::test] +async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { + let hub_url = start_hub().await; + let http = reqwest::Client::new(); + let (a, ca, _da) = replica(1000); + let (b, cb, _db) = replica(1000); + + // A creates a task and both replicas learn it through the hub. + let task_id = { + let mut ga = a.lock().unwrap(); + ga.create_task(NewTask { + title: "Renew passport".into(), + attention: Some(Attention::Orange), + ..Default::default() + }) + .unwrap() + .node_id + }; + sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); + sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + + // Divergent offline edits on conflict-tracked fields; B's is later (higher + // HLC) so its whole scalar snapshot wins. + ca.set(2000); + a.lock() + .unwrap() + .set_task_state(&task_id, TaskState::Done) + .unwrap(); + cb.set(3000); + b.lock() + .unwrap() + .set_task_attention(&task_id, Attention::Red) + .unwrap(); + + // A few exchanges in each direction settle it. + for _ in 0..2 { + sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); + sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + } + + let ta = a.lock().unwrap().get_task(&task_id).unwrap().unwrap(); + let tb = b.lock().unwrap().get_task(&task_id).unwrap().unwrap(); + assert_eq!(ta, tb, "replicas did not converge: {ta:?} vs {tb:?}"); + assert_eq!(ta.attention, Some(Attention::Red), "later HLC should win"); + assert!( + !a.lock().unwrap().conflicts_list().unwrap().is_empty(), + "A recorded no conflict" + ); + assert!( + !b.lock().unwrap().conflicts_list().unwrap().is_empty(), + "B recorded no conflict" + ); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 2ecef02..92cbacb 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -9,4 +9,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph` CLI (§1) — a thin client of the daemon: `next`, `task`, `doc`, `get`, `export`. Export materializes the store to a `/.md` tree with YAML frontmatter + body (§5), one-way, tombstones excluded. - Sync engine, local-only (§12): real hybrid logical clock + persistent device `origin`; an append-only op-log per mutation; an idempotent, order-independent merge/apply engine — last-writer-wins task scalars (discards surfaced in a `conflicts` queue), OR-set links, monotonic tombstones. Two-replica convergence proven. - Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. +- Network sync over HTTP (§6.1, §12, slice 9a): `hephd --mode server` exposes a sync hub (`POST /sync/push`, `GET /sync/pull?after=`, axum) over the same store; `hephd --mode local --hub-url ` becomes a spoke that background-syncs its op-log with that hub (and on demand via the `sync.now`/`sync.status` RPC). Exchange is incremental by HLC cursor (`sync_state`) and idempotent. The merge engine is `heph-core`'s, unchanged. Unauthenticated/single-owner for now (auth lands with OIDC). `conflicts.list`/`conflicts.resolve` are now reachable over the daemon socket. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index f696f70..7c6eaef 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -326,7 +326,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **97 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **100 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -334,16 +334,17 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Markdown handling (§5):** wiki-link + checkbox extraction (pure, idempotent, code-aware); `update_node` materializes/reconciles `wiki` links; `export` to a `/.md` tree. - ✅ **Recurrence (§4.4):** roll-forward in place — fresh checklist, logged occurrence, advance-skipping-misses; completion never carries forward (proptest). Per-task logs; `skip`. - ✅ **Ranking (§7):** pure two-stage filter + reorderable named dimensions; proptest total order. -- ✅ **Daemon RPC (§6) — local subset:** node.get/create/update/tombstone, task.create/set_state/set_attention/skip, next, list, health, journal.open_or_create, search, links.outgoing/backlinks, log.append/tail, export, conflicts.list/resolve, sync (ops_since/apply_op). Line-delimited JSON-RPC over a unix socket; sync `Client`. -- ✅ **Runtime modes (§3.1) — `local` only:** exclusive file-lock handoff via `LockGuard`. +- ✅ **Daemon RPC (§6):** node.get/create/update/tombstone, task.create/set_state/set_attention/skip, next, list, health, journal.open_or_create, search, links.outgoing/backlinks, log.append/tail, export, conflicts.list/resolve, sync.now/sync.status. Line-delimited JSON-RPC over a unix socket; sync `Client`. (`ops_since`/`apply_op` are `Store` methods exchanged over the hub HTTP endpoint, not the unix socket.) +- ✅ **Runtime modes (§3.1) — `local` + `server`:** exclusive file-lock handoff via `LockGuard`; `--mode local|server`, `--hub-url`, `--http-addr`. `client` (no replica) is a later slice. - ✅ **Sync engine (§12) minus network:** HLC (clock-injected, monotonic) + persistent device `origin`; op-log per mutation; `apply_op` merge — **LWW** task scalars + titles with a **conflict queue**, **OR-set** links, monotonic tombstones, idempotent; two-replica convergence proven. `adopt_owner` = basic §13 canonical-owner adoption. - ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict. +- ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10). - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **`server`/`client` modes + network sync (§3.1, §6.1, §12):** `RemoteStore`; hub network endpoint; push/pull by HLC cursor (the merge logic already exists); background sync; `sync.now`/`sync.status` RPC; multi-replica-over-real-sockets tests. Open: transport (`axum` HTTP/JSON vs gRPC), propagation cadence, device-id/hub registration. +1. ⏳ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica spoke that proxies every `Store` method to a `server` over HTTP — the online-only escape hatch (borrowed box, CI, future web backend). Open: how the proxy maps the mutating `Store` surface; `sync.now` is N/A for `client`. 2. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub endpoint, full per-user isolation, adoption-with-deterministic-ids. 3. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). -- 2.50.1 (Apple Git-155) From 5d54e913c23bee92871cf5938bdb8223e9844ae1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 15:29:28 -0700 Subject: [PATCH 16/91] hephd: client mode + RemoteStore (sync 9b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the online-only escape hatch — a no-replica daemon that proxies every Store call to a server over HTTP (tech-spec §3.1). - Daemon is now generic over the backing store (Arc>), so the same unix-socket surface fronts either a LocalStore (local/server) or a RemoteStore (client). sync::router/sync_once and the Ctx follow suit. - New POST /rpc route on the hub router runs the full rpc::dispatch over HTTP (result-xor-error body, always 200). dispatch gains task.get and links.add so the proxied API is complete. - RemoteStore (hephd): implements heph_core::Store by forwarding each call to /rpc via a blocking reqwest client (Store is sync; the daemon only calls it from the blocking pool). Error::Remote for transport failures; NOT_FOUND is preserved as Error::NodeNotFound. Sync primitives are stubbed (a client keeps no op-log). - main: --mode client + --server-url; client skips the file lock and opens no LocalStore. - tests/client_mode.rs: a RemoteStore drives node/task/search/list/health against a real HTTP server, and not-found maps back correctly. 102 tests green; clippy -D warnings + fmt + prek clean. Next: OIDC auth. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 18 ++ Cargo.toml | 1 + README.md | 10 +- crates/heph-core/src/error.rs | 5 + crates/hephd/src/lib.rs | 2 + crates/hephd/src/main.rs | 111 +++++++----- crates/hephd/src/remote.rs | 217 +++++++++++++++++++++++ crates/hephd/src/rpc.rs | 17 +- crates/hephd/src/server.rs | 14 +- crates/hephd/src/sync.rs | 51 +++++- crates/hephd/tests/client_mode.rs | 97 ++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 10 +- 13 files changed, 486 insertions(+), 68 deletions(-) create mode 100644 crates/hephd/src/remote.rs create mode 100644 crates/hephd/tests/client_mode.rs diff --git a/Cargo.lock b/Cargo.lock index 296ab67..c901551 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -463,6 +464,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -476,7 +489,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1199,7 +1215,9 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index a2a291d..c9a922c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ axum = "0.8" reqwest = { version = "0.13", default-features = false, features = [ "json", "query", + "blocking", ] } [profile.release] diff --git a/README.md b/README.md index f706e25..f8ba65f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. The **local system is feature-complete and replicas now sync through a hub over HTTP** — the offline-first everyday config (`local` + `hub_url`) converges end-to-end, with a `yrs` text-CRDT merging bodies. Remaining: the online-only `client` mode, auth, and the Neovim plugin. Built test-first (100 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes are implemented and replicas sync through a hub over HTTP** — the offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies, and `client` mode proxies to a server with no local replica. Remaining: auth and the Neovim plugin. Built test-first (102 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -21,8 +21,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | Sync engine — HLC, op-log, converging merge + conflict queue (no network yet) | ✅ done | | yrs text-CRDT for body merge | ✅ done | | `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done | -| `client` mode + `RemoteStore` (online-only, no replica) | ⏳ next | -| OIDC/Authentik auth + per-user isolation | ⏳ | +| `client` mode + `RemoteStore` (online-only, no replica) | ✅ done | +| OIDC/Authentik auth + per-user isolation | ⏳ next | | `heph.nvim` (primary surface) | ⏳ | ## Architecture @@ -30,7 +30,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph-core`** — the library: data model, the `Store` trait + SQLite store, markdown parsing/extraction, recurrence, the "what is next?" engine, and the sync engine (op-log, hybrid logical clocks, CRDT/LWW merge, conflict detection). Synchronous and clock-injected (no ambient wall-clock reads) so ranking and merge are deterministic. -- **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica; a syncing spoke when given `--hub-url`), **`server`** (also the sync hub: an HTTP endpoint others sync against), **`client`** *(planned)* (thin, remote, no replica) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and background sync. +- **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica; a syncing spoke when given `--hub-url`), **`server`** (also the sync hub: an HTTP endpoint others sync against), **`client`** (thin, remote, no replica — proxies to a `--server-url`) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and background sync. - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). - **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. @@ -83,7 +83,7 @@ mise run ai-docs # docs AI agents read firs ``` ./Cargo.toml # workspace manifest ./crates/heph-core/ # core library: model, store, extraction, recurrence, ranking, sync -./crates/hephd/ # daemon: local + server (hub) modes — unix-socket RPC + HTTP sync; client planned +./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc ./crates/heph/ # CLI: thin client of the daemon ./heph.nvim/ # Neovim plugin (planned) ./docs/ # Diataxis docs (design, tech-spec, how-to), Quartz config diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs index 79cf597..d0c3eb5 100644 --- a/crates/heph-core/src/error.rs +++ b/crates/heph-core/src/error.rs @@ -22,6 +22,11 @@ pub enum Error { /// A value in the database did not match the expected shape. #[error("data integrity: {0}")] Integrity(String), + + /// A remote backend (a `RemoteStore` in `client` mode) failed or returned an + /// error response (tech-spec §3.1). + #[error("remote: {0}")] + Remote(String), } /// Convenience result alias. diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 85ebbfe..c665ecd 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -10,6 +10,7 @@ pub mod client; pub mod clock; pub mod lock; +pub mod remote; pub mod rpc; pub mod server; pub mod sync; @@ -19,6 +20,7 @@ use std::path::PathBuf; pub use client::Client; pub use clock::SystemClock; pub use lock::LockGuard; +pub use remote::RemoteStore; pub use server::Daemon; pub use sync::{sync_once, SyncReport}; diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index de54dfb..781009a 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -14,7 +14,9 @@ use clap::{Parser, ValueEnum}; use tokio::net::{TcpListener, UnixListener}; use heph_core::LocalStore; -use hephd::{default_db_path, default_socket_path, sync, Daemon, LockGuard, SystemClock}; +use hephd::{ + default_db_path, default_socket_path, sync, Daemon, LockGuard, RemoteStore, SystemClock, +}; /// How often a spoke background-syncs with its hub. const SYNC_INTERVAL: Duration = Duration::from_secs(30); @@ -27,6 +29,8 @@ enum Mode { Local, /// Also a sync hub: exposes the authenticated network endpoint over HTTP. Server, + /// No local replica; proxy every call to a `--server-url` (online-only). + Client, } /// The Hephaestus per-device daemon. @@ -52,6 +56,10 @@ struct Cli { /// Address for the hub HTTP endpoint (server mode only). #[arg(long)] http_addr: Option, + + /// Server to proxy to (client mode only; required there). + #[arg(long)] + server_url: Option, } #[tokio::main] @@ -64,56 +72,71 @@ async fn main() -> Result<()> { .init(); let cli = Cli::parse(); - let db = cli.db.unwrap_or_else(default_db_path); - let socket = cli.socket.unwrap_or_else(default_socket_path); - - if let Some(parent) = db.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("creating store dir {}", parent.display()))?; - } + let socket = cli.socket.clone().unwrap_or_else(default_socket_path); if let Some(parent) = socket.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("creating socket dir {}", parent.display()))?; } - // Take the exclusive lock before opening the store (tech-spec §3.1). - let _lock = LockGuard::acquire(&db)?; - let store = LocalStore::open(&db, Box::new(SystemClock))?; - let daemon = Daemon::new(store).with_hub(cli.hub_url.clone()); - - // server mode: expose the hub HTTP endpoint over the same store. - if cli.mode == Mode::Server { - let addr = cli - .http_addr - .clone() - .unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string()); - let app = sync::router(daemon.store()); - let listener = TcpListener::bind(&addr) - .await - .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; - tracing::info!(%addr, "hub HTTP endpoint listening"); - tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app).await { - tracing::error!("hub HTTP endpoint stopped: {e}"); + // Build the daemon for the chosen mode. `local`/`server` own the file (and + // hold its lock for the process's life); `client` keeps no replica. + let (_lock, daemon) = match cli.mode { + Mode::Client => { + let server_url = cli + .server_url + .clone() + .context("client mode requires --server-url")?; + tracing::info!(%server_url, "client mode: proxying to server (no local replica)"); + (None, Daemon::new(RemoteStore::new(&server_url))) + } + Mode::Local | Mode::Server => { + let db = cli.db.clone().unwrap_or_else(default_db_path); + if let Some(parent) = db.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating store dir {}", parent.display()))?; } - }); - } + // Take the exclusive lock before opening the store (tech-spec §3.1). + let lock = LockGuard::acquire(&db)?; + let store = LocalStore::open(&db, Box::new(SystemClock))?; + let daemon = Daemon::new(store).with_hub(cli.hub_url.clone()); - // spoke: background-sync the op-log with the configured hub. - if let Some(hub) = cli.hub_url.clone() { - let store = daemon.store(); - tokio::spawn(async move { - let http = reqwest::Client::new(); - let mut tick = tokio::time::interval(SYNC_INTERVAL); - loop { - tick.tick().await; - match hephd::sync_once(store.clone(), &hub, &http).await { - Ok(report) => tracing::debug!(?report, "background sync"), - Err(e) => tracing::warn!("background sync failed: {e}"), - } + // server mode: expose the hub HTTP endpoint over the same store. + if cli.mode == Mode::Server { + let addr = cli + .http_addr + .clone() + .unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string()); + let app = sync::router(daemon.store()); + let http_listener = TcpListener::bind(&addr) + .await + .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; + tracing::info!(%addr, "hub HTTP endpoint listening"); + tokio::spawn(async move { + if let Err(e) = axum::serve(http_listener, app).await { + tracing::error!("hub HTTP endpoint stopped: {e}"); + } + }); } - }); - } + + // spoke: background-sync the op-log with the configured hub. + if let Some(hub) = cli.hub_url.clone() { + let store = daemon.store(); + tokio::spawn(async move { + let http = reqwest::Client::new(); + let mut tick = tokio::time::interval(SYNC_INTERVAL); + loop { + tick.tick().await; + match hephd::sync_once(store.clone(), &hub, &http).await { + Ok(report) => tracing::debug!(?report, "background sync"), + Err(e) => tracing::warn!("background sync failed: {e}"), + } + } + }); + } + + (Some(lock), daemon) + } + }; // Replace any stale socket from a previous run, then bind. if socket.exists() { @@ -123,6 +146,6 @@ async fn main() -> Result<()> { let listener = UnixListener::bind(&socket) .with_context(|| format!("binding socket {}", socket.display()))?; - tracing::info!(db = %db.display(), socket = %socket.display(), mode = ?cli.mode, "hephd listening"); + tracing::info!(socket = %socket.display(), mode = ?cli.mode, "hephd listening"); daemon.serve(listener).await } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs new file mode 100644 index 0000000..db5dfea --- /dev/null +++ b/crates/hephd/src/remote.rs @@ -0,0 +1,217 @@ +//! `RemoteStore` — the `client`-mode backend (tech-spec §3.1). +//! +//! A `client` keeps **no local replica**: it proxies every [`Store`] call to a +//! `server`'s `POST /rpc` endpoint (the same [`crate::rpc::dispatch`] the unix +//! socket runs). Since `Store` is synchronous, this uses a **blocking** reqwest +//! client; the daemon only ever calls store methods from its blocking pool, so +//! that is safe. +//! +//! With no op-log of its own, the sync primitives (`ops_since`/`apply_op`/ +//! `sync_state`/`record_sync`) are not meaningful here — a `client` never +//! background-syncs, it reads and writes the hub live. They are stubbed +//! accordingly; the daemon never invokes them in this mode. + +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +use heph_core::{ + Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, Store, + SyncCursors, Task, TaskState, +}; + +use crate::rpc::{Response, NOT_FOUND}; + +/// A no-replica store that proxies to a `server` over HTTP. +pub struct RemoteStore { + base: String, + http: reqwest::blocking::Client, +} + +impl RemoteStore { + /// Point a client at `server_url` (e.g. `http://hub.example:8787`). + pub fn new(server_url: &str) -> RemoteStore { + RemoteStore { + base: server_url.trim_end_matches('/').to_string(), + http: reqwest::blocking::Client::new(), + } + } + + /// Issue one `/rpc` call, returning the raw `result` value. + fn call(&self, method: &str, params: Value) -> Result { + let response: Response = self + .http + .post(format!("{}/rpc", self.base)) + .json(&json!({ "method": method, "params": params })) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|e| Error::Remote(e.to_string()))? + .json() + .map_err(|e| Error::Remote(e.to_string()))?; + if let Some(err) = response.error { + // Preserve "not found" so callers keep the typed contract. + return Err(if err.code == NOT_FOUND { + Error::NodeNotFound(err.message) + } else { + Error::Remote(err.message) + }); + } + Ok(response.result.unwrap_or(Value::Null)) + } + + /// Call `method` and decode the result into `T`. + fn call_as(&self, method: &str, params: Value) -> Result { + let value = self.call(method, params)?; + serde_json::from_value(value).map_err(|e| Error::Remote(e.to_string())) + } +} + +impl Store for RemoteStore { + fn create_node(&mut self, input: NewNode) -> Result { + self.call_as("node.create", json!(input)) + } + + fn get_node(&self, id: &str) -> Result> { + self.call_as("node.get", json!({ "id": id })) + } + + fn update_node( + &mut self, + id: &str, + title: Option, + body: Option, + ) -> Result { + self.call_as( + "node.update", + json!({ "id": id, "title": title, "body": body }), + ) + } + + fn tombstone_node(&mut self, id: &str) -> Result<()> { + self.call("node.tombstone", json!({ "id": id })).map(|_| ()) + } + + fn create_task(&mut self, input: NewTask) -> Result { + self.call_as("task.create", json!(input)) + } + + fn get_task(&self, node_id: &str) -> Result> { + self.call_as("task.get", json!({ "id": node_id })) + } + + fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result { + self.call_as("task.set_state", json!({ "id": node_id, "state": state })) + } + + fn skip_recurrence(&mut self, node_id: &str) -> Result { + self.call_as("task.skip", json!({ "id": node_id })) + } + + fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result { + self.call_as( + "task.set_attention", + json!({ "id": node_id, "attention": attention }), + ) + } + + fn next(&self, scope: Option<&str>, limit: usize) -> Result> { + self.call_as("next", json!({ "scope": scope, "limit": limit })) + } + + fn list( + &self, + scope: Option<&str>, + attention: Option, + include_blue: bool, + ) -> Result> { + self.call_as( + "list", + json!({ "scope": scope, "attention": attention, "include_blue": include_blue }), + ) + } + + fn health(&self) -> Result { + self.call_as("health", json!({})) + } + + fn search(&self, query: &str) -> Result> { + self.call_as("search", json!({ "query": query })) + } + + fn journal_open_or_create(&mut self, date: &str) -> Result { + self.call_as("journal.open_or_create", json!({ "date": date })) + } + + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { + self.call_as( + "links.add", + json!({ "src": src_id, "dst": dst_id, "link_type": link_type }), + ) + } + + fn outgoing_links(&self, id: &str) -> Result> { + self.call_as("links.outgoing", json!({ "id": id })) + } + + fn backlinks(&self, id: &str) -> Result> { + self.call_as("links.backlinks", json!({ "id": id })) + } + + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { + self.call("log.append", json!({ "task_id": task_id, "text": text })) + .map(|_| ()) + } + + fn log_tail(&self, task_id: &str, n: usize) -> Result> { + self.call_as("log.tail", json!({ "task_id": task_id, "n": n })) + } + + fn export(&self, dir: &std::path::Path) -> Result { + // Export runs server-side, writing under `dir` on the server's host. + let out: Value = self.call("export", json!({ "path": dir.to_string_lossy() }))?; + out.get("count") + .and_then(Value::as_u64) + .map(|n| n as usize) + .ok_or_else(|| Error::Remote("export: missing count".into())) + } + + // --- sync primitives: not meaningful for a no-replica client --- + + fn ops_since(&self, _after: Option<&str>) -> Result> { + Err(Error::Remote( + "ops_since is unsupported in client mode".into(), + )) + } + + fn apply_op(&mut self, _op: &heph_core::Op) -> Result { + Err(Error::Remote( + "apply_op is unsupported in client mode".into(), + )) + } + + fn adopt_owner(&mut self, _canonical: &str) -> Result<()> { + // The server owns the data; a client has nothing local to re-own. + Ok(()) + } + + fn sync_state(&self, _peer: &str) -> Result { + Ok(SyncCursors::default()) + } + + fn record_sync( + &mut self, + _peer: &str, + _pushed: Option<&str>, + _pulled: Option<&str>, + ) -> Result<()> { + Ok(()) + } + + fn conflicts_list(&self) -> Result> { + self.call_as("conflicts.list", json!({})) + } + + fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()> { + self.call("conflicts.resolve", json!({ "id": id, "choice": choice })) + .map(|_| ()) + } +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index d6762b7..e1ca489 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, NewNode, NewTask, Store, TaskState}; +use heph_core::{Attention, LinkType, NewNode, NewTask, Store, TaskState}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -168,6 +168,13 @@ struct LinkParams { id: String, } +#[derive(Deserialize)] +struct AddLinkParams { + src: String, + dst: String, + link_type: LinkType, +} + #[derive(Deserialize)] struct LogAppendParams { task_id: String, @@ -223,6 +230,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: IdParam = parse(params)?; + json!(store.get_task(&p.id)?) + } "task.set_state" => { let p: SetStateParams = parse(params)?; json!(store.set_task_state(&p.id, p.state)?) @@ -252,6 +263,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: AddLinkParams = parse(params)?; + json!(store.add_link(&p.src, &p.dst, p.link_type)?) + } "links.outgoing" => { let p: LinkParams = parse(params)?; json!(store.outgoing_links(&p.id)?) diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 8e158f3..055b9a0 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -16,28 +16,28 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; -use heph_core::{LocalStore, Store}; +use heph_core::Store; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; -use crate::sync; +use crate::sync::{self, SharedStore}; /// The shared, cheaply-cloneable context each connection serves from. #[derive(Clone)] struct Ctx { - store: Arc>, + store: SharedStore, /// The hub this device syncs with, if it is a spoke (`local` + `hub_url`). hub_url: Option, http: reqwest::Client, } -/// A running daemon over a shared local store. +/// A running daemon over a shared store (any [`Store`] backend). pub struct Daemon { ctx: Ctx, } impl Daemon { - /// Wrap an opened store. - pub fn new(store: LocalStore) -> Daemon { + /// Wrap an opened store (a `LocalStore`, or a `client`-mode `RemoteStore`). + pub fn new(store: S) -> Daemon { Daemon { ctx: Ctx { store: Arc::new(Mutex::new(store)), @@ -55,7 +55,7 @@ impl Daemon { /// The shared store handle, for code that needs to reach the same store the /// daemon serves (the hub HTTP router and background sync, tech-spec §6.1). - pub fn store(&self) -> Arc> { + pub fn store(&self) -> SharedStore { self.ctx.store.clone() } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index a559ac3..bbbbb27 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -7,6 +7,8 @@ //! //! - `POST /sync/push` — the spoke sends its new ops; the hub merges them. //! - `GET /sync/pull?after=` — the hub returns ops past the spoke's cursor. +//! - `POST /rpc` — the full daemon API ([`crate::rpc::dispatch`]) over HTTP, for +//! a no-replica `client`-mode [`crate::remote::RemoteStore`] to proxy against. //! //! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]): //! each side transfers only the tail it hasn't sent/seen. Merge is idempotent, @@ -22,11 +24,15 @@ use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; +use serde_json::Value; -use heph_core::{LocalStore, Op, Store}; +use heph_core::{Op, Store}; -/// The shared store handle a hub serves from. -type SharedStore = Arc>; +use crate::rpc::{self, Response, RpcError, INTERNAL_ERROR}; + +/// The shared store a hub serves from — any [`Store`], so a `server` fronts a +/// `LocalStore` and (later modes) could front another backend. +pub type SharedStore = Arc>; /// A batch of ops in flight (push body / pull response). #[derive(Debug, Serialize, Deserialize)] @@ -50,13 +56,13 @@ pub struct SyncReport { /// an async worker, tech-spec §3). async fn with_store(store: &SharedStore, f: F) -> Result where - F: FnOnce(&mut LocalStore) -> heph_core::Result + Send + 'static, + F: FnOnce(&mut (dyn Store + Send)) -> heph_core::Result + Send + 'static, T: Send + 'static, { let store = store.clone(); let out = tokio::task::spawn_blocking(move || { let mut guard = store.lock().expect("store mutex poisoned"); - f(&mut guard) + f(&mut *guard) }) .await?; Ok(out?) @@ -65,7 +71,7 @@ where /// Apply a batch of ops in HLC order, returning how many were newly applied and /// the highest HLC seen (the new cursor position). fn apply_batch( - store: &mut LocalStore, + store: &mut (dyn Store + Send), mut ops: Vec, ) -> heph_core::Result<(usize, Option)> { ops.sort_by(|a, b| a.hlc.cmp(&b.hlc)); @@ -85,9 +91,42 @@ pub fn router(store: SharedStore) -> Router { Router::new() .route("/sync/pull", get(pull)) .route("/sync/push", post(push)) + .route("/rpc", post(rpc_call)) .with_state(store) } +/// One `POST /rpc` call: a method name + params, mirroring the unix-socket RPC. +#[derive(Debug, Deserialize)] +struct RpcCall { + method: String, + #[serde(default)] + params: Value, +} + +/// `POST /rpc` — run one [`rpc::dispatch`] call on the hub's store and return a +/// JSON-RPC-shaped [`Response`] (result xor error). Always HTTP 200; method +/// failures travel in the body so the client can reconstruct the error. +async fn rpc_call(State(store): State, Json(call): Json) -> Json { + let store = store.clone(); + let dispatched = tokio::task::spawn_blocking(move || { + let mut guard = store.lock().expect("store mutex poisoned"); + rpc::dispatch(&mut *guard, &call.method, call.params) + }) + .await; + let response = match dispatched { + Ok(Ok(value)) => Response::ok(Value::Null, value), + Ok(Err(rpc_err)) => Response::failed(Value::Null, rpc_err), + Err(join_err) => Response::failed( + Value::Null, + RpcError { + code: INTERNAL_ERROR, + message: format!("dispatch task failed: {join_err}"), + }, + ), + }; + Json(response) +} + #[derive(Debug, Deserialize)] struct PullQuery { /// HLC cursor — return ops strictly newer than this (absent ⇒ from the start). diff --git a/crates/hephd/tests/client_mode.rs b/crates/hephd/tests/client_mode.rs new file mode 100644 index 0000000..f79b91d --- /dev/null +++ b/crates/hephd/tests/client_mode.rs @@ -0,0 +1,97 @@ +//! Client mode over real HTTP (tech-spec §3.1, slice 9b). A server runs the hub +//! router (which includes `/rpc`) over a temp `LocalStore`; a `RemoteStore` +//! proxies the `Store` API to it and we assert the calls land on the server's +//! store. The server runs on its own runtime thread, so the test thread can use +//! the blocking client without nesting runtimes. + +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use heph_core::{Attention, Error, FixedClock, LocalStore, NewNode, NewTask, Store}; +use hephd::sync::{self, SharedStore}; +use hephd::RemoteStore; + +const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z + +/// Start the hub router over a temp `LocalStore` on an ephemeral port; return +/// its base URL. The server thread + temp dir live for the test's duration. +fn start_server() -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let dir = tempfile::tempdir().unwrap(); + let store = + LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); + let shared: SharedStore = Arc::new(Mutex::new(store)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + tx.send(listener.local_addr().unwrap()).unwrap(); + let _keep = dir; // keep the temp DB alive while we serve + axum::serve(listener, sync::router(shared)).await.unwrap(); + }); + }); + let addr = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + format!("http://{addr}") +} + +#[test] +fn remote_store_proxies_the_store_api() { + let base = start_server(); + let mut remote = RemoteStore::new(&base); + + // Create + read a node round-trips through HTTP. + let node = remote + .create_node(NewNode::doc("Roof", "shingles need work")) + .unwrap(); + let got = remote.get_node(&node.id).unwrap().expect("node on server"); + assert_eq!(got.title, "Roof"); + assert_eq!(got.body.as_deref(), Some("shingles need work")); + + // A missing node is Ok(None), not an error. + assert!(remote.get_node("does-not-exist").unwrap().is_none()); + + // A body update materializes server-side and is full-text searchable. + remote + .update_node(&node.id, None, Some("new cedar shingles".into())) + .unwrap(); + let hits = remote.search("cedar").unwrap(); + assert!(hits.iter().any(|h| h.id == node.id), "search missed update"); + + // Tasks proxy too, and land in the Organizational list. + let task = remote + .create_task(NewTask { + title: "Renew passport".into(), + attention: Some(Attention::Red), + ..Default::default() + }) + .unwrap(); + let fetched = remote + .get_task(&task.node_id) + .unwrap() + .expect("task on server"); + assert_eq!(fetched.node_id, task.node_id); + let listed = remote.list(None, None, true).unwrap(); + assert!( + listed.iter().any(|t| t.node_id == task.node_id), + "task missing from list" + ); + + // Read-only aggregates proxy and start empty. + assert!(remote.health().unwrap().active_count >= 1); + assert!(remote.conflicts_list().unwrap().is_empty()); +} + +#[test] +fn remote_store_preserves_not_found() { + let base = start_server(); + let mut remote = RemoteStore::new(&base); + let err = remote + .update_node("missing", Some("x".into()), None) + .unwrap_err(); + assert!(matches!(err, Error::NodeNotFound(_)), "got {err:?}"); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 92cbacb..6e7f14f 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -10,4 +10,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Sync engine, local-only (§12): real hybrid logical clock + persistent device `origin`; an append-only op-log per mutation; an idempotent, order-independent merge/apply engine — last-writer-wins task scalars (discards surfaced in a `conflicts` queue), OR-set links, monotonic tombstones. Two-replica convergence proven. - Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. - Network sync over HTTP (§6.1, §12, slice 9a): `hephd --mode server` exposes a sync hub (`POST /sync/push`, `GET /sync/pull?after=`, axum) over the same store; `hephd --mode local --hub-url ` becomes a spoke that background-syncs its op-log with that hub (and on demand via the `sync.now`/`sync.status` RPC). Exchange is incremental by HLC cursor (`sync_state`) and idempotent. The merge engine is `heph-core`'s, unchanged. Unauthenticated/single-owner for now (auth lands with OIDC). `conflicts.list`/`conflicts.resolve` are now reachable over the daemon socket. +- Client mode (§3.1, slice 9b): `hephd --mode client --server-url ` runs with no local replica, proxying every store call to a server's `POST /rpc` endpoint (the full daemon API over HTTP). The daemon is now backend-agnostic (`local`/`server` front a `LocalStore`, `client` a `RemoteStore`), so surfaces see the same unix-socket API in every mode. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 7c6eaef..1f0bdf6 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -326,7 +326,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **100 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **102 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -335,18 +335,18 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Recurrence (§4.4):** roll-forward in place — fresh checklist, logged occurrence, advance-skipping-misses; completion never carries forward (proptest). Per-task logs; `skip`. - ✅ **Ranking (§7):** pure two-stage filter + reorderable named dimensions; proptest total order. - ✅ **Daemon RPC (§6):** node.get/create/update/tombstone, task.create/set_state/set_attention/skip, next, list, health, journal.open_or_create, search, links.outgoing/backlinks, log.append/tail, export, conflicts.list/resolve, sync.now/sync.status. Line-delimited JSON-RPC over a unix socket; sync `Client`. (`ops_since`/`apply_op` are `Store` methods exchanged over the hub HTTP endpoint, not the unix socket.) -- ✅ **Runtime modes (§3.1) — `local` + `server`:** exclusive file-lock handoff via `LockGuard`; `--mode local|server`, `--hub-url`, `--http-addr`. `client` (no replica) is a later slice. +- ✅ **Runtime modes (§3.1) — `local` + `server` + `client`:** exclusive file-lock handoff via `LockGuard` (local/server only); `--mode local|server|client`, `--hub-url`, `--http-addr`, `--server-url`. - ✅ **Sync engine (§12) minus network:** HLC (clock-injected, monotonic) + persistent device `origin`; op-log per mutation; `apply_op` merge — **LWW** task scalars + titles with a **conflict queue**, **OR-set** links, monotonic tombstones, idempotent; two-replica convergence proven. `adopt_owner` = basic §13 canonical-owner adoption. - ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict. - ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10). +- ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica spoke that proxies every `Store` method to a `server` over HTTP — the online-only escape hatch (borrowed box, CI, future web backend). Open: how the proxy maps the mutating `Store` surface; `sync.now` is N/A for `client`. -2. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub endpoint, full per-user isolation, adoption-with-deterministic-ids. -3. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). +1. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub `POST /sync/*` + `/rpc` endpoints, full per-user isolation, adoption-with-deterministic-ids. +2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). ## Related -- 2.50.1 (Apple Git-155) From 497c62a9880cee9f0674d2316e070bc730f6d846 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 15:58:20 -0700 Subject: [PATCH 17/91] =?UTF-8?q?hephd:=20OIDC=20hub=20authentication=20?= =?UTF-8?q?=E2=80=94=20verification=20side=20(auth=2010a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticate op exchange at the network boundary (tech-spec §13). The hub now requires a valid OIDC bearer token on /sync/* and /rpc; local mode is unchanged (no auth). - heph-core: Store::authorize_owner_sub — single-tenant gate that claims the owner's oidc_sub on first sight, then authorizes only that sub (403 for any other identity). LocalStore impl over users.oidc_sub; RemoteStore stub. - hephd auth module: TokenVerifier trait (mockable seam) + OidcVerifier (jsonwebtoken, rust_crypto). Strict validation: RS256 pinned, exact iss + aud, exp/nbf, required sub; JWKS discovered + cached, refetched on unknown kid (rotation). Claims/AuthError. - Hub router takes Option; an axum middleware on every route extracts the Bearer token, verifies it off the async worker, and runs the owner gate — 401 missing/invalid, 403 wrong identity, 503 IdP-unreachable. Open (no auth) when unconfigured, for local dev. - main: --oidc-issuer/--oidc-audience enable the hub verifier (server mode). - Security tests, all offline: stub-verifier middleware (missing/bad/valid + owner gate) and an adversarial battery driving OidcVerifier against an in-process mock IdP — rejects expired, wrong iss/aud, unknown kid, tampered signature, alg confusion (HS256/none), and missing sub. The RSA key + JWKS are generated at runtime (rsa/rand/base64 dev-deps) so no key is committed. - tech-spec: add an end-of-v1 dependency-refresh pass to the roadmap. 108 tests green; clippy -D warnings + fmt + prek clean. Next: client-side device-code login + keyring (10b). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 608 ++++++++++++++++++++++- Cargo.toml | 1 + README.md | 7 +- crates/heph-core/src/sqlite/mod.rs | 23 + crates/heph-core/src/store.rs | 7 + crates/heph-core/tests/convergence.rs | 15 + crates/hephd/Cargo.toml | 5 + crates/hephd/src/auth.rs | 153 ++++++ crates/hephd/src/lib.rs | 2 + crates/hephd/src/main.rs | 41 +- crates/hephd/src/remote.rs | 7 + crates/hephd/src/sync.rs | 88 +++- crates/hephd/tests/auth_hub.rs | 294 +++++++++++ crates/hephd/tests/client_mode.rs | 4 +- crates/hephd/tests/sync_http.rs | 2 +- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 6 +- 17 files changed, 1235 insertions(+), 29 deletions(-) create mode 100644 crates/hephd/src/auth.rs create mode 100644 crates/hephd/tests/auth_hub.rs diff --git a/Cargo.lock b/Cargo.lock index c901551..6a74c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,12 +183,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -210,6 +222,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -328,18 +349,82 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -354,6 +439,38 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -365,6 +482,65 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "errno" version = "0.3.14" @@ -414,9 +590,25 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -497,6 +689,28 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -511,6 +725,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -571,10 +796,14 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "base64", "clap", "fs4", "heph-core", + "jsonwebtoken", + "rand 0.8.6", "reqwest", + "rsa", "serde", "serde_json", "tempfile", @@ -584,6 +813,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.1" @@ -830,11 +1077,38 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -842,6 +1116,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -933,6 +1213,58 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -940,6 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -954,6 +1287,30 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -982,6 +1339,25 @@ dependencies = [ "regex", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1032,6 +1408,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1047,6 +1444,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1056,6 +1459,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1076,7 +1488,7 @@ dependencies = [ "bitflags", "num-traits", "rand 0.9.4", - "rand_chacha", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -1122,6 +1534,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1131,10 +1545,20 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1150,6 +1574,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1157,7 +1584,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -1241,6 +1668,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rrule" version = "0.13.0" @@ -1255,6 +1692,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1269,6 +1726,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1325,6 +1791,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1391,6 +1877,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1406,6 +1903,28 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -1443,6 +1962,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1455,6 +1990,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1493,7 +2034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -1548,6 +2089,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1698,6 +2270,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "ulid" version = "1.2.1" @@ -2120,6 +2698,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index c9a922c..bd921e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4", features = ["derive"] } fs4 = "0.12" axum = "0.8" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } reqwest = { version = "0.13", default-features = false, features = [ "json", "query", diff --git a/README.md b/README.md index f8ba65f..217a590 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes are implemented and replicas sync through a hub over HTTP** — the offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies, and `client` mode proxies to a server with no local replica. Remaining: auth and the Neovim plugin. Built test-first (102 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and the hub authenticates op exchange with OIDC bearer tokens.** The offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies; the hub verifies tokens (JWKS/RS256) and enforces single-tenant ownership. Remaining: the client-side device-code login + token cache, and the Neovim plugin. Built test-first (108 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -22,7 +22,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | yrs text-CRDT for body merge | ✅ done | | `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done | | `client` mode + `RemoteStore` (online-only, no replica) | ✅ done | -| OIDC/Authentik auth + per-user isolation | ⏳ next | +| OIDC hub auth — bearer-token verification + owner gate | ✅ done | +| OIDC client — device-code login, keyring token cache | ⏳ next | | `heph.nvim` (primary surface) | ⏳ | ## Architecture @@ -34,7 +35,7 @@ A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). - **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. -**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth** *(planned)*: OIDC against Authentik, with per-user isolation. +**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate; the client-side device-code login is in progress. Local-only instances need no auth. ## Build & run diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index f09821f..ee160fb 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -330,6 +330,29 @@ impl Store for LocalStore { syncstate::record(&self.conn, peer, pushed, pulled, now) } + fn authorize_owner_sub(&mut self, sub: &str) -> Result { + // The owner's bound identity (NULL until first authenticated sync). + let current: Option = self + .conn + .query_row( + "SELECT oidc_sub FROM users WHERE id = ?1", + [&self.owner_id], + |r| r.get(0), + ) + .optional()? + .flatten(); + match current { + None => { + self.conn.execute( + "UPDATE users SET oidc_sub = ?1 WHERE id = ?2", + (sub, &self.owner_id), + )?; + Ok(true) + } + Some(existing) => Ok(existing == sub), + } + } + fn conflicts_list(&self) -> Result> { apply::list_conflicts(&self.conn, &self.owner_id) } diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index d1fc704..162cf70 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -138,6 +138,13 @@ pub trait Store { fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>) -> Result<()>; + /// Single-tenant authentication gate (tech-spec §13). Map an OIDC `sub` to + /// this store's owner: on first sight, **claim** the owner by binding its + /// `oidc_sub`; thereafter authorize only that same `sub`. Returns `true` if + /// the `sub` owns this store, `false` if a different identity presented a + /// token. A hub calls this before serving any op exchange. + fn authorize_owner_sub(&mut self, sub: &str) -> Result; + /// Open merge conflicts surfaced for the user (`heph conflicts`). fn conflicts_list(&self) -> Result>; diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs index f84af1e..ea5d8fb 100644 --- a/crates/heph-core/tests/convergence.rs +++ b/crates/heph-core/tests/convergence.rs @@ -74,6 +74,21 @@ fn sync_cursors_default_empty_then_advance_per_direction() { ); } +#[test] +fn owner_sub_gate_claims_first_then_requires_match() { + // Single-tenant gate (§13): the first sub claims the owner; only that sub + // is authorized thereafter. + let (mut a, _ca) = replica(1000); + assert!(a.authorize_owner_sub("sub-alice").unwrap(), "first claims"); + assert!(a.authorize_owner_sub("sub-alice").unwrap(), "same sub ok"); + assert!( + !a.authorize_owner_sub("sub-mallory").unwrap(), + "a different identity must be rejected" + ); + // Still bound to the original after a rejection. + assert!(a.authorize_owner_sub("sub-alice").unwrap()); +} + #[test] fn online_round_trip_propagates_a_node() { let (mut a, _ca) = replica(1000); diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index d15d0b8..8a425fb 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -28,7 +28,12 @@ tracing-subscriber.workspace = true clap.workspace = true fs4.workspace = true axum.workspace = true +jsonwebtoken.workspace = true reqwest.workspace = true [dev-dependencies] tempfile = "3" +# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo). +rsa = "0.9" +rand = "0.8" +base64 = "0.22" diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs new file mode 100644 index 0000000..b7eee96 --- /dev/null +++ b/crates/hephd/src/auth.rs @@ -0,0 +1,153 @@ +//! Hub-side OIDC bearer-token verification (tech-spec §13). +//! +//! The hub authenticates op exchange at the **network boundary**: a request to +//! `/sync/*` or `/rpc` must carry a valid OIDC access token. Verification is a +//! [`TokenVerifier`] trait so the hub never hard-depends on a live IdP — tests +//! inject a stub, and the real [`OidcVerifier`] is exercised against an +//! in-process mock IdP (see `tests/auth_hub.rs`). +//! +//! Security posture (the easy-to-get-wrong parts, each covered by a test): +//! - **algorithm pinning** — only `RS256`; `jsonwebtoken::decode` rejects any +//! token whose header `alg` is not in the validation set, so `none`/`HS256` +//! confusion cannot select a different key type. +//! - **issuer + audience** — both required and matched exactly. +//! - **expiry** — `exp` validated (and `nbf` if present). +//! - **subject** — `sub` required. +//! - **key rotation** — an unknown `kid` triggers a single JWKS refetch. + +use std::sync::RwLock; + +use jsonwebtoken::jwk::{Jwk, JwkSet}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; + +/// The verified identity we rely on — only the subject is consumed; the rest of +/// the token is validated by [`jsonwebtoken`] but discarded. +#[derive(Debug, Clone, Deserialize)] +pub struct Claims { + /// The stable OIDC subject (Authentik `sub_mode: hashed_user_id`). + pub sub: String, +} + +/// Why a token was not accepted. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + /// No bearer token was presented. + #[error("missing bearer token")] + Missing, + /// The token was present but failed validation. + #[error("invalid token: {0}")] + Invalid(String), + /// The identity provider could not be reached to fetch keys. + #[error("identity provider unreachable: {0}")] + Provider(String), +} + +/// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be +/// tested with a stub and never requires a live IdP at test time. +pub trait TokenVerifier: Send + Sync { + /// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its + /// claims, or an [`AuthError`]. + fn verify(&self, bearer: &str) -> Result; +} + +/// What an OIDC provider's discovery document tells us (we only need the JWKS). +#[derive(Debug, Deserialize)] +struct Discovery { + jwks_uri: String, +} + +/// Verifies tokens against a real OIDC provider: discovers the JWKS, caches it, +/// and validates RS256 signatures + `iss`/`aud`/`exp`/`sub`. +pub struct OidcVerifier { + issuer: String, + audience: String, + http: reqwest::blocking::Client, + jwks: RwLock>, +} + +impl OidcVerifier { + /// Verify tokens whose `iss` equals `issuer` and `aud` contains `audience`. + /// `issuer` must match the token's `iss` claim exactly (for Authentik, the + /// per-application issuer `https://.../application/o//`). + pub fn new(issuer: impl Into, audience: impl Into) -> OidcVerifier { + OidcVerifier { + issuer: issuer.into(), + audience: audience.into(), + http: reqwest::blocking::Client::new(), + jwks: RwLock::new(None), + } + } + + /// Resolve the JWKS URI from the provider's discovery document. + fn jwks_uri(&self) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + self.issuer.trim_end_matches('/') + ); + let disc: Discovery = self + .http + .get(url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::json) + .map_err(|e| AuthError::Provider(e.to_string()))?; + Ok(disc.jwks_uri) + } + + /// Fetch (and cache) the provider's JWKS. + fn refresh_jwks(&self) -> Result<(), AuthError> { + let uri = self.jwks_uri()?; + let set: JwkSet = self + .http + .get(uri) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::json) + .map_err(|e| AuthError::Provider(e.to_string()))?; + *self.jwks.write().expect("jwks lock poisoned") = Some(set); + Ok(()) + } + + /// The cached signing key for `kid`, if present. + fn cached_key(&self, kid: &str) -> Option { + self.jwks + .read() + .expect("jwks lock poisoned") + .as_ref() + .and_then(|set| set.find(kid).cloned()) + } + + /// Find `kid`, refetching the JWKS once if it is not already cached (key + /// rotation). + fn key_for(&self, kid: &str) -> Result { + if let Some(key) = self.cached_key(kid) { + return Ok(key); + } + self.refresh_jwks()?; + self.cached_key(kid) + .ok_or_else(|| AuthError::Invalid(format!("unknown signing key '{kid}'"))) + } +} + +impl TokenVerifier for OidcVerifier { + fn verify(&self, bearer: &str) -> Result { + let header = decode_header(bearer).map_err(|e| AuthError::Invalid(e.to_string()))?; + let kid = header + .kid + .ok_or_else(|| AuthError::Invalid("token header has no kid".into()))?; + let jwk = self.key_for(&kid)?; + let key = DecodingKey::from_jwk(&jwk).map_err(|e| AuthError::Invalid(e.to_string()))?; + + // Strict validation: RS256 only (decode rejects other `alg`s), exact + // issuer + audience, and require the claims we depend on. + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[&self.issuer]); + validation.set_audience(&[&self.audience]); + validation.set_required_spec_claims(&["exp", "sub", "aud", "iss"]); + + let data = decode::(bearer, &key, &validation) + .map_err(|e| AuthError::Invalid(e.to_string()))?; + Ok(data.claims) + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index c665ecd..14b0b80 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -7,6 +7,7 @@ //! query/mutation logic all lives in `heph-core`; this crate is transport, //! locking, and (later) sync/auth. +pub mod auth; pub mod client; pub mod clock; pub mod lock; @@ -17,6 +18,7 @@ pub mod sync; use std::path::PathBuf; +pub use auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; pub use client::Client; pub use clock::SystemClock; pub use lock::LockGuard; diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index 781009a..dcc0b72 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -1,12 +1,14 @@ -//! `hephd` binary — starts the daemon in `local` or `server` mode. +//! `hephd` binary — starts the daemon in `local`, `server`, or `client` mode. //! -//! Both modes own the local SQLite file (exclusive lock) and serve surfaces -//! over a unix socket. **server** additionally exposes the hub HTTP endpoint for -//! spokes to sync against; a **local** instance given `--hub-url` becomes a -//! syncing spoke that background-exchanges its op-log with that hub (tech-spec -//! §3.1, §6.1, §12). `client` mode (no local replica) is a later slice. +//! `local`/`server` own the local SQLite file (exclusive lock); `client` keeps +//! no replica and proxies to a `--server-url`. All three serve surfaces over a +//! unix socket. **server** additionally exposes the hub HTTP endpoint for spokes +//! to sync against (requiring OIDC bearer tokens when `--oidc-issuer`/`-audience` +//! are set); a **local** instance given `--hub-url` becomes a syncing spoke that +//! background-exchanges its op-log with that hub (tech-spec §3.1, §6.1, §12, §13). use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; @@ -60,6 +62,15 @@ struct Cli { /// Server to proxy to (client mode only; required there). #[arg(long)] server_url: Option, + + /// OIDC issuer to verify hub bearer tokens against (server mode). When set + /// with --oidc-audience, the hub endpoints require a valid token. + #[arg(long)] + oidc_issuer: Option, + + /// OIDC audience (client id) hub tokens must carry (server mode). + #[arg(long)] + oidc_audience: Option, } #[tokio::main] @@ -106,7 +117,23 @@ async fn main() -> Result<()> { .http_addr .clone() .unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string()); - let app = sync::router(daemon.store()); + let verifier: Option> = + match (cli.oidc_issuer.clone(), cli.oidc_audience.clone()) { + (Some(issuer), Some(audience)) => { + tracing::info!(%issuer, "hub requires OIDC bearer tokens"); + Some(Arc::new(hephd::OidcVerifier::new(issuer, audience))) + } + (None, None) => { + tracing::warn!( + "hub running UNAUTHENTICATED (no --oidc-issuer/--oidc-audience)" + ); + None + } + _ => { + anyhow::bail!("--oidc-issuer and --oidc-audience must be set together") + } + }; + let app = sync::router(daemon.store(), verifier); let http_listener = TcpListener::bind(&addr) .await .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index db5dfea..33ec49a 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -206,6 +206,13 @@ impl Store for RemoteStore { Ok(()) } + fn authorize_owner_sub(&mut self, _sub: &str) -> Result { + // Hub-side gate; a no-replica client never hosts an endpoint to guard. + Err(Error::Remote( + "authorize_owner_sub is a hub-side operation".into(), + )) + } + fn conflicts_list(&self) -> Result> { self.call_as("conflicts.list", json!({})) } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index bbbbb27..7a8ce59 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -19,8 +19,10 @@ use std::sync::{Arc, Mutex}; use anyhow::Result; -use axum::extract::{Query, State}; +use axum::extract::{Query, Request, State}; use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::Response as AxumResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; @@ -28,12 +30,21 @@ use serde_json::Value; use heph_core::{Op, Store}; +use crate::auth::{AuthError, TokenVerifier}; use crate::rpc::{self, Response, RpcError, INTERNAL_ERROR}; /// The shared store a hub serves from — any [`Store`], so a `server` fronts a /// `LocalStore` and (later modes) could front another backend. pub type SharedStore = Arc>; +/// What the hub HTTP routes share: the store and (when authentication is +/// configured) the bearer-token verifier. +#[derive(Clone)] +struct HubState { + store: SharedStore, + verifier: Option>, +} + /// A batch of ops in flight (push body / pull response). #[derive(Debug, Serialize, Deserialize)] pub struct OpsBody { @@ -86,13 +97,70 @@ fn apply_batch( Ok((applied, max_hlc)) } -/// The hub's HTTP router (server mode). Mount it on a TCP listener. -pub fn router(store: SharedStore) -> Router { +/// The hub's HTTP router (server mode). Mount it on a TCP listener. When +/// `verifier` is `Some`, every route requires a valid OIDC bearer token whose +/// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev). +pub fn router(store: SharedStore, verifier: Option>) -> Router { + let state = HubState { store, verifier }; Router::new() .route("/sync/pull", get(pull)) .route("/sync/push", post(push)) .route("/rpc", post(rpc_call)) - .with_state(store) + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) + .with_state(state) +} + +/// Reject any request lacking a valid bearer token whose `sub` owns this hub. +/// A no-op when the hub has no verifier configured (open dev mode). +async fn require_auth( + State(state): State, + request: Request, + next: Next, +) -> Result { + let Some(verifier) = state.verifier.clone() else { + return Ok(next.run(request).await); // open: no auth configured + }; + + let Some(token) = bearer_token(&request) else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // Verification (and the store gate) hit the network / DB — run off the async + // worker on the blocking pool. + let claims = tokio::task::spawn_blocking(move || verifier.verify(&token)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| match e { + AuthError::Provider(_) => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::UNAUTHORIZED, + })?; + + // Single-tenant gate: the token's identity must own this hub. + let store = state.store.clone(); + let owns = tokio::task::spawn_blocking(move || { + store + .lock() + .expect("store mutex poisoned") + .authorize_owner_sub(&claims.sub) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !owns { + return Err(StatusCode::FORBIDDEN); + } + + Ok(next.run(request).await) +} + +/// Extract the `Authorization: Bearer ` value, if present. +fn bearer_token(request: &Request) -> Option { + request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_string) } /// One `POST /rpc` call: a method name + params, mirroring the unix-socket RPC. @@ -106,8 +174,8 @@ struct RpcCall { /// `POST /rpc` — run one [`rpc::dispatch`] call on the hub's store and return a /// JSON-RPC-shaped [`Response`] (result xor error). Always HTTP 200; method /// failures travel in the body so the client can reconstruct the error. -async fn rpc_call(State(store): State, Json(call): Json) -> Json { - let store = store.clone(); +async fn rpc_call(State(state): State, Json(call): Json) -> Json { + let store = state.store.clone(); let dispatched = tokio::task::spawn_blocking(move || { let mut guard = store.lock().expect("store mutex poisoned"); rpc::dispatch(&mut *guard, &call.method, call.params) @@ -136,10 +204,10 @@ struct PullQuery { /// `GET /sync/pull?after=` — ops past the caller's cursor, HLC order. async fn pull( - State(store): State, + State(state): State, Query(q): Query, ) -> Result, StatusCode> { - let ops = with_store(&store, move |s| s.ops_since(q.after.as_deref())) + let ops = with_store(&state.store, move |s| s.ops_since(q.after.as_deref())) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(OpsBody { ops })) @@ -147,10 +215,10 @@ async fn pull( /// `POST /sync/push` — merge the caller's ops; reply with how many newly applied. async fn push( - State(store): State, + State(state): State, Json(body): Json, ) -> Result, StatusCode> { - let (applied, _max) = with_store(&store, move |s| apply_batch(s, body.ops)) + let (applied, _max) = with_store(&state.store, move |s| apply_batch(s, body.ops)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(SyncReport { diff --git a/crates/hephd/tests/auth_hub.rs b/crates/hephd/tests/auth_hub.rs new file mode 100644 index 0000000..c594cf0 --- /dev/null +++ b/crates/hephd/tests/auth_hub.rs @@ -0,0 +1,294 @@ +//! Hub authentication (tech-spec §13, slice 10a). Two layers, both offline: +//! +//! 1. **Middleware + owner gate** via a stub verifier — proves the hub rejects +//! missing/invalid tokens, admits valid ones, and enforces single-tenant +//! ownership — with zero crypto. +//! 2. **`OidcVerifier` against an in-process mock IdP** (a real RSA key + JWKS) — +//! an adversarial battery proving the *crypto* path accepts a good token and +//! rejects every common forgery (expired, wrong iss/aud, unknown kid, +//! tampered signature, `alg` confusion, missing `sub`). +//! +//! No external IdP is touched; Authentik is only needed for a manual smoke test. + +use std::collections::HashMap; +use std::sync::{mpsc, Arc, Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::traits::PublicKeyParts; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde::Serialize; +use serde_json::{json, Value}; + +use heph_core::{FixedClock, LocalStore}; +use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; +use hephd::sync::{self, SharedStore}; + +const NOW: i64 = 1_704_067_200_000; +const AUDIENCE: &str = "heph-hub"; +const KID: &str = "test-key-1"; + +/// A throwaway RSA keypair generated once per test run — no key material lives +/// in the repo. `pem` signs tokens; `n`/`e` (base64url) are served in the JWKS. +struct TestKey { + pem: String, + n: String, + e: String, +} + +fn test_key() -> &'static TestKey { + static KEY: OnceLock = OnceLock::new(); + KEY.get_or_init(|| { + let mut rng = rand::thread_rng(); + let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA key"); + let public = RsaPublicKey::from(&private); + TestKey { + pem: private + .to_pkcs8_pem(LineEnding::LF) + .expect("encode PEM") + .to_string(), + n: URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()), + e: URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()), + } + }) +} + +// --- layer 1: middleware + owner gate (stub verifier) --------------------- + +/// A verifier that maps known opaque tokens to subjects — no crypto. +struct StubVerifier(HashMap); +impl TokenVerifier for StubVerifier { + fn verify(&self, bearer: &str) -> Result { + self.0 + .get(bearer) + .map(|sub| Claims { sub: sub.clone() }) + .ok_or_else(|| AuthError::Invalid("unknown stub token".into())) + } +} + +/// Start a hub with the given verifier over a fresh temp store; return base URL. +fn start_hub(verifier: Option>) -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let dir = tempfile::tempdir().unwrap(); + let store = + LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); + let shared: SharedStore = Arc::new(Mutex::new(store)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + tx.send(listener.local_addr().unwrap()).unwrap(); + let _keep = dir; + axum::serve(listener, sync::router(shared, verifier)) + .await + .unwrap(); + }); + }); + format!( + "http://{}", + rx.recv_timeout(Duration::from_secs(5)).unwrap() + ) +} + +/// `POST /rpc health` with an optional bearer token; return the HTTP status. +fn rpc_health_status(base: &str, token: Option<&str>) -> u16 { + let http = reqwest::blocking::Client::new(); + let mut req = http + .post(format!("{base}/rpc")) + .json(&json!({ "method": "health", "params": {} })); + if let Some(t) = token { + req = req.header("Authorization", format!("Bearer {t}")); + } + req.send().unwrap().status().as_u16() +} + +fn stub(pairs: &[(&str, &str)]) -> Option> { + let map = pairs + .iter() + .map(|(t, s)| (t.to_string(), s.to_string())) + .collect(); + Some(Arc::new(StubVerifier(map))) +} + +#[test] +fn open_hub_needs_no_token() { + let base = start_hub(None); + assert_eq!(rpc_health_status(&base, None), 200); +} + +#[test] +fn authed_hub_rejects_missing_and_bad_tokens_admits_valid() { + let base = start_hub(stub(&[("tok-alice", "alice")])); + assert_eq!(rpc_health_status(&base, None), 401, "missing token"); + assert_eq!(rpc_health_status(&base, Some("garbage")), 401, "bad token"); + assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200, "valid"); +} + +#[test] +fn owner_gate_rejects_a_second_identity() { + let base = start_hub(stub(&[("tok-alice", "alice"), ("tok-mallory", "mallory")])); + // Alice authenticates first and claims the hub. + assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200); + // A different valid identity is forbidden (single-tenant isolation). + assert_eq!(rpc_health_status(&base, Some("tok-mallory")), 403); + // Alice still works. + assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200); +} + +// --- layer 2: OidcVerifier against a mock IdP (real RS256) ----------------- + +/// Start a mock OIDC provider (discovery + JWKS) on an ephemeral port; the +/// returned URL is both the issuer and the discovery base. +fn start_mock_idp() -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tx.send(base.clone()).unwrap(); + let app = Router::new() + .route("/.well-known/openid-configuration", get(discovery)) + .route("/jwks", get(jwks)) + .with_state(base); + axum::serve(listener, app).await.unwrap(); + }); + }); + rx.recv_timeout(Duration::from_secs(5)).unwrap() +} + +async fn discovery(State(base): State) -> Json { + Json(json!({ "issuer": base, "jwks_uri": format!("{base}/jwks") })) +} + +async fn jwks() -> Json { + let key = test_key(); + Json(json!({ + "keys": [{ + "kty": "RSA", "use": "sig", "alg": "RS256", + "kid": KID, "n": key.n, "e": key.e, + }] + })) +} + +#[derive(Serialize)] +struct TokenClaims { + sub: String, + iss: String, + aud: String, + exp: u64, + iat: u64, +} + +#[derive(Serialize)] +struct NoSubClaims { + iss: String, + aud: String, + exp: u64, +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn rsa_key() -> EncodingKey { + EncodingKey::from_rsa_pem(test_key().pem.as_bytes()).unwrap() +} + +/// Sign an RS256 token with the given `kid` and standard claims. +fn sign(claims: &TokenClaims, kid: &str) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + encode(&header, claims, &rsa_key()).unwrap() +} + +fn good_claims(issuer: &str) -> TokenClaims { + TokenClaims { + sub: "hashed-eblume".into(), + iss: issuer.into(), + aud: AUDIENCE.into(), + exp: unix_now() + 3600, + iat: unix_now(), + } +} + +#[test] +fn oidc_verifier_accepts_a_valid_token() { + let issuer = start_mock_idp(); + let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE); + let token = sign(&good_claims(&issuer), KID); + let claims = verifier.verify(&token).expect("valid token accepted"); + assert_eq!(claims.sub, "hashed-eblume"); +} + +#[test] +fn oidc_verifier_rejects_forgeries() { + let issuer = start_mock_idp(); + let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE); + + // expired (well past jsonwebtoken's default 60s leeway) + let mut c = good_claims(&issuer); + c.exp = unix_now() - 3600; + assert!(verifier.verify(&sign(&c, KID)).is_err(), "expired"); + + // wrong issuer + let mut c = good_claims(&issuer); + c.iss = "https://evil.example".into(); + assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong iss"); + + // wrong audience + let mut c = good_claims(&issuer); + c.aud = "someone-else".into(); + assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong aud"); + + // unknown signing key (kid not in JWKS, even after refetch) + assert!( + verifier + .verify(&sign(&good_claims(&issuer), "other-kid")) + .is_err(), + "unknown kid" + ); + + // tampered signature + let mut token = sign(&good_claims(&issuer), KID); + let last = token.pop().unwrap(); + token.push(if last == 'A' { 'B' } else { 'A' }); + assert!(verifier.verify(&token).is_err(), "tampered signature"); + + // algorithm confusion: HS256-signed token must be rejected (RS256 pinned) + let mut hs = Header::new(Algorithm::HS256); + hs.kid = Some(KID.to_string()); + let hs_token = encode(&hs, &good_claims(&issuer), &EncodingKey::from_secret(b"x")).unwrap(); + assert!(verifier.verify(&hs_token).is_err(), "HS256 confusion"); + + // alg: none — a header claiming no signature + let none_token = "eyJhbGciOiJub25lIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJzdWIiOiJ4In0."; + assert!(verifier.verify(none_token).is_err(), "alg none"); + + // missing sub + let nosub = NoSubClaims { + iss: issuer.clone(), + aud: AUDIENCE.into(), + exp: unix_now() + 3600, + }; + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(KID.to_string()); + let token = encode(&header, &nosub, &rsa_key()).unwrap(); + assert!(verifier.verify(&token).is_err(), "missing sub"); +} diff --git a/crates/hephd/tests/client_mode.rs b/crates/hephd/tests/client_mode.rs index f79b91d..15707a2 100644 --- a/crates/hephd/tests/client_mode.rs +++ b/crates/hephd/tests/client_mode.rs @@ -32,7 +32,9 @@ fn start_server() -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); tx.send(listener.local_addr().unwrap()).unwrap(); let _keep = dir; // keep the temp DB alive while we serve - axum::serve(listener, sync::router(shared)).await.unwrap(); + axum::serve(listener, sync::router(shared, None)) + .await + .unwrap(); }); }); let addr = rx.recv_timeout(Duration::from_secs(5)).unwrap(); diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs index de4194e..c9c8e21 100644 --- a/crates/hephd/tests/sync_http.rs +++ b/crates/hephd/tests/sync_http.rs @@ -48,7 +48,7 @@ async fn start_hub() -> String { Box::leak(Box::new(dir)); // keep the hub DB file alive let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let app = sync::router(hub); + let app = sync::router(hub, None); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 6e7f14f..e5bec27 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -11,4 +11,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. - Network sync over HTTP (§6.1, §12, slice 9a): `hephd --mode server` exposes a sync hub (`POST /sync/push`, `GET /sync/pull?after=`, axum) over the same store; `hephd --mode local --hub-url ` becomes a spoke that background-syncs its op-log with that hub (and on demand via the `sync.now`/`sync.status` RPC). Exchange is incremental by HLC cursor (`sync_state`) and idempotent. The merge engine is `heph-core`'s, unchanged. Unauthenticated/single-owner for now (auth lands with OIDC). `conflicts.list`/`conflicts.resolve` are now reachable over the daemon socket. - Client mode (§3.1, slice 9b): `hephd --mode client --server-url ` runs with no local replica, proxying every store call to a server's `POST /rpc` endpoint (the full daemon API over HTTP). The daemon is now backend-agnostic (`local`/`server` front a `LocalStore`, `client` a `RemoteStore`), so surfaces see the same unix-socket API in every mode. +- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). Client-side login (device-code flow) lands next. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 1f0bdf6..5998b5b 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -292,6 +292,7 @@ All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts - **Web UI** (the hub serves sync only in v1; reserve `axum` for it later). - **Actual k3s deployment** to blumeops (Dagger→Zot image, ArgoCD app + Kustomize manifests, external-secrets) — fast-follow once the architecture is proven; the hub binary is built to be deployable. - **Calendar** integration (read-mostly CalDAV; **never explode recurrence into stored events**), iOS/Watch capture, inferred/semantic context, P2P-over-tailnet sync fallback. +- **Dependency-refresh pass:** before declaring v1 done, sweep every external dependency (Cargo crates, the Neovim plugin's deps, CI/tooling) up to its latest stable release, re-running the full suite + `clippy`/`fmt` to catch breakage. Slices add deps at the version current when written; this reconciles them once at the end rather than churning mid-build. See [[design]] §5–§7 for the constraints later phases impose on present choices (keep tasks vs. calendar events separate; expand RRULEs lazily). @@ -326,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **102 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **108 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -340,12 +341,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict. - ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10). - ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. +- ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub `POST /sync/*` + `/rpc` endpoints, full per-user isolation, adoption-with-deterministic-ids. +1. ⏳ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`heph auth login`), token cache in the OS keyring + auto-refresh, the spoke attaching its bearer to `sync_once`/RPC, and local→authed **adoption** (owner-embedded deterministic-id rewrite). Multi-tenant hub (owner-per-token storage) remains a future extension beyond the current single-tenant gate. 2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). ## Related -- 2.50.1 (Apple Git-155) From f4db1862340a62c140f81d5c2575cce21ba56fe4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 16:27:36 -0700 Subject: [PATCH 18/91] =?UTF-8?q?hephd:=20OIDC=20client=20auth=20=E2=80=94?= =?UTF-8?q?=20device-code=20flow=20+=20token=20attach=20(auth=2010b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the auth loop: clients obtain a bearer token and present it to the hub (tech-spec §13). - oauth module: DeviceFlow (RFC 8628 — discover, start, poll handling authorization_pending/slow_down, refresh) + StoredToken + TokenStore (OS keyring via `keyring`, in-memory for tests) + current_bearer (loads and refreshes-on-expiry). - heph auth login/logout: runs the device flow, prints the verification URL + user code, caches the token in the keyring. - sync_once gains a bearer arg; the daemon (Daemon::spawn_sync_loop + sync.now) obtains it via current_bearer; RemoteStore attaches it to /rpc. --oidc-issuer/--oidc-client-id configure the spoke/client. - Fix a latent panic: reqwest::blocking spins its own runtime and panics inside the daemon's spawn_blocking pool. All blocking auth/proxy HTTP (OidcVerifier JWKS, DeviceFlow, RemoteStore) now uses runtime-free `ureq`; async reqwest remains only for sync_once. (Caught by the new e2e test.) - Tests (offline): device flow + refresh + token store vs a mock OAuth server; a full spoke->authenticated-hub loop (valid token accepted, missing token rejected) signed by a runtime-generated RSA key. 112 tests green; clippy -D warnings + fmt + prek clean. Slice 10 (auth) complete; next is heph.nvim. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1131 +++++++++++++++++++++- Cargo.toml | 8 +- README.md | 7 +- crates/heph/src/main.rs | 70 +- crates/hephd/Cargo.toml | 2 + crates/hephd/src/auth.rs | 38 +- crates/hephd/src/lib.rs | 15 + crates/hephd/src/main.rs | 59 +- crates/hephd/src/oauth.rs | 327 +++++++ crates/hephd/src/remote.rs | 65 +- crates/hephd/src/server.rs | 71 +- crates/hephd/src/sync.rs | 23 +- crates/hephd/tests/auth_hub.rs | 77 +- crates/hephd/tests/oauth.rs | 153 +++ crates/hephd/tests/sync_http.rs | 24 +- docs/changelog.d/v1-prototype.feature.md | 3 +- docs/reference/tech-spec.md | 7 +- 17 files changed, 1996 insertions(+), 84 deletions(-) create mode 100644 crates/hephd/src/oauth.rs create mode 100644 crates/hephd/tests/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index 6a74c3e..c1f43a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -97,6 +114,48 @@ dependencies = [ "rustversion", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -108,6 +167,59 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -231,6 +343,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -243,6 +377,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.63" @@ -259,6 +402,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -294,6 +443,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -355,6 +514,55 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -370,6 +578,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -433,12 +650,42 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -482,6 +729,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -541,6 +797,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -615,12 +904,43 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -647,7 +967,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -662,6 +981,30 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -681,10 +1024,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", + "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -720,11 +1062,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "group" version = "0.13.0" @@ -745,13 +1100,28 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -801,6 +1171,7 @@ dependencies = [ "fs4", "heph-core", "jsonwebtoken", + "keyring", "rand 0.8.6", "reqwest", "rsa", @@ -811,8 +1182,21 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ureq", ] +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1026,6 +1410,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1047,6 +1437,28 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1101,6 +1513,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "openssl", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1110,12 +1537,28 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -1151,6 +1594,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1187,12 +1636,31 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1204,6 +1672,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1213,6 +1694,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1239,6 +1734,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1265,6 +1769,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1287,6 +1802,63 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -1408,6 +1980,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -1435,6 +2018,20 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1459,6 +2056,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1468,6 +2075,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1528,6 +2144,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -1642,9 +2264,7 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -1678,6 +2298,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rrule" version = "0.13.0" @@ -1761,6 +2395,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1805,6 +2474,61 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1865,6 +2589,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1877,6 +2612,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1903,6 +2649,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1913,6 +2669,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1984,6 +2746,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2034,7 +2802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2156,6 +2924,36 @@ dependencies = [ "syn", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -2276,6 +3074,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ulid" version = "1.2.1" @@ -2304,6 +3113,50 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2316,6 +3169,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2376,7 +3235,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -2434,6 +3302,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.99" @@ -2454,6 +3356,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2604,18 +3515,125 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -2657,6 +3675,62 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.50" @@ -2756,3 +3830,40 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index bd921e4..54f59a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,10 +35,16 @@ clap = { version = "4", features = ["derive"] } fs4 = "0.12" axum = "0.8" jsonwebtoken = { version = "10", features = ["rust_crypto"] } +keyring = { version = "3", features = [ + "apple-native", + "sync-secret-service", + "crypto-rust", + "vendored", +] } +ureq = { version = "3", features = ["json"] } reqwest = { version = "0.13", default-features = false, features = [ "json", "query", - "blocking", ] } [profile.release] diff --git a/README.md b/README.md index 217a590..f87a9b0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and the hub authenticates op exchange with OIDC bearer tokens.** The offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies; the hub verifies tokens (JWKS/RS256) and enforces single-tenant ownership. Remaining: the client-side device-code login + token cache, and the Neovim plugin. Built test-first (108 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — nearly feature-complete** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and op exchange is authenticated end-to-end with OIDC** (Authentik): the hub verifies bearer tokens (JWKS/RS256) and enforces single-tenant ownership, and `heph auth login` runs the device-code flow, caching tokens in the OS keyring. The offline-first everyday config (`local` + `hub_url`) converges with a `yrs` text-CRDT merging bodies. Remaining: the Neovim plugin (the primary surface). Built test-first (112 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -23,7 +23,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done | | `client` mode + `RemoteStore` (online-only, no replica) | ✅ done | | OIDC hub auth — bearer-token verification + owner gate | ✅ done | -| OIDC client — device-code login, keyring token cache | ⏳ next | +| OIDC client — device-code login, keyring token cache | ✅ done | +| `heph.nvim` (primary surface) | ⏳ next | | `heph.nvim` (primary surface) | ⏳ | ## Architecture @@ -35,7 +36,7 @@ A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). - **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. -**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate; the client-side device-code login is in progress. Local-only instances need no auth. +**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate — and clients obtain tokens via the OAuth 2.0 device-code flow (`heph auth login`), cached in the OS keyring. Local-only instances need no auth. ## Build & run diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index ddccc11..7ab71eb 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -9,7 +9,7 @@ use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use heph_core::{Node, RankedTask, Task}; -use hephd::{default_socket_path, Client}; +use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; #[derive(Parser, Debug)] #[command(name = "heph", version, about)] @@ -81,10 +81,77 @@ enum Command { /// Destination directory (created if needed). dir: PathBuf, }, + /// Authenticate this device with a sync hub (OAuth 2.0 device-code flow). + Auth { + #[command(subcommand)] + action: AuthAction, + }, +} + +#[derive(Subcommand, Debug)] +enum AuthAction { + /// Log in via the device-code flow; caches the bearer token for hub sync. + Login { + /// Hub/server URL this token is for (keys the credential store entry). + #[arg(long)] + hub_url: String, + /// OIDC issuer, e.g. https://authentik.ops.eblu.me/application/o/heph/. + #[arg(long)] + issuer: String, + /// OIDC client id this device authenticates as. + #[arg(long)] + client_id: String, + /// Scopes to request (`offline_access` yields a refresh token). + #[arg(long, default_value = "openid offline_access")] + scope: String, + }, + /// Forget the cached token for a hub. + Logout { + /// Hub/server URL whose cached token to remove. + #[arg(long)] + hub_url: String, + }, +} + +/// Run the device-code flow (or clear a token) — no daemon needed. +fn run_auth(action: AuthAction) -> Result<()> { + match action { + AuthAction::Login { + hub_url, + issuer, + client_id, + scope, + } => { + let flow = DeviceFlow::discover(&issuer, &client_id)?; + let auth = flow.start(&scope)?; + let uri = auth + .verification_uri_complete + .as_deref() + .unwrap_or(&auth.verification_uri); + println!( + "To authorize hephaestus, visit:\n {uri}\nand enter code: {}\n\nWaiting…", + auth.user_code + ); + let token = flow.poll(&auth, std::thread::sleep)?; + KeyringTokenStore::new(hub_url.as_str()).save(&token)?; + println!("Logged in. Token cached for {hub_url}."); + } + AuthAction::Logout { hub_url } => { + KeyringTokenStore::new(hub_url.as_str()).clear()?; + println!("Logged out of {hub_url}."); + } + } + Ok(()) } fn main() -> Result<()> { let cli = Cli::parse(); + + // `auth` runs locally (device-code flow + keyring); it needs no daemon. + if let Command::Auth { action } = cli.command { + return run_auth(action); + } + let socket = cli.socket.unwrap_or_else(default_socket_path); let mut client = Client::connect(&socket)?; @@ -157,6 +224,7 @@ fn main() -> Result<()> { let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); println!("Exported {count} nodes to {}", dir.display()); } + Command::Auth { .. } => unreachable!("auth is handled before connecting"), } Ok(()) } diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index 8a425fb..807feb4 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -29,7 +29,9 @@ clap.workspace = true fs4.workspace = true axum.workspace = true jsonwebtoken.workspace = true +keyring.workspace = true reqwest.workspace = true +ureq.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs index b7eee96..e3081a1 100644 --- a/crates/hephd/src/auth.rs +++ b/crates/hephd/src/auth.rs @@ -62,7 +62,7 @@ struct Discovery { pub struct OidcVerifier { issuer: String, audience: String, - http: reqwest::blocking::Client, + http: ureq::Agent, jwks: RwLock>, } @@ -74,37 +74,43 @@ impl OidcVerifier { OidcVerifier { issuer: issuer.into(), audience: audience.into(), - http: reqwest::blocking::Client::new(), + http: crate::blocking_agent(), jwks: RwLock::new(None), } } + /// GET `url` and decode a JSON body, erroring on a non-success status. + fn get_json(&self, url: &str) -> Result { + let mut resp = self + .http + .get(url) + .call() + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !resp.status().is_success() { + return Err(AuthError::Provider(format!( + "{url} returned {}", + resp.status() + ))); + } + resp.body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string())) + } + /// Resolve the JWKS URI from the provider's discovery document. fn jwks_uri(&self) -> Result { let url = format!( "{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/') ); - let disc: Discovery = self - .http - .get(url) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .and_then(reqwest::blocking::Response::json) - .map_err(|e| AuthError::Provider(e.to_string()))?; + let disc: Discovery = self.get_json(&url)?; Ok(disc.jwks_uri) } /// Fetch (and cache) the provider's JWKS. fn refresh_jwks(&self) -> Result<(), AuthError> { let uri = self.jwks_uri()?; - let set: JwkSet = self - .http - .get(uri) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .and_then(reqwest::blocking::Response::json) - .map_err(|e| AuthError::Provider(e.to_string()))?; + let set: JwkSet = self.get_json(&uri)?; *self.jwks.write().expect("jwks lock poisoned") = Some(set); Ok(()) } diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 14b0b80..60f7de3 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod client; pub mod clock; pub mod lock; +pub mod oauth; pub mod remote; pub mod rpc; pub mod server; @@ -22,10 +23,24 @@ pub use auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; pub use client::Client; pub use clock::SystemClock; pub use lock::LockGuard; +pub use oauth::{current_bearer, DeviceFlow, KeyringTokenStore, StoredToken, TokenStore}; pub use remote::RemoteStore; pub use server::Daemon; pub use sync::{sync_once, SyncReport}; +/// A blocking HTTP agent for the auth paths (JWKS fetch, device flow, the +/// `client`-mode `/rpc` proxy). It spins **no** async runtime, so unlike +/// `reqwest::blocking` it is safe inside `spawn_blocking` and plain sync code; +/// 4xx/5xx are *not* turned into errors so callers can read error bodies +/// (e.g. the device flow's `authorization_pending`). +pub(crate) fn blocking_agent() -> ureq::Agent { + ureq::Agent::new_with_config( + ureq::Agent::config_builder() + .http_status_as_error(false) + .build(), + ) +} + /// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to /// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3). pub fn default_socket_path() -> PathBuf { diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index dcc0b72..e8d9cd1 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -17,7 +17,8 @@ use tokio::net::{TcpListener, UnixListener}; use heph_core::LocalStore; use hephd::{ - default_db_path, default_socket_path, sync, Daemon, LockGuard, RemoteStore, SystemClock, + default_db_path, default_socket_path, sync, Daemon, KeyringTokenStore, LockGuard, RemoteStore, + SystemClock, TokenStore, }; /// How often a spoke background-syncs with its hub. @@ -71,6 +72,28 @@ struct Cli { /// OIDC audience (client id) hub tokens must carry (server mode). #[arg(long)] oidc_audience: Option, + + /// OIDC client id this device authenticates as, for spoke/client sync. With + /// --oidc-issuer, the device attaches a cached bearer token to hub requests. + #[arg(long)] + oidc_client_id: Option, +} + +/// Build the spoke/client token source: a keyring store keyed by `account` (the +/// hub/server url) plus the issuer + client id. `None` unless both are set. +fn spoke_auth( + account: &str, + issuer: Option<&String>, + client_id: Option<&String>, +) -> Option<(Arc, String, String)> { + match (issuer, client_id) { + (Some(issuer), Some(client_id)) => Some(( + Arc::new(KeyringTokenStore::new(account)) as Arc, + issuer.clone(), + client_id.clone(), + )), + _ => None, + } } #[tokio::main] @@ -98,7 +121,17 @@ async fn main() -> Result<()> { .clone() .context("client mode requires --server-url")?; tracing::info!(%server_url, "client mode: proxying to server (no local replica)"); - (None, Daemon::new(RemoteStore::new(&server_url))) + let store = match spoke_auth( + &server_url, + cli.oidc_issuer.as_ref(), + cli.oidc_client_id.as_ref(), + ) { + Some((tokens, issuer, client_id)) => { + RemoteStore::with_auth(&server_url, tokens, issuer, client_id) + } + None => RemoteStore::new(&server_url), + }; + (None, Daemon::new(store)) } Mode::Local | Mode::Server => { let db = cli.db.clone().unwrap_or_else(default_db_path); @@ -109,7 +142,12 @@ async fn main() -> Result<()> { // Take the exclusive lock before opening the store (tech-spec §3.1). let lock = LockGuard::acquire(&db)?; let store = LocalStore::open(&db, Box::new(SystemClock))?; - let daemon = Daemon::new(store).with_hub(cli.hub_url.clone()); + let spoke = cli.hub_url.as_deref().and_then(|hub| { + spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref()) + }); + let daemon = Daemon::new(store) + .with_hub(cli.hub_url.clone()) + .with_spoke_auth(spoke); // server mode: expose the hub HTTP endpoint over the same store. if cli.mode == Mode::Server { @@ -146,20 +184,7 @@ async fn main() -> Result<()> { } // spoke: background-sync the op-log with the configured hub. - if let Some(hub) = cli.hub_url.clone() { - let store = daemon.store(); - tokio::spawn(async move { - let http = reqwest::Client::new(); - let mut tick = tokio::time::interval(SYNC_INTERVAL); - loop { - tick.tick().await; - match hephd::sync_once(store.clone(), &hub, &http).await { - Ok(report) => tracing::debug!(?report, "background sync"), - Err(e) => tracing::warn!("background sync failed: {e}"), - } - } - }); - } + daemon.spawn_sync_loop(SYNC_INTERVAL); (Some(lock), daemon) } diff --git a/crates/hephd/src/oauth.rs b/crates/hephd/src/oauth.rs new file mode 100644 index 0000000..9cba7c8 --- /dev/null +++ b/crates/hephd/src/oauth.rs @@ -0,0 +1,327 @@ +//! Client-side OIDC: the OAuth 2.0 device-code flow (RFC 8628), token storage, +//! and refresh (tech-spec §13). +//! +//! A spoke (`local` + `hub_url`) or a `client` uses this to obtain the bearer +//! token it presents to the hub. The flow is **blocking** — it is interactive +//! (`heph auth login` waits for the user to authorize in a browser) and the +//! daemon only refreshes from its blocking pool. Tokens persist in a +//! [`TokenStore`] (the OS keyring in production, in-memory in tests). + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::auth::AuthError; + +/// The standard device-code grant type. +const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; +/// Treat a token as expired this many seconds early, to avoid races. +const EXPIRY_SKEW: u64 = 30; + +/// Persisted OIDC tokens for one provider. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredToken { + /// The bearer token presented to the hub. + pub access_token: String, + /// Used to obtain a fresh access token without re-authenticating. + pub refresh_token: Option, + /// Unix seconds at which `access_token` expires. + pub expires_at: u64, +} + +impl StoredToken { + /// Whether the access token is expired (or within the safety skew). + pub fn is_expired(&self, now: u64) -> bool { + now + EXPIRY_SKEW >= self.expires_at + } +} + +/// Current unix time in seconds. +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Where tokens persist between runs. +pub trait TokenStore: Send + Sync { + /// Load the stored token, if any. + fn load(&self) -> Option; + /// Persist (replacing) the token. + fn save(&self, token: &StoredToken) -> Result<(), AuthError>; + /// Remove any stored token. + fn clear(&self) -> Result<(), AuthError>; +} + +/// An in-memory [`TokenStore`] for tests. +#[derive(Default)] +pub struct MemoryTokenStore(std::sync::Mutex>); + +impl TokenStore for MemoryTokenStore { + fn load(&self) -> Option { + self.0.lock().expect("token lock poisoned").clone() + } + fn save(&self, token: &StoredToken) -> Result<(), AuthError> { + *self.0.lock().expect("token lock poisoned") = Some(token.clone()); + Ok(()) + } + fn clear(&self) -> Result<(), AuthError> { + *self.0.lock().expect("token lock poisoned") = None; + Ok(()) + } +} + +/// A [`TokenStore`] backed by the OS keyring (Keychain / Secret Service). The +/// token JSON is stored as the secret for `(service, account)`. +pub struct KeyringTokenStore { + service: String, + account: String, +} + +impl KeyringTokenStore { + /// Store tokens under this service, keyed by `account` (the hub url). + pub fn new(account: impl Into) -> KeyringTokenStore { + KeyringTokenStore { + service: "hephaestus".into(), + account: account.into(), + } + } + + fn entry(&self) -> Result { + keyring::Entry::new(&self.service, &self.account) + .map_err(|e| AuthError::Provider(e.to_string())) + } +} + +impl TokenStore for KeyringTokenStore { + fn load(&self) -> Option { + let secret = self.entry().ok()?.get_password().ok()?; + serde_json::from_str(&secret).ok() + } + fn save(&self, token: &StoredToken) -> Result<(), AuthError> { + let json = serde_json::to_string(token).map_err(|e| AuthError::Provider(e.to_string()))?; + self.entry()? + .set_password(&json) + .map_err(|e| AuthError::Provider(e.to_string())) + } + fn clear(&self) -> Result<(), AuthError> { + match self.entry()?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(AuthError::Provider(e.to_string())), + } + } +} + +/// The device-authorization response (RFC 8628 §3.2). +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceAuth { + /// The code the daemon polls the token endpoint with. + pub device_code: String, + /// The short code the user types at the verification page. + pub user_code: String, + /// Where the user goes to authorize. + pub verification_uri: String, + /// Verification URI with the code pre-filled (optional). + #[serde(default)] + pub verification_uri_complete: Option, + /// Seconds between polls. + #[serde(default = "default_interval")] + pub interval: u64, + /// Seconds until `device_code` expires. + pub expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +/// Discovery fields the device flow needs. +#[derive(Debug, Deserialize)] +struct DiscoveryDoc { + device_authorization_endpoint: String, + token_endpoint: String, +} + +/// A token-endpoint success response. +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +impl TokenResponse { + fn into_stored(self) -> StoredToken { + StoredToken { + access_token: self.access_token, + refresh_token: self.refresh_token, + expires_at: now_secs() + self.expires_in.unwrap_or(3600), + } + } +} + +/// A token-endpoint error response (RFC 6749 §5.2 / RFC 8628 §3.5). +#[derive(Debug, Deserialize)] +struct TokenErrorBody { + error: String, +} + +/// Drives the OAuth 2.0 device-code flow against one provider. +pub struct DeviceFlow { + client_id: String, + http: ureq::Agent, + device_authorization_endpoint: String, + token_endpoint: String, +} + +impl DeviceFlow { + /// Discover the device + token endpoints from `issuer` and build a flow. + pub fn discover(issuer: &str, client_id: &str) -> Result { + let http = crate::blocking_agent(); + let url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + let mut resp = http + .get(&url) + .call() + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !resp.status().is_success() { + return Err(AuthError::Provider(format!( + "discovery returned {}", + resp.status() + ))); + } + let doc: DiscoveryDoc = resp + .body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string()))?; + Ok(DeviceFlow { + client_id: client_id.to_string(), + http, + device_authorization_endpoint: doc.device_authorization_endpoint, + token_endpoint: doc.token_endpoint, + }) + } + + /// Request a device + user code (RFC 8628 §3.1). + pub fn start(&self, scope: &str) -> Result { + let mut resp = self + .http + .post(&self.device_authorization_endpoint) + .send_form([("client_id", self.client_id.as_str()), ("scope", scope)]) + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !resp.status().is_success() { + return Err(AuthError::Provider(format!( + "device authorization returned {}", + resp.status() + ))); + } + resp.body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string())) + } + + /// Poll the token endpoint until the user authorizes, the code expires, or + /// access is denied. `sleep` is injected so tests need not wait in real + /// time (production passes [`std::thread::sleep`]). + pub fn poll( + &self, + auth: &DeviceAuth, + sleep: impl Fn(Duration), + ) -> Result { + let deadline = now_secs() + auth.expires_in; + let mut interval = auth.interval.max(1); + loop { + if now_secs() >= deadline { + return Err(AuthError::Invalid("device code expired".into())); + } + let mut response = self + .http + .post(&self.token_endpoint) + .send_form([ + ("grant_type", DEVICE_GRANT), + ("device_code", auth.device_code.as_str()), + ("client_id", self.client_id.as_str()), + ]) + .map_err(|e| AuthError::Provider(e.to_string()))?; + + if response.status().is_success() { + let token: TokenResponse = response + .body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string()))?; + return Ok(token.into_stored()); + } + + // A non-success is either "keep waiting" or a terminal failure. + let body: TokenErrorBody = response + .body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string()))?; + match body.error.as_str() { + "authorization_pending" => {} + "slow_down" => interval += 5, + other => return Err(AuthError::Invalid(format!("device flow failed: {other}"))), + } + sleep(Duration::from_secs(interval)); + } + } + + /// Exchange a refresh token for a fresh access token (RFC 6749 §6). + pub fn refresh(&self, refresh_token: &str) -> Result { + let mut response = self + .http + .post(&self.token_endpoint) + .send_form([ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", self.client_id.as_str()), + ]) + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !response.status().is_success() { + return Err(AuthError::Provider(format!( + "token refresh returned {}", + response.status() + ))); + } + let mut token: StoredToken = response + .body_mut() + .read_json::() + .map_err(|e| AuthError::Provider(e.to_string()))? + .into_stored(); + // Providers may omit the refresh token on refresh — keep the old one. + if token.refresh_token.is_none() { + token.refresh_token = Some(refresh_token.to_string()); + } + Ok(token) + } +} + +/// Return a usable access token from `store`, refreshing via `issuer`/`client_id` +/// if the stored one is expired. Returns `None` if nothing is stored; errors if +/// a refresh was needed but failed. Saves a refreshed token back to `store`. +pub fn current_bearer( + store: &dyn TokenStore, + issuer: &str, + client_id: &str, +) -> Result, AuthError> { + let Some(token) = store.load() else { + return Ok(None); + }; + if !token.is_expired(now_secs()) { + return Ok(Some(token.access_token)); + } + let Some(refresh) = token.refresh_token.clone() else { + return Err(AuthError::Invalid( + "token expired and no refresh token".into(), + )); + }; + let refreshed = DeviceFlow::discover(issuer, client_id)?.refresh(&refresh)?; + store.save(&refreshed)?; + Ok(Some(refreshed.access_token)) +} diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 33ec49a..b4734dc 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -11,6 +11,8 @@ //! background-syncs, it reads and writes the hub live. They are stubbed //! accordingly; the daemon never invokes them in this mode. +use std::sync::Arc; + use serde::de::DeserializeOwned; use serde_json::{json, Value}; @@ -19,33 +21,74 @@ use heph_core::{ SyncCursors, Task, TaskState, }; +use crate::oauth::{self, TokenStore}; use crate::rpc::{Response, NOT_FOUND}; +/// How a client obtains the bearer token it presents to the server. +struct AuthCtx { + tokens: Arc, + issuer: String, + client_id: String, +} + /// A no-replica store that proxies to a `server` over HTTP. pub struct RemoteStore { base: String, - http: reqwest::blocking::Client, + http: ureq::Agent, + auth: Option, } impl RemoteStore { - /// Point a client at `server_url` (e.g. `http://hub.example:8787`). + /// Point a client at `server_url` (e.g. `http://hub.example:8787`), + /// unauthenticated. pub fn new(server_url: &str) -> RemoteStore { RemoteStore { base: server_url.trim_end_matches('/').to_string(), - http: reqwest::blocking::Client::new(), + http: crate::blocking_agent(), + auth: None, + } + } + + /// Point a client at `server_url`, attaching a cached OIDC bearer token + /// (refreshed as needed) from `tokens` to every call. + pub fn with_auth( + server_url: &str, + tokens: Arc, + issuer: String, + client_id: String, + ) -> RemoteStore { + RemoteStore { + auth: Some(AuthCtx { + tokens, + issuer, + client_id, + }), + ..RemoteStore::new(server_url) } } /// Issue one `/rpc` call, returning the raw `result` value. fn call(&self, method: &str, params: Value) -> Result { - let response: Response = self - .http - .post(format!("{}/rpc", self.base)) - .json(&json!({ "method": method, "params": params })) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .map_err(|e| Error::Remote(e.to_string()))? - .json() + let mut request = self.http.post(format!("{}/rpc", self.base)); + if let Some(auth) = &self.auth { + let bearer = oauth::current_bearer(auth.tokens.as_ref(), &auth.issuer, &auth.client_id) + .map_err(|e| Error::Remote(e.to_string()))?; + if let Some(bearer) = bearer { + request = request.header("Authorization", format!("Bearer {bearer}")); + } + } + let mut http_response = request + .send_json(json!({ "method": method, "params": params })) + .map_err(|e| Error::Remote(e.to_string()))?; + if !http_response.status().is_success() { + return Err(Error::Remote(format!( + "server returned {}", + http_response.status() + ))); + } + let response: Response = http_response + .body_mut() + .read_json() .map_err(|e| Error::Remote(e.to_string()))?; if let Some(err) = response.error { // Preserve "not found" so callers keep the typed contract. diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 055b9a0..389b7ea 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -10,6 +10,7 @@ //! ops with the configured hub (tech-spec §6.1, §12). use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::Result; use serde_json::{json, Value}; @@ -18,9 +19,18 @@ use tokio::net::{UnixListener, UnixStream}; use heph_core::Store; +use crate::oauth::{self, TokenStore}; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; use crate::sync::{self, SharedStore}; +/// How a spoke obtains the bearer token it presents to its hub (tech-spec §13). +#[derive(Clone)] +struct SpokeAuth { + store: Arc, + issuer: String, + client_id: String, +} + /// The shared, cheaply-cloneable context each connection serves from. #[derive(Clone)] struct Ctx { @@ -28,6 +38,28 @@ struct Ctx { /// The hub this device syncs with, if it is a spoke (`local` + `hub_url`). hub_url: Option, http: reqwest::Client, + /// Token source for authenticated sync (None ⇒ unauthenticated hub). + auth: Option, +} + +impl Ctx { + /// The current bearer token for hub sync (refreshing if expired), or `None` + /// if this spoke has no auth configured / no usable token. + async fn bearer(&self) -> Option { + let auth = self.auth.clone()?; + let result = tokio::task::spawn_blocking(move || { + oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id) + }) + .await; + match result { + Ok(Ok(token)) => token, + Ok(Err(e)) => { + tracing::warn!("could not obtain bearer token: {e}"); + None + } + Err(_) => None, + } + } } /// A running daemon over a shared store (any [`Store`] backend). @@ -43,6 +75,7 @@ impl Daemon { store: Arc::new(Mutex::new(store)), hub_url: None, http: reqwest::Client::new(), + auth: None, }, } } @@ -53,12 +86,47 @@ impl Daemon { self } + /// Configure how this spoke obtains its bearer token for authenticated sync. + /// `None` (or an unset hub) leaves sync unauthenticated. + pub fn with_spoke_auth( + mut self, + auth: Option<(Arc, String, String)>, + ) -> Daemon { + self.ctx.auth = auth.map(|(store, issuer, client_id)| SpokeAuth { + store, + issuer, + client_id, + }); + self + } + /// The shared store handle, for code that needs to reach the same store the /// daemon serves (the hub HTTP router and background sync, tech-spec §6.1). pub fn store(&self) -> SharedStore { self.ctx.store.clone() } + /// If this is a spoke (`hub_url` set), spawn a background task that syncs the + /// op-log with the hub every `interval` (attaching a bearer token when auth + /// is configured). No-op otherwise. + pub fn spawn_sync_loop(&self, interval: Duration) { + let Some(hub) = self.ctx.hub_url.clone() else { + return; + }; + let ctx = self.ctx.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(interval); + loop { + tick.tick().await; + let bearer = ctx.bearer().await; + match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await { + Ok(report) => tracing::debug!(?report, "background sync"), + Err(e) => tracing::warn!("background sync failed: {e}"), + } + } + }); + } + /// Serve connections on `listener` until the task is cancelled. Each /// connection is handled concurrently; all share the one store. pub async fn serve(&self, listener: UnixListener) -> Result<()> { @@ -145,7 +213,8 @@ async fn sync_now(ctx: &Ctx) -> Result { message: "no hub_url configured; this instance is standalone".into(), }); }; - match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http).await { + let bearer = ctx.bearer().await; + match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await { Ok(report) => Ok(json!(report)), Err(e) => Err(RpcError { code: INTERNAL_ERROR, diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 7a8ce59..4080907 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -12,9 +12,10 @@ //! //! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]): //! each side transfers only the tail it hasn't sent/seen. Merge is idempotent, -//! so a re-pushed op the hub already has is a harmless no-op. Auth is deferred to -//! tech-spec §13 (slice 10) — the endpoint is currently unauthenticated and -//! scoped to the hub's single owner. +//! so a re-pushed op the hub already has is a harmless no-op. When the hub is +//! configured with a verifier ([`crate::auth`]), every route requires a valid +//! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach +//! that token via the `bearer` argument to [`sync_once`]. use std::sync::{Arc, Mutex}; @@ -234,6 +235,7 @@ pub async fn sync_once( store: SharedStore, hub_url: &str, http: &reqwest::Client, + bearer: Option<&str>, ) -> Result { let base = hub_url.trim_end_matches('/'); let mut report = SyncReport::default(); @@ -248,6 +250,9 @@ pub async fn sync_once( if let Some(after) = &cursors.last_pulled_hlc { req = req.query(&[("after", after)]); } + if let Some(token) = bearer { + req = req.bearer_auth(token); + } let pulled: OpsBody = req.send().await?.error_for_status()?.json().await?; report.pulled = pulled.ops.len(); if !pulled.ops.is_empty() { @@ -268,11 +273,13 @@ pub async fn sync_once( if !to_push.is_empty() { // `ops_since` returns HLC order, so the last is the new cursor. let max_pushed = to_push.last().map(|o| o.hlc.clone()); - http.post(format!("{base}/sync/push")) - .json(&OpsBody { ops: to_push }) - .send() - .await? - .error_for_status()?; + let mut req = http + .post(format!("{base}/sync/push")) + .json(&OpsBody { ops: to_push }); + if let Some(token) = bearer { + req = req.bearer_auth(token); + } + req.send().await?.error_for_status()?; if let Some(cursor) = max_pushed { let hub = hub_url.to_string(); with_store(&store, move |s| s.record_sync(&hub, Some(&cursor), None)).await?; diff --git a/crates/hephd/tests/auth_hub.rs b/crates/hephd/tests/auth_hub.rs index c594cf0..dd8d013 100644 --- a/crates/hephd/tests/auth_hub.rs +++ b/crates/hephd/tests/auth_hub.rs @@ -27,7 +27,7 @@ use rsa::{RsaPrivateKey, RsaPublicKey}; use serde::Serialize; use serde_json::{json, Value}; -use heph_core::{FixedClock, LocalStore}; +use heph_core::{FixedClock, LocalStore, NewNode, Store}; use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; use hephd::sync::{self, SharedStore}; @@ -102,14 +102,15 @@ fn start_hub(verifier: Option>) -> String { /// `POST /rpc health` with an optional bearer token; return the HTTP status. fn rpc_health_status(base: &str, token: Option<&str>) -> u16 { - let http = reqwest::blocking::Client::new(); - let mut req = http - .post(format!("{base}/rpc")) - .json(&json!({ "method": "health", "params": {} })); + let mut req = ureq::post(format!("{base}/rpc")); if let Some(t) = token { req = req.header("Authorization", format!("Bearer {t}")); } - req.send().unwrap().status().as_u16() + match req.send_json(json!({ "method": "health", "params": {} })) { + Ok(resp) => resp.status().as_u16(), + Err(ureq::Error::StatusCode(code)) => code, + Err(e) => panic!("request failed: {e}"), + } } fn stub(pairs: &[(&str, &str)]) -> Option> { @@ -292,3 +293,67 @@ fn oidc_verifier_rejects_forgeries() { let token = encode(&header, &nosub, &rsa_key()).unwrap(); assert!(verifier.verify(&token).is_err(), "missing sub"); } + +// --- layer 3: the loop closes — spoke ⇄ authed hub over real HTTP ----------- + +const OWNER: &str = "canonical-user"; + +/// Adopt the canonical owner over a temp store and share it. +fn shared_replica() -> SharedStore { + let dir = Box::leak(Box::new(tempfile::tempdir().unwrap())); + let mut store = + LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); + store.adopt_owner(OWNER).unwrap(); + Arc::new(Mutex::new(store)) +} + +#[tokio::test] +async fn spoke_syncs_to_an_authed_hub_only_with_a_valid_token() { + let issuer = start_mock_idp(); + + // A hub that requires tokens issued by the mock IdP. + let hub_store = shared_replica(); + let verifier = Arc::new(OidcVerifier::new(issuer.clone(), AUDIENCE)); + let hub_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let hub_url = format!("http://{}", hub_listener.local_addr().unwrap()); + { + let app = sync::router(hub_store.clone(), Some(verifier)); + tokio::spawn(async move { axum::serve(hub_listener, app).await.unwrap() }); + } + + // A spoke with a local node to push. + let spoke = shared_replica(); + let node_id = spoke + .lock() + .unwrap() + .create_node(NewNode::doc("Roof", "shingles")) + .unwrap() + .id; + + let http = reqwest::Client::new(); + + // Without a token the hub refuses the exchange. + assert!( + sync::sync_once(spoke.clone(), &hub_url, &http, None) + .await + .is_err(), + "unauthenticated sync must fail" + ); + + // With a valid token signed by the IdP, the push is accepted and the node + // reaches the hub. + let token = sign(&good_claims(&issuer), KID); + let report = sync::sync_once(spoke.clone(), &hub_url, &http, Some(&token)) + .await + .expect("authenticated sync succeeds"); + assert!(report.pushed > 0, "spoke pushed nothing"); + assert!( + hub_store + .lock() + .unwrap() + .get_node(&node_id) + .unwrap() + .is_some(), + "node did not reach the hub" + ); +} diff --git a/crates/hephd/tests/oauth.rs b/crates/hephd/tests/oauth.rs new file mode 100644 index 0000000..f61c872 --- /dev/null +++ b/crates/hephd/tests/oauth.rs @@ -0,0 +1,153 @@ +//! Device-code flow + token store (tech-spec §13, slice 10b), offline. +//! +//! A mock OAuth provider serves discovery, the device-authorization endpoint, +//! and the token endpoint (which reports `authorization_pending` once before +//! issuing tokens). We drive `DeviceFlow` against it with an injected no-op +//! sleep, so the polling loop is exercised deterministically and instantly. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; +use std::thread; +use std::time::Duration; + +use axum::extract::{Form, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use hephd::oauth::{DeviceFlow, MemoryTokenStore, StoredToken, TokenStore}; + +#[derive(Clone)] +struct IdpState { + base: String, + /// How many times the token endpoint has been polled for the device code. + polls: Arc, +} + +/// Start a mock OIDC provider; return its base URL. +fn start_idp() -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tx.send(base.clone()).unwrap(); + let state = IdpState { + base, + polls: Arc::new(AtomicUsize::new(0)), + }; + let app = Router::new() + .route("/.well-known/openid-configuration", get(discovery)) + .route("/device", post(device_authorization)) + .route("/token", post(token)) + .with_state(state); + axum::serve(listener, app).await.unwrap(); + }); + }); + rx.recv_timeout(Duration::from_secs(5)).unwrap() +} + +async fn discovery(State(s): State) -> Json { + Json(json!({ + "issuer": s.base, + "device_authorization_endpoint": format!("{}/device", s.base), + "token_endpoint": format!("{}/token", s.base), + })) +} + +async fn device_authorization(State(s): State) -> Json { + Json(json!({ + "device_code": "dev-code-xyz", + "user_code": "WDJB-MJHT", + "verification_uri": format!("{}/activate", s.base), + "interval": 1, + "expires_in": 300, + })) +} + +async fn token(State(s): State, Form(form): Form>) -> Response { + match form.get("grant_type").map(String::as_str) { + Some("urn:ietf:params:oauth:grant-type:device_code") => { + // Report pending on the first poll, then issue tokens. + if s.polls.fetch_add(1, Ordering::SeqCst) == 0 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "authorization_pending" })), + ) + .into_response(); + } + Json(json!({ + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + })) + .into_response() + } + Some("refresh_token") => Json(json!({ + "access_token": "access-2", + "expires_in": 3600, + })) + .into_response(), + _ => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "unsupported_grant_type" })), + ) + .into_response(), + } +} + +#[test] +fn device_flow_polls_pending_then_issues_a_token() { + let issuer = start_idp(); + let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap(); + + let auth = flow.start("openid").unwrap(); + assert_eq!(auth.user_code, "WDJB-MJHT"); + assert!(auth.verification_uri.contains("/activate")); + + // No real waiting — the injected sleep is a no-op. + let token = flow.poll(&auth, |_| {}).unwrap(); + assert_eq!(token.access_token, "access-1"); + assert_eq!(token.refresh_token.as_deref(), Some("refresh-1")); + assert!(token.expires_at > 0); +} + +#[test] +fn refresh_keeps_the_old_refresh_token_when_omitted() { + let issuer = start_idp(); + let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap(); + let refreshed = flow.refresh("refresh-1").unwrap(); + assert_eq!(refreshed.access_token, "access-2"); + // The provider omitted a new refresh token, so the old one is retained. + assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-1")); +} + +#[test] +fn memory_token_store_round_trips_and_reports_expiry() { + let store = MemoryTokenStore::default(); + assert!(store.load().is_none()); + + let token = StoredToken { + access_token: "a".into(), + refresh_token: Some("r".into()), + expires_at: 10_000, + }; + store.save(&token).unwrap(); + assert_eq!(store.load(), Some(token.clone())); + + assert!(!token.is_expired(5_000), "still valid well before expiry"); + assert!( + token.is_expired(10_000), + "expired at the boundary (with skew)" + ); + + store.clear().unwrap(); + assert!(store.load().is_none()); +} diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs index c9c8e21..de8b7bf 100644 --- a/crates/hephd/tests/sync_http.rs +++ b/crates/hephd/tests/sync_http.rs @@ -70,9 +70,13 @@ async fn a_node_propagates_a_to_hub_to_b() { }; // A pushes to the hub; B pulls from it. - let up = sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); + let up = sync::sync_once(a.clone(), &hub_url, &http, None) + .await + .unwrap(); assert!(up.pushed > 0, "A pushed nothing"); - let down = sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + let down = sync::sync_once(b.clone(), &hub_url, &http, None) + .await + .unwrap(); assert!(down.applied > 0, "B applied nothing"); let on_b = b.lock().unwrap().get_node(&id).unwrap().expect("reached B"); @@ -98,8 +102,12 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { .unwrap() .node_id }; - sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); - sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + sync::sync_once(a.clone(), &hub_url, &http, None) + .await + .unwrap(); + sync::sync_once(b.clone(), &hub_url, &http, None) + .await + .unwrap(); // Divergent offline edits on conflict-tracked fields; B's is later (higher // HLC) so its whole scalar snapshot wins. @@ -116,8 +124,12 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { // A few exchanges in each direction settle it. for _ in 0..2 { - sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); - sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + sync::sync_once(a.clone(), &hub_url, &http, None) + .await + .unwrap(); + sync::sync_once(b.clone(), &hub_url, &http, None) + .await + .unwrap(); } let ta = a.lock().unwrap().get_task(&task_id).unwrap().unwrap(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index e5bec27..c874259 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -11,5 +11,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. - Network sync over HTTP (§6.1, §12, slice 9a): `hephd --mode server` exposes a sync hub (`POST /sync/push`, `GET /sync/pull?after=`, axum) over the same store; `hephd --mode local --hub-url ` becomes a spoke that background-syncs its op-log with that hub (and on demand via the `sync.now`/`sync.status` RPC). Exchange is incremental by HLC cursor (`sync_state`) and idempotent. The merge engine is `heph-core`'s, unchanged. Unauthenticated/single-owner for now (auth lands with OIDC). `conflicts.list`/`conflicts.resolve` are now reachable over the daemon socket. - Client mode (§3.1, slice 9b): `hephd --mode client --server-url ` runs with no local replica, proxying every store call to a server's `POST /rpc` endpoint (the full daemon API over HTTP). The daemon is now backend-agnostic (`local`/`server` front a `LocalStore`, `client` a `RemoteStore`), so surfaces see the same unix-socket API in every mode. -- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). Client-side login (device-code flow) lands next. +- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). +- Client authentication (§13, slice 10b): `heph auth login --hub-url --issuer --client-id ` runs the OAuth 2.0 device-code flow and caches the token in the OS keyring; spokes and `client` mode attach it to hub requests, refreshing on expiry (`--oidc-issuer`/`--oidc-client-id`). Offline-tested against a mock OAuth server and a full spoke-to-authenticated-hub loop. (Auth/proxy HTTP uses the runtime-free `ureq`, since `reqwest::blocking` is unsafe inside the async daemon.) - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 5998b5b..9deae82 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **108 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **112 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -342,12 +342,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10). - ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. - ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). -- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. +- ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. +- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`heph auth login`), token cache in the OS keyring + auto-refresh, the spoke attaching its bearer to `sync_once`/RPC, and local→authed **adoption** (owner-embedded deterministic-id rewrite). Multi-tenant hub (owner-per-token storage) remains a future extension beyond the current single-tenant gate. +1. ⏳ **Adoption refinement + multi-tenant (§13):** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. 2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). ## Related -- 2.50.1 (Apple Git-155) From 87c76da6593e88e9474759b5e848ffdd73f825e5 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 19:07:03 -0700 Subject: [PATCH 19/91] =?UTF-8?q?docs:=20refresh=20resume=20state=20for=20?= =?UTF-8?q?next=20session=20(AGENTS=20structure=20+=20=C2=A714=20order)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend is feature-complete; point the trackers at heph.nvim as the next slice so a cold-start session resumes correctly. - AGENTS.md: update the (always-in-context) project-structure block — all three daemon modes + sync + auth are done; CLI gained search/journal/auth; heph.nvim is the next slice. Point at tech-spec §14 as the live tracker. - tech-spec §14: reorder "not yet done" so heph.nvim is #1 (the remaining build slice), with adoption refinement and the dependency-refresh sweep as non-blocking follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 10 +++++----- docs/reference/tech-spec.md | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 687a2eb..caec98b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,15 +41,15 @@ See [[agent-change-process]] for the full methodology. ## Project Structure -A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; crates are added to the workspace as their slice begins, so not every crate below exists yet. +A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; the **Rust backend is feature-complete** (all three runtime modes + sync + OIDC auth) — the remaining v1 work is `heph.nvim`. The live progress tracker is **[[tech-spec]] §14**. ``` ./Cargo.toml # workspace manifest (shared deps + members) ./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction, - # recurrence, "what is next?" ranking, op-log/HLC/CRDT sync -./crates/hephd/ # daemon: local mode done (JSON-RPC over unix socket + file lock); server/client modes planned -./crates/heph/ # CLI: next/task/doc/get/export (thin client of hephd); `heph conflicts` planned -./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim + # recurrence, "what is next?" ranking, op-log/HLC/CRDT (yrs) sync +./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc + OIDC auth +./crates/heph/ # CLI (thin client of hephd): next/task/doc/get/export/search/journal/auth +./heph.nvim/ # Neovim plugin (planned, next slice): primary surface; replaces obsidian.nvim ./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content ./docs/changelog.d/ # towncrier fragments for noteworthy changes ./.dagger/ # Dagger module (src/hephaestus_ci/) backing docs builds and releases diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 9deae82..82f064d 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -348,8 +348,11 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi **Not yet done (resume order)** -1. ⏳ **Adoption refinement + multi-tenant (§13):** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). +> The Rust backend is feature-complete; `heph.nvim` is the one remaining build slice. The rest are non-blocking polish + an end-of-v1 sweep (§11). + +1. ⏳ **`heph.nvim` (§8) — the next slice, the primary surface:** obsidian.nvim parity + task/agenda views over the `hephd` unix socket; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). First non-Rust slice — likely wants its own short design pass (Lua layout, RPC client, CI runner setup) before coding. +2. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +3. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related -- 2.50.1 (Apple Git-155) From ee865e563585bc274a5e55891ee707f17f78c764 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 20:33:29 -0700 Subject: [PATCH 20/91] heph.nvim: RPC client + buffer editing + wiki-links + journal (slice 11a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primary surface begins (tech-spec §8): a Neovim plugin that is a thin client of the local hephd over its unix-socket JSON-RPC. - node.resolve {title} → Node|null (heph-core Store + dispatch): exact, owner-scoped, non-tombstoned alias-then-title match — the same mapping that materializes wiki links, so follow-link jumps to the node the stored link points at (never fuzzy search). Unit + rpc_socket integration tests. - heph.nvim/: vim.uv unix-socket JSON-RPC client (blocking call via vim.wait, id-demuxed, partial-line buffered, luanil so JSON null → Lua nil; isolated Sessions for tests). Buffer-backed nodes (heph://node/, acwrite; BufReadCmd→node.get / BufWriteCmd→node.update, whole-buffer body round-trips exactly through the CRDT). [[wiki-link]] follow on . Daily journal. :Heph command surface + completion. - Headless e2e (§9): a self-contained busted-style runner (tests/e2e/runner.lua) — no external plugins, no network, deterministic CI exit codes. Specs: journal round-trip, follow-link (+ unresolved no-op), link-two-docs/backlink. `make -C heph.nvim test` builds hephd and runs it. Docs: heph-nvim reference card, §14 tracker (11a done; 11b/11c/11d queued), changelog fragment. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- crates/heph-core/src/sqlite/links.rs | 7 +- crates/heph-core/src/sqlite/mod.rs | 29 ++++ crates/heph-core/src/store.rs | 9 + crates/hephd/src/remote.rs | 4 + crates/hephd/src/rpc.rs | 9 + crates/hephd/tests/rpc_socket.rs | 27 +++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 67 ++++++++ docs/reference/reference.md | 1 + docs/reference/tech-spec.md | 13 +- heph.nvim/Makefile | 16 ++ heph.nvim/README.md | 55 ++++++ heph.nvim/lua/heph/command.lua | 62 +++++++ heph.nvim/lua/heph/config.lua | 39 +++++ heph.nvim/lua/heph/daemon.lua | 58 +++++++ heph.nvim/lua/heph/init.lua | 33 ++++ heph.nvim/lua/heph/journal.lua | 19 +++ heph.nvim/lua/heph/link.lua | 62 +++++++ heph.nvim/lua/heph/node.lua | 52 ++++++ heph.nvim/lua/heph/rpc.lua | 202 +++++++++++++++++++++++ heph.nvim/lua/heph/util.lua | 26 +++ heph.nvim/plugin/heph.lua | 52 ++++++ heph.nvim/tests/e2e/backlink_spec.lua | 32 ++++ heph.nvim/tests/e2e/follow_link_spec.lua | 40 +++++ heph.nvim/tests/e2e/helpers.lua | 137 +++++++++++++++ heph.nvim/tests/e2e/journal_spec.lua | 32 ++++ heph.nvim/tests/e2e/run.lua | 22 +++ heph.nvim/tests/e2e/runner.lua | 140 ++++++++++++++++ 29 files changed, 1240 insertions(+), 10 deletions(-) create mode 100644 docs/reference/heph-nvim.md create mode 100644 heph.nvim/Makefile create mode 100644 heph.nvim/README.md create mode 100644 heph.nvim/lua/heph/command.lua create mode 100644 heph.nvim/lua/heph/config.lua create mode 100644 heph.nvim/lua/heph/daemon.lua create mode 100644 heph.nvim/lua/heph/init.lua create mode 100644 heph.nvim/lua/heph/journal.lua create mode 100644 heph.nvim/lua/heph/link.lua create mode 100644 heph.nvim/lua/heph/node.lua create mode 100644 heph.nvim/lua/heph/rpc.lua create mode 100644 heph.nvim/lua/heph/util.lua create mode 100644 heph.nvim/plugin/heph.lua create mode 100644 heph.nvim/tests/e2e/backlink_spec.lua create mode 100644 heph.nvim/tests/e2e/follow_link_spec.lua create mode 100644 heph.nvim/tests/e2e/helpers.lua create mode 100644 heph.nvim/tests/e2e/journal_spec.lua create mode 100644 heph.nvim/tests/e2e/run.lua create mode 100644 heph.nvim/tests/e2e/runner.lua diff --git a/README.md b/README.md index f87a9b0..e0b4162 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | `client` mode + `RemoteStore` (online-only, no replica) | ✅ done | | OIDC hub auth — bearer-token verification + owner gate | ✅ done | | OIDC client — device-code login, keyring token cache | ✅ done | -| `heph.nvim` (primary surface) | ⏳ next | -| `heph.nvim` (primary surface) | ⏳ | +| `heph.nvim` (primary surface) — RPC client, buffer-backed editing, wiki-link follow, journal (slice 11a) | ✅ done | +| `heph.nvim` — task/agenda views, promotion, CI runner (slices 11b–11c) | ⏳ next | ## Architecture diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index b29d3f2..0557464 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -123,7 +123,7 @@ pub(super) fn sync_wiki_links( let mut desired: Vec = Vec::new(); let mut desired_set: HashSet = HashSet::new(); for target in extract(body).wiki_links { - if let Some(dst) = resolve(conn, owner, &target)? { + if let Some(dst) = resolve_id(conn, owner, &target)? { if dst != src_id && desired_set.insert(dst.clone()) { desired.push(dst); } @@ -159,8 +159,9 @@ pub(super) fn sync_wiki_links( } /// Resolve a wiki-link target to a node id for this owner, matching an alias -/// first, then an exact title. `None` if nothing matches. -fn resolve(conn: &Connection, owner: &str, target: &str) -> Result> { +/// first, then an exact title. `None` if nothing matches. Shared by `wiki` +/// link materialization and the `node.resolve` surface (tech-spec §5, §6). +pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result> { let by_alias: Option = conn .query_row( "SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index ee160fb..822da3b 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -200,6 +200,13 @@ impl Store for LocalStore { nodes::tombstone(&self.conn, &self.owner_id, now, id) } + fn resolve_node(&self, title: &str) -> Result> { + match links::resolve_id(&self.conn, &self.owner_id, title)? { + Some(id) => nodes::get(&self.conn, &id), + None => Ok(None), + } + } + fn create_task(&mut self, input: NewTask) -> Result { let now = self.clock.now_ms(); tasks::create(&mut self.conn, &self.owner_id, now, input) @@ -381,6 +388,28 @@ mod tests { assert_eq!(v, latest_version()); } + #[test] + fn resolve_node_matches_exact_title_not_fuzzy() { + use crate::model::NewNode; + let mut store = store_at(1); + let roof = store.create_node(NewNode::doc("Roof", "shingles")).unwrap(); + // A fuzzy/FTS match would surface this for "Roof"; exact resolve must not. + store + .create_node(NewNode::doc("Roofing options", "estimates")) + .unwrap(); + + let got = store.resolve_node("Roof").unwrap().expect("exact title"); + assert_eq!(got.id, roof.id); + + // A prefix is not an exact title — resolves to nothing, never the + // fuzzy neighbour. + assert!(store.resolve_node("Roo").unwrap().is_none()); + + // Tombstoned nodes are excluded. + store.tombstone_node(&roof.id).unwrap(); + assert!(store.resolve_node("Roof").unwrap().is_none()); + } + #[test] fn opening_twice_is_idempotent_for_the_local_user() { let conn = Connection::open_in_memory().unwrap(); diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 162cf70..a9c50f7 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -40,6 +40,15 @@ pub trait Store { /// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3). fn tombstone_node(&mut self, id: &str) -> Result<()>; + /// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an + /// alias match first, then an exact, owner-scoped, non-tombstoned title + /// match; `None` if nothing matches (an unresolved link is allowed, §5). + /// + /// This is the same mapping the store uses to materialize `wiki` links, so + /// a surface's "follow link under cursor" jumps to the *same* node the + /// stored link points at — unlike fuzzy `search` (tech-spec §6, §8). + fn resolve_node(&self, title: &str) -> Result>; + // --- tasks --- /// Create a committed task, auto-creating its canonical context `doc` and diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index b4734dc..d777625 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -133,6 +133,10 @@ impl Store for RemoteStore { self.call("node.tombstone", json!({ "id": id })).map(|_| ()) } + fn resolve_node(&self, title: &str) -> Result> { + self.call_as("node.resolve", json!({ "title": title })) + } + fn create_task(&mut self, input: NewTask) -> Result { self.call_as("task.create", json!(input)) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index e1ca489..874b98b 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -109,6 +109,11 @@ struct IdParam { id: String, } +#[derive(Deserialize)] +struct ResolveParams { + title: String, +} + #[derive(Deserialize)] struct UpdateParams { id: String, @@ -226,6 +231,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ResolveParams = parse(params)?; + json!(store.resolve_node(&p.title)?) + } "task.create" => { let p: NewTask = parse(params)?; json!(store.create_task(p)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 8951167..47abfa9 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -75,6 +75,33 @@ fn node_create_and_get_round_trip_over_socket() { assert_eq!(missing, Value::Null); } +#[test] +fn node_resolve_is_exact_not_fuzzy_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let target = c + .call("node.create", json!({ "kind": "doc", "title": "Roof" })) + .unwrap(); + let target_id = target["id"].as_str().unwrap().to_string(); + // A fuzzy neighbour that an FTS `search` for "Roof" would also surface. + c.call( + "node.create", + json!({ "kind": "doc", "title": "Roofing options" }), + ) + .unwrap(); + + // Exact title resolves to exactly the target node. + let got = c.call("node.resolve", json!({ "title": "Roof" })).unwrap(); + assert_eq!(got["id"], target_id); + + // An unresolved link is JSON null, not an error (tech-spec §5). + let missing = c + .call("node.resolve", json!({ "title": "Nonexistent" })) + .unwrap(); + assert_eq!(missing, Value::Null); +} + #[test] fn task_create_appears_in_next_with_context_link() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index c874259..a1a770d 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -14,3 +14,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). - Client authentication (§13, slice 10b): `heph auth login --hub-url --issuer --client-id ` runs the OAuth 2.0 device-code flow and caches the token in the OS keyring; spokes and `client` mode attach it to hub requests, refreshing on expiry (`--oidc-issuer`/`--oidc-client-id`). Offline-tested against a mock OAuth server and a full spoke-to-authenticated-hub loop. (Auth/proxy HTTP uses the runtime-free `ureq`, since `reqwest::blocking` is unsafe inside the async daemon.) - CI runs the Rust suite (fmt/clippy/test) via the project build hook. +- `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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md new file mode 100644 index 0000000..0a5854e --- /dev/null +++ b/docs/reference/heph-nvim.md @@ -0,0 +1,67 @@ +--- +title: heph.nvim +modified: 2026-06-01 +tags: + - reference + - design +--- + +# heph.nvim + +The primary user surface (tech-spec §8): a Neovim plugin that replaces +obsidian.nvim and is a **thin client of the local `hephd`** over its +unix-socket JSON-RPC. Notes, journals, and tasks are edited as ordinary +buffers; the daemon owns all storage and sync. Built in checkpointed slices on +`feature/v1-prototype`; this card tracks the stable surface as it lands. + +## Architecture + +`heph.nvim/lua/heph/` modules, each small and single-purpose: + +| Module | Responsibility | +|---|---| +| `rpc` | libuv (`vim.uv`) unix-socket JSON-RPC client. A blocking `call()` is built over the async pipe by pumping the loop with `vim.wait` until the matching id returns. Demuxes responses by id; partial lines are buffered; JSON `null` decodes to Lua `nil` (`luanil`). A `Session` is one connection — the module keeps a default singleton and lets tests open isolated sessions. | +| `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). | +| `config` / `init` | `setup(opts)`, socket resolution, default keymaps. | +| `command` | The `:Heph ` dispatch + completion. | + +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 RPC dependencies + +Beyond the existing methods (tech-spec §6), the plugin relies on +**`node.resolve {title} → Node | null`**: an exact, owner-scoped, +non-tombstoned alias-then-title match — the same mapping the store uses to +materialize `wiki` links, so "follow link under cursor" jumps to the *same* +node the stored link points at. + +## Commands (as of slice 11a) + +| Command | Action | +|---|---| +| `:Heph today` | Open today's journal | +| `:Heph journal ` | Open a dated journal | +| `:Heph follow` (also `` in a node buffer) | Follow the `[[link]]` under the cursor | +| `:Heph open ` | Open a node buffer by id | + +Task/agenda views (`:Heph next`/`list`/`capture`, set-attention, done/drop), +the per-task log, and context-item **promotion** arrive in slices 11b/11c. + +## Testing (tech-spec §9) + +The headless e2e suite drives the plugin in `nvim --headless` against a real +`hephd` over a temp socket, asserting both buffer contents and resulting DB +state (via an isolated RPC session). It uses a **self-contained busted-style +runner** (`tests/e2e/runner.lua`) — no external plugins, no network — so CI is +deterministic. `make test` builds the daemon and runs it; a deliberately +failing spec exits non-zero (no false-green). + +## Related + +- [[tech-spec]] — §8 surface spec, §6 RPC API, §9 testing strategy +- [[design]] — the mode model (Tactical/Strategic/Organizational) and rationale diff --git a/docs/reference/reference.md b/docs/reference/reference.md index 1117159..bbbd30e 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -13,6 +13,7 @@ Technical reference material for the repository tooling that ships with this pro ## Project - [[tech-spec]] — Hephaestus technical specification (data model, RPC API, "what is next?" ranking, recurrence, testing strategy, v1 scope) +- [[heph-nvim]] — The Neovim plugin surface: architecture, buffer-backed editing, RPC dependencies, commands, and the headless e2e harness ## Template Surface Area diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 82f064d..aa9e5ea 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **112 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **114 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`make -C heph.nvim test`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slice 11a). **Done** @@ -345,14 +345,17 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). +- ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal `, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic CI exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `make -C heph.nvim test` builds the daemon and runs it. **Not yet done (resume order)** -> The Rust backend is feature-complete; `heph.nvim` is the one remaining build slice. The rest are non-blocking polish + an end-of-v1 sweep (§11). +> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a done). The rest are non-blocking polish + an end-of-v1 sweep (§11). -1. ⏳ **`heph.nvim` (§8) — the next slice, the primary surface:** obsidian.nvim parity + task/agenda views over the `hephd` unix socket; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). First non-Rust slice — likely wants its own short design pass (Lua layout, RPC client, CI runner setup) before coding. -2. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -3. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **`heph.nvim` slice 11b (§8) — task views:** enrich `list` to titled rows; Tactical `next` + Organizational `list` views; task capture, set-attention, mark done/dropped; per-task log quick-append; `vim.ui.select` pickers (Telescope auto-upgrade when present). e2e: capture→next→context→checklist→done, and the recurring fresh-checklist workflow. +2. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; extend `.forgejo/scripts/build` to build `hephd` and run the nvim e2e suite (runner needs `neovim`; the self-contained busted runner needs **no** plenary). +3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +4. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +5. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related diff --git a/heph.nvim/Makefile b/heph.nvim/Makefile new file mode 100644 index 0000000..0243c55 --- /dev/null +++ b/heph.nvim/Makefile @@ -0,0 +1,16 @@ +# heph.nvim — headless e2e suite (tech-spec §9). +# +# `make test` builds the daemon, then drives the plugin in headless Neovim +# against a real hephd over a temp socket, via a self-contained busted-style +# runner (no external plugins, no network — see tests/e2e/runner.lua). + +HEPHD_BIN ?= $(CURDIR)/../target/debug/hephd +export HEPHD_BIN + +.PHONY: test build-hephd + +build-hephd: + cargo build -p hephd --manifest-path $(CURDIR)/../Cargo.toml + +test: build-hephd + nvim --headless -u NONE -c "luafile tests/e2e/run.lua" diff --git a/heph.nvim/README.md b/heph.nvim/README.md new file mode 100644 index 0000000..173e67c --- /dev/null +++ b/heph.nvim/README.md @@ -0,0 +1,55 @@ +# heph.nvim + +The primary surface for [hephaestus](../README.md) — an obsidian.nvim +replacement that is a thin client of the local `hephd` daemon over its +unix-socket JSON-RPC (tech-spec §8). Notes, journals, and tasks are edited as +ordinary Neovim buffers; saving routes through the daemon. + +> **Status:** built in checkpointed slices. **11a (this slice)** delivers the +> RPC client, buffer-backed editing, `[[wiki-link]]` following, and the daily +> journal. Task/agenda views (`:Heph next`/`list`/capture), the per-task log, +> and promotion arrive in 11b/11c. See tech-spec §14. + +## How it works + +- **Buffer-backed nodes.** A node is edited in a buffer named + `heph://node/`. Opening it loads the markdown body via `node.get`; `:w` + saves the whole buffer back via `node.update` (the backend diffs it into a + text CRDT, so sending the full buffer is correct). `buftype=acwrite`. +- **Links.** Press `` on a `[[wiki-link]]` to jump to its node (resolved + exactly via `node.resolve`). Unresolved links are allowed — they just notify. +- **Journal.** `:Heph today` (or `:Heph journal YYYY-MM-DD`) opens a dated + journal note; the id is deterministic so reopening is idempotent. + +## Setup + +Requires a running `hephd` (`hephd --mode local`) and Neovim ≥ 0.10. + +```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 +}) +``` + +## Commands + +| Command | Action | +|---|---| +| `:Heph today` | Open today's journal | +| `:Heph journal ` | Open a dated journal | +| `:Heph follow` | Follow the `[[link]]` under the cursor (also ``) | +| `:Heph open ` | Open a node buffer by id | + +## Tests + +The e2e suite drives the plugin in headless Neovim against a real daemon: + +```bash +make test # builds hephd, runs the headless e2e suite +``` + +The suite uses a small self-contained busted-style runner +(`tests/e2e/runner.lua`) — no external plugins and no network, so it is +deterministic in CI. It needs only Neovim (≥ 0.10) and a built `hephd`. diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua new file mode 100644 index 0000000..8953d63 --- /dev/null +++ b/heph.nvim/lua/heph/command.lua @@ -0,0 +1,62 @@ +--- The `:Heph ` user-command surface. Tactical/Organizational task +--- views (next/list/capture/...) arrive with slice 11b; this is the knowledge- +--- base core (journal, links). + +local M = {} + +--- subcommand -> handler(args: string[]) +M.subs = { + today = function() + require("heph.journal").open() + end, + journal = function(args) + require("heph.journal").open(args[1]) + end, + follow = function() + require("heph.link").follow() + end, + open = function(args) + if args[1] then + require("heph.node").open(args[1]) + end + end, +} + +--- `:Heph` entry point. +function M.run(opts) + local args = opts.fargs + local sub = args[1] + if not sub then + require("heph.util").notify("usage: :Heph <" .. table.concat(M.names(), "|") .. ">", vim.log.levels.WARN) + return + end + local handler = M.subs[sub] + if not handler then + require("heph.util").notify("unknown subcommand: " .. sub, vim.log.levels.ERROR) + return + end + local ok, err = pcall(handler, vim.list_slice(args, 2)) + if not ok then + require("heph.util").notify(tostring(err), vim.log.levels.ERROR) + end +end + +--- Sorted subcommand names. +function M.names() + local names = vim.tbl_keys(M.subs) + table.sort(names) + return names +end + +--- Completion: subcommand names at the first position. +function M.complete(arglead, cmdline, _cursorpos) + -- Only complete the subcommand token (first arg after :Heph). + if cmdline:match("^%s*Heph%s+%S*$") then + return vim.tbl_filter(function(n) + return n:find(arglead, 1, true) == 1 + end, M.names()) + end + return {} +end + +return M diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua new file mode 100644 index 0000000..9453e1a --- /dev/null +++ b/heph.nvim/lua/heph/config.lua @@ -0,0 +1,39 @@ +--- Configuration defaults, socket resolution, and default keymaps. + +local M = {} + +M.defaults = { + --- Path to hephd's unix socket. `nil` → resolved to 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. + 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. +function M.resolve_socket(opt) + if opt and #opt > 0 then + return opt + end + local xdg = vim.env.XDG_RUNTIME_DIR + local base = (xdg and #xdg > 0) and xdg or (vim.env.TMPDIR or "/tmp") + return (base:gsub("/+$", "")) .. "/heph/hephd.sock" +end + +--- Apply the default keymaps (no-op when `opts.keymaps` is false). +function M.apply_keymaps(opts) + if not opts.keymaps then + return + end + local map = vim.keymap.set + map("n", "hj", function() + require("heph.journal").open() + end, { desc = "heph: today's journal" }) + -- Task/agenda maps are added with their views in slice 11b. +end + +return M diff --git a/heph.nvim/lua/heph/daemon.lua b/heph.nvim/lua/heph/daemon.lua new file mode 100644 index 0000000..c3ff8e8 --- /dev/null +++ b/heph.nvim/lua/heph/daemon.lua @@ -0,0 +1,58 @@ +--- Locate, spawn, and wait on a `hephd` daemon. Shared by optional autostart +--- and by the e2e harness (so test readiness uses the same definition the +--- plugin does). + +local uv = vim.uv or vim.loop + +local M = {} + +--- 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) + local args = { "--mode", "local" } + if opts.db then + table.insert(args, "--db") + table.insert(args, opts.db) + end + if opts.socket then + table.insert(args, "--socket") + table.insert(args, opts.socket) + end + local handle, pid = uv.spawn(opts.bin or "hephd", { + args = args, + stdio = { nil, nil, opts.stderr }, + }, function(code, signal) + if opts.on_exit then + opts.on_exit(code, signal) + end + end) + if not handle then + error("heph: failed to spawn hephd (bin=" .. (opts.bin or "hephd") .. ")") + end + return { handle = handle, pid = pid } +end + +--- Wait until `socket` both exists and accepts a real RPC (`health`). The +--- existence check alone races the daemon's bind→accept, so we prove liveness +--- with a round-trip on a throwaway session. Returns `true`, or `false, reason`. +function M.wait_ready(socket, timeout) + timeout = timeout or 5000 + if not vim.wait(timeout, function() + return uv.fs_stat(socket) ~= nil + end, 20) then + return false, "socket never appeared: " .. socket + end + local session = require("heph.rpc").new_session(socket) + local ok = vim.wait(timeout, function() + return pcall(function() + session:call("health", vim.empty_dict(), { timeout = 200 }) + end) + end, 50) + session:close() + if not ok then + return false, "socket present but not accepting rpc: " .. socket + end + return true +end + +return M diff --git a/heph.nvim/lua/heph/init.lua b/heph.nvim/lua/heph/init.lua new file mode 100644 index 0000000..ea54747 --- /dev/null +++ b/heph.nvim/lua/heph/init.lua @@ -0,0 +1,33 @@ +--- heph.nvim — the primary surface for hephaestus (tech-spec §8): an +--- obsidian.nvim replacement that is a thin client of the local `hephd` over +--- its unix-socket JSON-RPC. This module is the public entry point. + +local config = require("heph.config") + +local M = {} + +--- The resolved config from the last `setup` (nil before setup). +M.config = nil + +--- Configure the plugin. `opts.socket` overrides the daemon socket path; +--- `opts.keymaps = false` disables the default keymaps. Idempotent. +function M.setup(opts) + local cfg = vim.tbl_deep_extend("force", config.defaults, opts or {}) + cfg.socket = config.resolve_socket(cfg.socket) + M.config = cfg + + require("heph.rpc").setup(cfg.socket) + + if cfg.autostart then + local ok = require("heph.daemon").wait_ready(cfg.socket, 500) + if not ok then + require("heph.daemon").spawn({ bin = cfg.bin, socket = cfg.socket, db = nil }) + require("heph.daemon").wait_ready(cfg.socket, 5000) + end + end + + config.apply_keymaps(cfg) + return M +end + +return M diff --git a/heph.nvim/lua/heph/journal.lua b/heph.nvim/lua/heph/journal.lua new file mode 100644 index 0000000..389ab81 --- /dev/null +++ b/heph.nvim/lua/heph/journal.lua @@ -0,0 +1,19 @@ +--- Daily journal (tech-spec §8). `journal.open_or_create` is idempotent (the +--- node id is deterministic in (owner, date)), so opening today's note twice is +--- safe. + +local rpc = require("heph.rpc") +local util = require("heph.util") + +local M = {} + +--- Open (creating if absent) the journal for `date` (default: today), in a +--- buffer. Returns the node. +function M.open(date) + date = date or util.iso_today() + local node = rpc.call("journal.open_or_create", { date = date }) + require("heph.node").open(node.id) + return node +end + +return M diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua new file mode 100644 index 0000000..6ef598a --- /dev/null +++ b/heph.nvim/lua/heph/link.lua @@ -0,0 +1,62 @@ +--- `[[wiki-link]]` parsing and following (tech-spec §8). +--- +--- The cursor grammar mirrors `heph-core`'s `extract.rs`: a span `[[target]]` +--- or `[[target|display]]`, where the resolvable name is everything left of the +--- first `|`, trimmed. Resolution goes through `node.resolve` (exact, the same +--- mapping that materializes stored `wiki` links) — never fuzzy `search`, which +--- would mis-jump. + +local rpc = require("heph.rpc") +local util = require("heph.util") + +local M = {} + +--- The wiki target under the cursor on the current line, or nil. Scans for the +--- `[[...]]` span that contains the cursor column. +function M.target_under_cursor() + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + 1 -- 1-based byte column + local from = 1 + while true do + local open_s, open_e = line:find("[[", from, true) + if not open_s then + return nil + end + local close_s, close_e = line:find("]]", open_e + 1, true) + if not close_s then + return nil + end + if col >= open_s and col <= close_e then + local inner = line:sub(open_e + 1, close_s - 1) + local target = inner:match("^([^|]*)") or "" + target = target:gsub("^%s+", ""):gsub("%s+$", "") + return (#target > 0) and target or nil + end + from = close_e + 1 + end +end + +--- Follow the `[[link]]` under the cursor to its node. Unresolved links are +--- allowed (tech-spec §5) — an INFO toast, not an error. +function M.follow() + local target = M.target_under_cursor() + if not target then + util.notify("no [[link]] under cursor", vim.log.levels.WARN) + return + end + local node = rpc.call("node.resolve", { title = target }) + if not node then + util.notify("unresolved link [[" .. target .. "]]", vim.log.levels.INFO) + return + end + require("heph.node").open(node.id) +end + +--- Attach the buffer-local `` follow keymap (only on heph:// buffers). +function M.attach(buf) + vim.keymap.set("n", "", function() + M.follow() + end, { buffer = buf, desc = "heph: follow [[link]]" }) +end + +return M diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua new file mode 100644 index 0000000..05a812c --- /dev/null +++ b/heph.nvim/lua/heph/node.lua @@ -0,0 +1,52 @@ +--- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a +--- real buffer named `heph://node/`. `:e` loads it via `node.get`; `:w` +--- saves the whole buffer back via `node.update` (the backend CRDT-diffs the +--- whole-buffer text, so sending the full body is correct and idempotent). + +local rpc = require("heph.rpc") +local util = require("heph.util") + +local M = {} + +--- `BufReadCmd` handler for `heph://node/`: load the body into the buffer. +function M.read(buf, uri) + local _, id = util.parse_uri(uri) + if not id then + error("heph: not a node uri: " .. tostring(uri)) + end + local node = rpc.call("node.get", { id = id }) + local body = (node and node.body) or "" + -- `plain` split keeps a trailing "" element for a trailing newline, so the + -- body round-trips exactly through `table.concat` on write. + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true })) + vim.b[buf].heph_node_id = id + vim.b[buf].heph_node_kind = (node and node.kind) or "doc" + vim.bo[buf].buftype = "acwrite" -- written via BufWriteCmd, not to a file + vim.bo[buf].filetype = "markdown" + vim.bo[buf].fileformat = "unix" + vim.bo[buf].modified = false + require("heph.link").attach(buf) +end + +--- `BufWriteCmd` handler: persist the whole buffer as the node body. +function M.write(buf, _uri) + local id = vim.b[buf].heph_node_id + if not id then + error("heph: buffer has no heph node id") + end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + rpc.call("node.update", { id = id, body = table.concat(lines, "\n") }) + vim.bo[buf].modified = false +end + +--- Open (or focus) the buffer for node `id`. +function M.open(id) + vim.cmd.edit(util.node_uri(id)) +end + +--- Force-reload the buffer for node `id` from the daemon (discards local edits). +function M.reload(id) + vim.cmd("edit! " .. vim.fn.fnameescape(util.node_uri(id))) +end + +return M diff --git a/heph.nvim/lua/heph/rpc.lua b/heph.nvim/lua/heph/rpc.lua new file mode 100644 index 0000000..3a71b4d --- /dev/null +++ b/heph.nvim/lua/heph/rpc.lua @@ -0,0 +1,202 @@ +--- Line-delimited JSON-RPC client over hephd's unix socket (tech-spec §6). +--- +--- The daemon speaks one JSON object per line: a request `{id, method, params}` +--- gets exactly one response line `{id, result}` xor `{id, error}`. We talk to +--- it over a libuv pipe and expose a **blocking** `call()` by pumping the event +--- loop with `vim.wait` until the matching id returns — synchronous ergonomics +--- over an async transport, which is what every surface call and the e2e tests +--- want. +--- +--- A `Session` is one connection; the module keeps a default singleton for the +--- plugin and lets tests open isolated sessions (`new_session`) so an assertion +--- never shares state with the buffer under test. + +local uv = vim.uv or vim.loop + +local Session = {} +Session.__index = Session + +--- Create an unconnected session bound to `socket_path` (lazy connect). +function Session.new(socket_path) + return setmetatable({ + socket_path = socket_path, + pipe = nil, + buf = "", -- partial-line accumulator + pending = {}, -- [id] = { done, result, err } + next_id = 0, + connected = false, + }, Session) +end + +--- Drain complete `\n`-terminated lines out of the read buffer. Runs in the +--- libuv fast-event context: string/table ops only, never `vim.api`/`vim.fn`. +function Session:_on_bytes(chunk) + self.buf = self.buf .. chunk + while true do + local nl = self.buf:find("\n", 1, true) + if not nl then + break + end + local line = self.buf:sub(1, nl - 1) + self.buf = self.buf:sub(nl + 1) + if #line > 0 then + self:_dispatch(line) + end + end +end + +--- Match one response line to its pending call by id. A line with no id is a +--- server notification (tech-spec §6, slice 11d) — ignored for now. +function Session:_dispatch(line) + -- `luanil` decodes JSON null to Lua nil (not the vim.NIL sentinel), so a + -- `null` result / nullable field reads as a plain absent value. + local ok, msg = pcall(vim.json.decode, line, { luanil = { object = true, array = true } }) + if not ok or type(msg) ~= "table" or msg.id == nil then + return + end + local slot = self.pending[msg.id] + if not slot then + return + end + if msg.error ~= nil then + slot.err = string.format("rpc error %s: %s", tostring(msg.error.code), tostring(msg.error.message)) + else + slot.result = msg.result + end + slot.done = true +end + +--- Fail every outstanding call so blocked `vim.wait`s unblock immediately +--- rather than each waiting out its full timeout. Safe to call from the read +--- callback (fast-event context): only touches tables and `vim.schedule`. +function Session:_fail_all(reason) + self.connected = false + for _, slot in pairs(self.pending) do + if not slot.done then + slot.err = reason + slot.done = true + end + end + local pipe = self.pipe + self.pipe = nil + if pipe then + vim.schedule(function() + pcall(function() + pipe:close() + end) + end) + end +end + +--- Connect (idempotent). Blocks until the connect callback fires. +function Session:_ensure() + if self.connected then + return + end + assert(self.socket_path, "heph: no socket configured (call require('heph').setup{ socket = ... })") + local pipe = uv.new_pipe(false) + local done, cerr = false, nil + pipe:connect(self.socket_path, function(e) + cerr = e + done = true + end) + if not vim.wait(5000, function() + return done + end, 10) then + pcall(function() + pipe:close() + end) + error("heph: timed out connecting to hephd at " .. self.socket_path) + end + if cerr then + pcall(function() + pipe:close() + end) + error("heph: cannot connect to hephd at " .. self.socket_path .. ": " .. cerr) + end + self.pipe = pipe + self.buf = "" + self.connected = true + pipe:read_start(function(rerr, chunk) + if rerr then + self:_fail_all("connection error: " .. rerr) + elseif chunk == nil then + self:_fail_all("hephd closed the connection") + else + self:_on_bytes(chunk) + end + end) +end + +--- Call `method` with `params`, blocking until the response. Raises a Lua error +--- on an rpc error or timeout. `opts.timeout` defaults to 5000ms. +function Session:call(method, params, opts) + opts = opts or {} + self:_ensure() + self.next_id = self.next_id + 1 + local id = self.next_id + local slot = { done = false } + self.pending[id] = slot + + -- Empty params must serialize as `{}`, not `[]` (the daemon parses an object). + if params == nil or (type(params) == "table" and vim.tbl_isempty(params)) then + params = vim.empty_dict() + end + local line = vim.json.encode({ id = id, method = method, params = params }) .. "\n" + self.pipe:write(line) + + local ok = vim.wait(opts.timeout or 5000, function() + return slot.done + end, 5) + self.pending[id] = nil + if not ok then + error("heph: rpc timeout calling " .. method) + end + if slot.err then + error("heph: " .. slot.err) + end + return slot.result +end + +--- Close the connection, failing any in-flight calls. +function Session:close() + self:_fail_all("connection closed") +end + +local M = { Session = Session } + +--- (Re)bind the default singleton session to `socket_path`. +function M.setup(socket_path) + if M._default then + M._default:close() + end + M._default = Session.new(socket_path) + return M._default +end + +--- The default singleton session (created unconnected if absent). +function M.session() + if not M._default then + M._default = Session.new(nil) + end + return M._default +end + +--- Blocking call on the default session. +function M.call(method, params, opts) + return M.session():call(method, params, opts) +end + +--- An isolated session for a socket — used by tests for independent assertions. +function M.new_session(socket_path) + return Session.new(socket_path) +end + +--- Close the default session. +function M.close() + if M._default then + M._default:close() + end +end + +return M diff --git a/heph.nvim/lua/heph/util.lua b/heph.nvim/lua/heph/util.lua new file mode 100644 index 0000000..3e22a0b --- /dev/null +++ b/heph.nvim/lua/heph/util.lua @@ -0,0 +1,26 @@ +--- Small shared helpers: URIs, dates, notifications. + +local M = {} + +--- Today's date as an ISO `YYYY-MM-DD`. Uses the real wall clock — the plugin +--- picks "today"; `heph-core` stays clock-injected, this is surface-only. +function M.iso_today() + return os.date("%Y-%m-%d") +end + +--- The buffer URI for a node id. +function M.node_uri(id) + return "heph://node/" .. id +end + +--- Parse a `heph:///` URI into `kind, id` (nil on no match). +function M.parse_uri(uri) + return uri:match("^heph://([^/]+)/(.+)$") +end + +--- Notify with a consistent `heph:` prefix. +function M.notify(msg, level) + vim.notify("heph: " .. msg, level or vim.log.levels.INFO) +end + +return M diff --git a/heph.nvim/plugin/heph.lua b/heph.nvim/plugin/heph.lua new file mode 100644 index 0000000..40502e2 --- /dev/null +++ b/heph.nvim/plugin/heph.lua @@ -0,0 +1,52 @@ +--- heph.nvim plugin entry: register the `heph://` buffer autocmds and the +--- `:Heph` command. Loaded once by Neovim from `runtimepath/plugin/`. + +if vim.g.loaded_heph then + return +end +vim.g.loaded_heph = true + +local grp = vim.api.nvim_create_augroup("heph", { clear = true }) + +-- `heph://node/` buffers load and save through the daemon (tech-spec §8). +vim.api.nvim_create_autocmd("BufReadCmd", { + group = grp, + pattern = "heph://*", + callback = function(ev) + local ok, err = pcall(require("heph.node").read, ev.buf, ev.match) + if not ok then + vim.notify(tostring(err), vim.log.levels.ERROR) + end + end, +}) + +vim.api.nvim_create_autocmd("BufWriteCmd", { + group = grp, + pattern = "heph://*", + callback = function(ev) + local ok, err = pcall(require("heph.node").write, ev.buf, ev.match) + if not ok then + vim.notify(tostring(err), vim.log.levels.ERROR) + end + end, +}) + +-- Release the socket cleanly on exit. +vim.api.nvim_create_autocmd("VimLeavePre", { + group = grp, + callback = function() + pcall(function() + require("heph.rpc").close() + end) + end, +}) + +vim.api.nvim_create_user_command("Heph", function(opts) + require("heph.command").run(opts) +end, { + nargs = "*", + desc = "hephaestus", + complete = function(arglead, cmdline, cursorpos) + return require("heph.command").complete(arglead, cmdline, cursorpos) + end, +}) diff --git a/heph.nvim/tests/e2e/backlink_spec.lua b/heph.nvim/tests/e2e/backlink_spec.lua new file mode 100644 index 0000000..04e6072 --- /dev/null +++ b/heph.nvim/tests/e2e/backlink_spec.lua @@ -0,0 +1,32 @@ +-- Workflow (d): link two documents — type [[B]] in A, save, assert the +-- backlink B<-A was materialized by the daemon's extraction. + +local h = require("e2e.helpers") + +describe("link two docs", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("typing [[B]] in A and saving creates a wiki backlink B<-A", function() + local b = h.create_doc("B", "the B doc") + local a = h.create_doc("A", "") + + local buf = h.open(a.id) + h.set_lines(buf, { "see [[B]]" }) + h.save(buf) + + local backlinks = ctx.q:call("links.backlinks", { id = b.id }) + local found = false + for _, l in ipairs(backlinks) do + if l.src_id == a.id and l.link_type == "wiki" then + found = true + end + end + assert.is_true(found, "expected a wiki backlink from A to B") + end) +end) diff --git a/heph.nvim/tests/e2e/follow_link_spec.lua b/heph.nvim/tests/e2e/follow_link_spec.lua new file mode 100644 index 0000000..d9395cf --- /dev/null +++ b/heph.nvim/tests/e2e/follow_link_spec.lua @@ -0,0 +1,40 @@ +-- Workflow (c): follow a [[link]] under the cursor on to the target doc. + +local h = require("e2e.helpers") + +describe("follow link", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("follows [[B]] under the cursor to doc B", function() + local b = h.create_doc("B", "the B doc") + local a = h.create_doc("A", "see [[B]] here") + + local buf = h.open(a.id) + local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] + local open_at = line:find("%[%[B") -- start of "[[B" + assert.is_truthy(open_at) + -- Put the cursor on the target inside the brackets. + vim.api.nvim_win_set_cursor(0, { 1, open_at + 1 }) + + require("heph.link").follow() + + local cur = vim.api.nvim_get_current_buf() + assert.are.equal("heph://node/" .. b.id, vim.api.nvim_buf_get_name(cur)) + assert.are.equal(b.id, vim.b[cur].heph_node_id) + end) + + it("leaves an unresolved [[link]] in place without erroring", function() + local a = h.create_doc("Lonely", "points to [[Nowhere]]") + local buf = h.open(a.id) + vim.api.nvim_win_set_cursor(0, { 1, 12 }) -- inside [[Nowhere]] + require("heph.link").follow() + -- Still on the same buffer; no jump happened. + assert.are.equal(buf, vim.api.nvim_get_current_buf()) + end) +end) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua new file mode 100644 index 0000000..2658ea6 --- /dev/null +++ b/heph.nvim/tests/e2e/helpers.lua @@ -0,0 +1,137 @@ +--- E2e harness (tech-spec §9): spin up a real `hephd` in local mode against a +--- temp DB + socket, point the plugin at it, and tear it down deterministically. +--- Step builders (create doc/task, open, edit, save) are reusable across specs. + +local rpc = require("heph.rpc") +local daemon = require("heph.daemon") + +local M = {} +local counter = 0 + +local function repo_root() + -- ":p" makes this absolute regardless of how the runner was launched. + local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p") + return vim.fn.fnamemodify(here, ":h:h:h:h") -- .../heph.nvim/tests/e2e -> repo root +end + +--- The hephd binary to drive: `$HEPHD_BIN` or the workspace debug build. +function M.hephd_bin() + local env = vim.env.HEPHD_BIN + if env and #env > 0 then + return env + end + return repo_root() .. "/target/debug/hephd" +end + +-- A short unique temp dir. unix socket paths are capped near 104 bytes +-- (`sun_path`), so we stay under a short base, never `tempname()`. +local function unique_dir() + counter = counter + 1 + local base = vim.env.HEPH_TEST_TMP + base = (base and #base > 0) and base:gsub("/+$", "") or "/tmp" + local dir = string.format("%s/h%d-%d", base, vim.fn.getpid(), counter) + vim.fn.mkdir(dir, "p") + return dir +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() + local dir = unique_dir() + local sock = dir .. "/s" + local db = dir .. "/db" + assert(#sock < 104, "socket path too long for sun_path: " .. sock) + local bin = M.hephd_bin() + assert( + vim.fn.executable(bin) == 1, + "hephd not built/executable: " .. bin .. " (run: cargo build -p hephd)" + ) + + local exited = { done = false } + local d = daemon.spawn({ + bin = bin, + db = db, + socket = sock, + on_exit = function() + exited.done = true + end, + }) + local ok, reason = daemon.wait_ready(sock, 5000) + assert(ok, "daemon not ready: " .. tostring(reason)) + + rpc.setup(sock) -- the plugin's default session, used by buffers/commands + return { + dir = dir, + sock = sock, + db = db, + daemon = d, + exited = exited, + q = rpc.new_session(sock), -- isolated session for independent assertions + } +end + +--- Tear down: close sessions, delete heph:// buffers, reap the daemon, rm temp. +function M.stop(ctx) + if not ctx then + return + end + pcall(function() + ctx.q:close() + end) + pcall(function() + rpc.close() + end) + 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 }) + end + end + local h = ctx.daemon and ctx.daemon.handle + if h then + if not ctx.exited.done then + pcall(function() + h:kill("sigterm") + end) + vim.wait(2000, function() + return ctx.exited.done + end, 20) + end + pcall(function() + if not h:is_closing() then + h:close() + end + end) + end + pcall(function() + vim.fn.delete(ctx.dir, "rf") + end) +end + +-- --- step builders (drive the plugin's default session) --- + +function M.create_doc(title, body) + return rpc.call("node.create", { kind = "doc", title = title, body = body or "" }) +end + +function M.create_task(opts) + return rpc.call("task.create", opts or {}) +end + +--- Open node `id` in a buffer; returns the buffer handle. +function M.open(id) + require("heph.node").open(id) + return vim.api.nvim_get_current_buf() +end + +function M.set_lines(buf, lines) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) +end + +--- Save a heph:// buffer (fires BufWriteCmd → node.update). +function M.save(buf) + vim.api.nvim_buf_call(buf, function() + vim.cmd("write") + end) +end + +return M diff --git a/heph.nvim/tests/e2e/journal_spec.lua b/heph.nvim/tests/e2e/journal_spec.lua new file mode 100644 index 0000000..337c57b --- /dev/null +++ b/heph.nvim/tests/e2e/journal_spec.lua @@ -0,0 +1,32 @@ +-- Workflow (b): create a daily journal, write an entry, save, assert persisted. + +local h = require("e2e.helpers") + +describe("journal", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("creates the journal, persists an entry, and round-trips exactly", function() + local node = require("heph.journal").open("2026-06-01") + local buf = vim.api.nvim_get_current_buf() + assert.are.equal("heph://node/" .. node.id, vim.api.nvim_buf_get_name(buf)) + assert.are.equal("journal", vim.b[buf].heph_node_kind) + + h.set_lines(buf, { "Today I wrote tests." }) + h.save(buf) + assert.is_false(vim.bo[buf].modified) + + -- Persisted body equals the typed text exactly — the trailing-newline canary. + local stored = ctx.q:call("node.get", { id = node.id }) + assert.are.equal("Today I wrote tests.", stored.body) + + -- Idempotent reopen returns the same deterministic journal node. + local again = require("heph.journal").open("2026-06-01") + assert.are.equal(node.id, again.id) + end) +end) diff --git a/heph.nvim/tests/e2e/run.lua b/heph.nvim/tests/e2e/run.lua new file mode 100644 index 0000000..0b71254 --- /dev/null +++ b/heph.nvim/tests/e2e/run.lua @@ -0,0 +1,22 @@ +--- Headless entry point for the e2e suite. Bootstraps the runtimepath and +--- package.path, loads the plugin, runs every `*_spec.lua` in this directory, +--- and exits non-zero if any test failed (so CI fails honestly). +--- +--- nvim --headless -u NONE -c "luafile tests/e2e/run.lua" + +local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p") +local root = vim.fn.fnamemodify(here, ":h:h:h") -- .../heph.nvim (absolute) +local e2e = root .. "/tests/e2e" + +vim.opt.runtimepath:append(root) +package.path = root .. "/tests/?.lua;" .. root .. "/tests/?/init.lua;" .. package.path +vim.cmd("runtime plugin/heph.lua") + +local runner = require("e2e.runner") +runner.install_globals() + +local files = vim.fn.glob(e2e .. "/*_spec.lua", false, true) +table.sort(files) +local failed = runner.run_files(files) + +vim.cmd(failed > 0 and "cquit 1" or "quit") diff --git a/heph.nvim/tests/e2e/runner.lua b/heph.nvim/tests/e2e/runner.lua new file mode 100644 index 0000000..bc1d124 --- /dev/null +++ b/heph.nvim/tests/e2e/runner.lua @@ -0,0 +1,140 @@ +--- A tiny, dependency-free busted-compatible test runner (tech-spec §9 sanctions +--- "drive nvim ... from the test runner"). Provides the `describe`/`it`/ +--- `before_each`/`after_each` globals and a luassert-style `assert` table, then +--- runs `*_spec.lua` files in headless Neovim with proper exit codes — no +--- external plugins, no network, nothing vendored. + +local M = {} + +-- Registration state (module-local; the installed globals close over these). +local cases = {} +local scope_stack = {} + +local function full_name(leaf) + local parts = {} + for _, s in ipairs(scope_stack) do + if s.name then + parts[#parts + 1] = s.name + end + end + parts[#parts + 1] = leaf + return table.concat(parts, " ") +end + +--- Install the spec globals. `assert` becomes a callable luassert-ish table: +--- `assert(cond, msg)` still works (so harness code using the builtin is fine), +--- plus `assert.are.equal`, `assert.is_true/is_false/is_truthy/is_falsy`. +function M.install_globals() + _G.describe = function(name, fn) + table.insert(scope_stack, { name = name, befores = {}, afters = {} }) + fn() + table.remove(scope_stack) + end + _G.before_each = function(fn) + table.insert(scope_stack[#scope_stack].befores, fn) + end + _G.after_each = function(fn) + table.insert(scope_stack[#scope_stack].afters, fn) + end + _G.it = function(name, fn) + -- Snapshot the active before/after chain (outermost-first for befores, + -- innermost-first for afters), as busted runs them per-test. + local befores, afters = {}, {} + for _, s in ipairs(scope_stack) do + for _, b in ipairs(s.befores) do + befores[#befores + 1] = b + end + end + for i = #scope_stack, 1, -1 do + for _, a in ipairs(scope_stack[i].afters) do + afters[#afters + 1] = a + end + end + table.insert(cases, { name = full_name(name), fn = fn, befores = befores, afters = afters }) + end + + local function fail(msg, level) + error(msg, (level or 1) + 1) + end + local A = setmetatable({}, { + __call = function(_, cond, msg) + if not cond then + fail(msg or "assertion failed") + end + return cond + end, + }) + local function eq(a, b, msg) + if a ~= b then + fail(msg or string.format("expected %s, got %s", vim.inspect(b), vim.inspect(a))) + end + end + A.are = { equal = eq, equals = eq } + A.equals = eq + A.equal = eq + A.is_true = function(x, msg) + if x ~= true then + fail(msg or ("expected true, got " .. vim.inspect(x))) + end + end + A.is_false = function(x, msg) + if x ~= false then + fail(msg or ("expected false, got " .. vim.inspect(x))) + end + end + A.is_truthy = function(x, msg) + if not x then + fail(msg or ("expected truthy, got " .. vim.inspect(x))) + end + end + A.is_falsy = function(x, msg) + if x then + fail(msg or ("expected falsy, got " .. vim.inspect(x))) + end + end + _G.assert = A +end + +--- Run each spec file, returning the number of failed tests. Prints TAP-ish +--- lines so failures are obvious in CI logs. +function M.run_files(files) + local passed, failed = 0, 0 + for _, file in ipairs(files) do + cases = {} + scope_stack = {} + local loaded, lerr = pcall(dofile, file) + if not loaded then + failed = failed + 1 + print("not ok - load " .. file) + print(" " .. tostring(lerr):gsub("\n", "\n ")) + end + for _, c in ipairs(cases) do + local ok, err = true, nil + for _, b in ipairs(c.befores) do + local bok, berr = pcall(b) + if not bok then + ok, err = false, berr + break + end + end + if ok then + ok, err = pcall(c.fn) + end + for _, a in ipairs(c.afters) do + pcall(a) -- teardown always runs, even after a failure + end + if ok then + passed = passed + 1 + print("ok - " .. c.name) + else + failed = failed + 1 + print("not ok - " .. c.name) + print(" " .. tostring(err):gsub("\n", "\n ")) + end + end + end + print(string.format("\n%d passed, %d failed", passed, failed)) + return failed +end + +return M -- 2.50.1 (Apple Git-155) From 3997e948edacffdeaadee71bf6b4089a54649dbb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 21:02:46 -0700 Subject: [PATCH 21/91] heph.nvim: run e2e via `mise run test-nvim`, drop the Makefile Make mise the dev entrypoint for the headless e2e suite, matching the repo's mise-tasks convention (auto-discovered, shows in `mise tasks`). Dev relies on system-installed nvim + rustc; CI will provide them via Dagger (slice 11c). The self-contained shim runner is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/heph-nvim.md | 8 +++++--- docs/reference/tech-spec.md | 4 ++-- heph.nvim/Makefile | 16 ---------------- heph.nvim/README.md | 5 +++-- mise-tasks/test-nvim | 25 +++++++++++++++++++++++++ 5 files changed, 35 insertions(+), 23 deletions(-) delete mode 100644 heph.nvim/Makefile create mode 100755 mise-tasks/test-nvim diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 0a5854e..3295457 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -57,9 +57,11 @@ the per-task log, and context-item **promotion** arrive in slices 11b/11c. The headless e2e suite drives the plugin in `nvim --headless` against a real `hephd` over a temp socket, asserting both buffer contents and resulting DB state (via an isolated RPC session). It uses a **self-contained busted-style -runner** (`tests/e2e/runner.lua`) — no external plugins, no network — so CI is -deterministic. `make test` builds the daemon and runs it; a deliberately -failing spec exits non-zero (no false-green). +runner** (`tests/e2e/runner.lua`) — no external plugins, no network — so it is +deterministic. `mise run test-nvim` builds the daemon and runs the suite against +system-installed Neovim; a deliberately failing spec exits non-zero (no +false-green). In CI the same suite runs inside a Dagger container that provides +Neovim + the Rust toolchain (slice 11c). ## Related diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index aa9e5ea..a5dc5f6 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -345,14 +345,14 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). -- ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal `, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic CI exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `make -C heph.nvim test` builds the daemon and runs it. +- ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal `, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). **Not yet done (resume order)** > The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a done). The rest are non-blocking polish + an end-of-v1 sweep (§11). 1. ⏳ **`heph.nvim` slice 11b (§8) — task views:** enrich `list` to titled rows; Tactical `next` + Organizational `list` views; task capture, set-attention, mark done/dropped; per-task log quick-append; `vim.ui.select` pickers (Telescope auto-upgrade when present). e2e: capture→next→context→checklist→done, and the recurring fresh-checklist workflow. -2. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; extend `.forgejo/scripts/build` to build `hephd` and run the nvim e2e suite (runner needs `neovim`; the self-contained busted runner needs **no** plenary). +2. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; **CI via Dagger** — a `test` function in `.dagger/` (mirroring `build_docs`) bakes a pinned `neovim` + Rust toolchain into a container and runs the e2e suite (the self-contained busted runner needs **no** plenary). Dev stays native: `mise run test-nvim` against system nvim/rustc; Dagger is the CI-only provisioner. The Forgejo workflow shrinks to `dagger call test`. 3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 4. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. 5. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. diff --git a/heph.nvim/Makefile b/heph.nvim/Makefile deleted file mode 100644 index 0243c55..0000000 --- a/heph.nvim/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -# heph.nvim — headless e2e suite (tech-spec §9). -# -# `make test` builds the daemon, then drives the plugin in headless Neovim -# against a real hephd over a temp socket, via a self-contained busted-style -# runner (no external plugins, no network — see tests/e2e/runner.lua). - -HEPHD_BIN ?= $(CURDIR)/../target/debug/hephd -export HEPHD_BIN - -.PHONY: test build-hephd - -build-hephd: - cargo build -p hephd --manifest-path $(CURDIR)/../Cargo.toml - -test: build-hephd - nvim --headless -u NONE -c "luafile tests/e2e/run.lua" diff --git a/heph.nvim/README.md b/heph.nvim/README.md index 173e67c..9fc630f 100644 --- a/heph.nvim/README.md +++ b/heph.nvim/README.md @@ -47,9 +47,10 @@ require("heph").setup({ The e2e suite drives the plugin in headless Neovim against a real daemon: ```bash -make test # builds hephd, runs the headless e2e suite +mise run test-nvim # builds hephd, runs the headless e2e suite ``` The suite uses a small self-contained busted-style runner (`tests/e2e/runner.lua`) — no external plugins and no network, so it is -deterministic in CI. It needs only Neovim (≥ 0.10) and a built `hephd`. +deterministic. Dev runs use system-installed Neovim (≥ 0.10) + rustc; CI runs +the same suite inside a Dagger container that provides them (slice 11c). diff --git a/mise-tasks/test-nvim b/mise-tasks/test-nvim new file mode 100755 index 0000000..f0a1047 --- /dev/null +++ b/mise-tasks/test-nvim @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +#MISE description="Run the heph.nvim headless e2e suite (builds hephd, drives nvim --headless)" + +# Dev path: uses system-installed nvim + rustc. CI runs the same suite inside a +# Dagger container that provides them (tech-spec §9, slice 11c). The runner is +# self-contained (tests/e2e/runner.lua) — no external nvim plugins, no network. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if ! command -v nvim >/dev/null 2>&1; then + echo "error: nvim not found on PATH (install Neovim >= 0.10)" >&2 + exit 1 +fi + +echo "== building hephd (debug) ==" +cargo build -p hephd + +export HEPHD_BIN="$ROOT/target/debug/hephd" + +echo "== heph.nvim headless e2e ==" +cd "$ROOT/heph.nvim" +nvim --headless -u NONE -c "luafile tests/e2e/run.lua" -- 2.50.1 (Apple Git-155) From 7c9a734ebd609da989d435d2bc0d7a39caa65827 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 21:12:56 -0700 Subject: [PATCH 22/91] =?UTF-8?q?heph.nvim:=20task=20views=20=E2=80=94=20n?= =?UTF-8?q?ext/list/capture/attention/state/log=20(slice=2011b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: enrich `list` to return titled RankedTask rows (title + canonical_context_id, via a shared ranked_from_row with `next`), so the Organizational view needs no N+1 node.get. TDD: query_surface test asserts list rows carry title + context id. Plugin: - view.lua: Tactical `next` + Organizational `list` rendered scratch buffers; opens the row's canonical-context doc. Narrowed the node autocmd to heph://node/* so view buffers (heph://next, heph://list) don't trip it. - task.lua: 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). - picker.lua: vim.ui.select with Telescope auto-upgrade (headless-safe). - command.lua: :Heph next/list/capture/attention/done/drop/skip/log/search. e2e: capture→next→open context→add/check checklist→done; recurring fresh-checklist (complete rolls forward in place, next occurrence all-unchecked — the §4.4 hard requirement). 6 specs green via `mise run test-nvim`. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- crates/heph-core/src/sqlite/mod.rs | 2 +- crates/heph-core/src/sqlite/tasks.rs | 77 +++++++++++--------- crates/heph-core/src/store.rs | 6 +- crates/heph-core/tests/query_surface.rs | 14 ++++ crates/hephd/src/remote.rs | 2 +- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 21 ++++-- docs/reference/tech-spec.md | 14 ++-- heph.nvim/lua/heph/command.lua | 63 +++++++++++++++++ heph.nvim/lua/heph/config.lua | 10 ++- heph.nvim/lua/heph/picker.lua | 56 +++++++++++++++ heph.nvim/lua/heph/task.lua | 90 ++++++++++++++++++++++++ heph.nvim/lua/heph/view.lua | 89 +++++++++++++++++++++++ heph.nvim/plugin/heph.lua | 4 +- heph.nvim/tests/e2e/capture_spec.lua | 56 +++++++++++++++ heph.nvim/tests/e2e/recurring_spec.lua | 54 ++++++++++++++ 17 files changed, 507 insertions(+), 55 deletions(-) create mode 100644 heph.nvim/lua/heph/picker.lua create mode 100644 heph.nvim/lua/heph/task.lua create mode 100644 heph.nvim/lua/heph/view.lua create mode 100644 heph.nvim/tests/e2e/capture_spec.lua create mode 100644 heph.nvim/tests/e2e/recurring_spec.lua diff --git a/README.md b/README.md index e0b4162..143d637 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | OIDC hub auth — bearer-token verification + owner gate | ✅ done | | OIDC client — device-code login, keyring token cache | ✅ done | | `heph.nvim` (primary surface) — RPC client, buffer-backed editing, wiki-link follow, journal (slice 11a) | ✅ done | -| `heph.nvim` — task/agenda views, promotion, CI runner (slices 11b–11c) | ⏳ next | +| `heph.nvim` — Tactical/Organizational task views, capture, attention, done/drop, log (slice 11b) | ✅ done | +| `heph.nvim` — context-item promotion + Dagger CI runner (slice 11c) | ⏳ next | ## Architecture diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 822da3b..7ed2af2 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -241,7 +241,7 @@ impl Store for LocalStore { scope: Option<&str>, attention: Option, include_blue: bool, - ) -> Result> { + ) -> Result> { tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue) } diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 197c1c0..4a5164f 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -283,35 +283,37 @@ pub(super) fn next( } /// Enumerate outstanding committed tasks for the Organizational view (the whole -/// set incl. backlog, tech-spec §6). Optional `scope` (project) and `attention` -/// filters; `include_blue` keeps on-deck items (default true for `list`). +/// set incl. backlog, tech-spec §6) as **titled rows** ([`RankedTask`] shape — +/// the same the plugin renders for `next`, so the survey view needs no N+1 +/// `node.get`). Optional `scope` (project) and `attention` filters; +/// `include_blue` keeps on-deck items (default true for `list`). pub(super) fn list( conn: &Connection, owner: &str, scope: Option<&str>, attention: Option, include_blue: bool, -) -> Result> { +) -> Result> { let sql = " - SELECT t.node_id, t.attention, t.do_date, t.late_on, t.state, t.recurrence, + SELECT n.id, n.title, n.created_at, n.tombstoned, + t.attention, t.do_date, t.late_on, t.state, (SELECT dst_id FROM links - WHERE src_id = t.node_id AND type = 'in-project' AND tombstoned = 0 - ORDER BY created_at, id LIMIT 1) AS project_id + WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS project_id, + (SELECT dst_id FROM links + WHERE src_id = n.id AND type = 'canonical-context' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS ctx_id FROM tasks t JOIN nodes n ON n.id = t.node_id WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding' ORDER BY n.created_at, n.id"; let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([owner], |row| { - let task = from_row(row)?; - let project: Option = row.get("project_id")?; - Ok((task, project)) - })?; + let rows = stmt.query_map([owner], ranked_from_row)?; let mut out = Vec::new(); for row in rows { - let (task, project) = row?; + let task = row?; if let Some(s) = scope { - if project.as_deref() != Some(s) { + if task.project_id.as_deref() != Some(s) { continue; } } @@ -376,31 +378,36 @@ fn load_candidates(conn: &Connection, owner: &str) -> Result> { FROM tasks t JOIN nodes n ON n.id = t.node_id WHERE n.owner_id = ?1 AND n.tombstoned = 0"; let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([owner], |row| { - let attention = match row.get::<_, Option>("attention")? { - Some(s) => Some( - Attention::parse(&s) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, - ), - None => None, - }; - Ok(RankedTask { - node_id: row.get("id")?, - title: row.get("title")?, - attention, - do_date: row.get("do_date")?, - late_on: row.get("late_on")?, - state: TaskState::parse(&row.get::<_, String>("state")?) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, - tombstoned: row.get::<_, i64>("tombstoned")? != 0, - project_id: row.get("project_id")?, - canonical_context_id: row.get("ctx_id")?, - created_at: row.get("created_at")?, - }) - })?; + let rows = stmt.query_map([owner], ranked_from_row)?; Ok(rows.collect::>>()?) } +/// Map a row selected with the `id, title, created_at, tombstoned, attention, +/// do_date, late_on, state, project_id, ctx_id` shape into a [`RankedTask`]. +/// Shared by `next`'s candidate load and the enriched `list`. +fn ranked_from_row(row: &Row) -> rusqlite::Result { + let attention = match row.get::<_, Option>("attention")? { + Some(s) => Some( + Attention::parse(&s) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + ), + None => None, + }; + Ok(RankedTask { + node_id: row.get("id")?, + title: row.get("title")?, + attention, + do_date: row.get("do_date")?, + late_on: row.get("late_on")?, + state: TaskState::parse(&row.get::<_, String>("state")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + project_id: row.get("project_id")?, + canonical_context_id: row.get("ctx_id")?, + created_at: row.get("created_at")?, + }) +} + /// Set a task's attention-state. pub(super) fn set_attention( conn: &Connection, diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index a9c50f7..a5137f9 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -77,13 +77,15 @@ pub trait Store { fn next(&self, scope: Option<&str>, limit: usize) -> Result>; /// Enumerate outstanding committed tasks for the Organizational view — the - /// whole set incl. backlog (tech-spec §6). `include_blue` keeps on-deck. + /// whole set incl. backlog (tech-spec §6), as **titled** [`RankedTask`] rows + /// (the same shape `next` returns, so the survey view needs no N+1 + /// `get_node`). `include_blue` keeps on-deck. fn list( &self, scope: Option<&str>, attention: Option, include_blue: bool, - ) -> Result>; + ) -> Result>; /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; diff --git a/crates/heph-core/tests/query_surface.rs b/crates/heph-core/tests/query_surface.rs index 3ebe0d6..a09493a 100644 --- a/crates/heph-core/tests/query_surface.rs +++ b/crates/heph-core/tests/query_surface.rs @@ -47,6 +47,20 @@ fn list_can_exclude_blue_and_filter_by_attention() { ); } +#[test] +fn list_rows_carry_title_and_canonical_context() { + let mut s = store(); + let id = task(&mut s, "Buy milk", Attention::Orange); + + // The Organizational view needs titles + the one-keystroke context jump + // without an N+1 node.get (tech-spec §6, §8). + let rows = s.list(None, None, true).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].node_id, id); + assert_eq!(rows[0].title, "Buy milk"); + assert!(rows[0].canonical_context_id.is_some()); +} + #[test] fn list_scopes_to_a_project() { let mut s = store(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index d777625..3d3cdae 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -169,7 +169,7 @@ impl Store for RemoteStore { scope: Option<&str>, attention: Option, include_blue: bool, - ) -> Result> { + ) -> Result> { self.call_as( "list", json!({ "scope": scope, "attention": attention, "include_blue": include_blue }), diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index a1a770d..919c122 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -15,3 +15,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Client authentication (§13, slice 10b): `heph auth login --hub-url --issuer --client-id ` runs the OAuth 2.0 device-code flow and caches the token in the OS keyring; spokes and `client` mode attach it to hub requests, refreshing on expiry (`--oidc-issuer`/`--oidc-client-id`). Offline-tested against a mock OAuth server and a full spoke-to-authenticated-hub loop. (Auth/proxy HTTP uses the runtime-free `ureq`, since `reqwest::blocking` is unsafe inside the async daemon.) - CI runs the Rust suite (fmt/clippy/test) via the project build hook. - `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). diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 3295457..a669055 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -40,17 +40,28 @@ non-tombstoned alias-then-title match — the same mapping the store uses to materialize `wiki` links, so "follow link under cursor" jumps to the *same* node the stored link points at. -## Commands (as of slice 11a) +## Commands (as of slice 11b) | Command | Action | |---|---| -| `:Heph today` | Open today's journal | -| `:Heph journal ` | Open a dated journal | +| `:Heph today` / `:Heph journal ` | Open today's / a dated journal | | `:Heph follow` (also `` in a node buffer) | Follow the `[[link]]` under the cursor | | `:Heph open ` | Open a node buffer by id | +| `:Heph search ` | Full-text search; pick a result to open | +| `:Heph next [scope]` | Tactical "what is next?" view (`` opens a task's context) | +| `:Heph list [attention]` | Organizational survey of the outstanding set | +| `:Heph capture ` | Capture a committed task (pick attention) | +| `:Heph attention [color]` | Set the current task's attention | +| `:Heph done` / `:Heph drop` / `:Heph skip` | State change on the current task | +| `:Heph log <text>` | Append a breadcrumb to the current task's log | -Task/agenda views (`:Heph next`/`list`/`capture`, set-attention, done/drop), -the per-task log, and context-item **promotion** arrive in slices 11b/11c. +"Current task" is resolved from the buffer: a `task` node, or a canonical-context +doc whose owning task is followed via its `canonical-context` backlink. The +`next`/`list` views render the titled rows the daemon returns (`list` enriched to +carry titles + the context id, so no N+1 `node.get`). Pickers use built-in +`vim.ui.select`, auto-upgrading to Telescope when installed. + +Context-item **promotion** (`:Heph promote`) and the CI runner arrive in slice 11c. ## Testing (tech-spec §9) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index a5dc5f6..1009e34 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **114 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`make -C heph.nvim test`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slice 11a). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **115 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`mise run test-nvim`, 6 specs), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11b). **Done** @@ -346,16 +346,16 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). +- ✅ **`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). **Not yet done (resume order)** -> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a done). The rest are non-blocking polish + an end-of-v1 sweep (§11). +> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a–11b done). The rest are non-blocking polish + an end-of-v1 sweep (§11). -1. ⏳ **`heph.nvim` slice 11b (§8) — task views:** enrich `list` to titled rows; Tactical `next` + Organizational `list` views; task capture, set-attention, mark done/dropped; per-task log quick-append; `vim.ui.select` pickers (Telescope auto-upgrade when present). e2e: capture→next→context→checklist→done, and the recurring fresh-checklist workflow. -2. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; **CI via Dagger** — a `test` function in `.dagger/` (mirroring `build_docs`) bakes a pinned `neovim` + Rust toolchain into a container and runs the e2e suite (the self-contained busted runner needs **no** plenary). Dev stays native: `mise run test-nvim` against system nvim/rustc; Dagger is the CI-only provisioner. The Forgejo workflow shrinks to `dagger call test`. -3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -4. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -5. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; **CI via Dagger** — a `test` function in `.dagger/` (mirroring `build_docs`) bakes a pinned `neovim` + Rust toolchain into a container and runs the e2e suite (the self-contained busted runner needs **no** plenary). Dev stays native: `mise run test-nvim` against system nvim/rustc; Dagger is the CI-only provisioner. The Forgejo workflow shrinks to `dagger call test`. +2. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +3. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +4. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 8953d63..b16133a 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -4,8 +4,11 @@ local M = {} +local ATTENTIONS = { "white", "orange", "red", "blue" } + --- subcommand -> handler(args: string[]) M.subs = { + -- knowledge base today = function() require("heph.journal").open() end, @@ -20,6 +23,66 @@ M.subs = { require("heph.node").open(args[1]) end end, + search = function(args) + local query = table.concat(args, " ") + if #query == 0 then + require("heph.util").notify("usage: :Heph search <query>", vim.log.levels.WARN) + return + end + local nodes = require("heph.rpc").call("search", { query = query }) + require("heph.picker").select(nodes, { + prompt = "heph search: " .. query, + format = function(n) + return string.format("[%s] %s", n.kind, n.title) + end, + }, function(choice) + if choice then + require("heph.node").open(choice.id) + end + end) + end, + + -- tasks + next = function(args) + require("heph.view").next({ scope = args[1] }) + end, + list = function(args) + require("heph.view").list({ attention = args[1] }) + end, + capture = function(args) + local title = table.concat(args, " ") + if #title == 0 then + require("heph.util").notify("usage: :Heph capture <title>", vim.log.levels.WARN) + return + end + require("heph.picker").select(ATTENTIONS, { prompt = "attention for: " .. title }, function(attention) + require("heph.task").capture(title, { attention = attention }) + require("heph.util").notify("captured: " .. title) + end) + end, + attention = function(args) + if args[1] then + require("heph.task").set_attention_current(args[1]) + else + require("heph.picker").select(ATTENTIONS, { prompt = "attention" }, function(choice) + if choice then + require("heph.task").set_attention_current(choice) + end + end) + end + end, + done = function() + require("heph.task").set_state_current("done") + end, + drop = function() + require("heph.task").set_state_current("dropped") + end, + skip = function() + require("heph.task").skip_current() + end, + log = function(args) + require("heph.task").log_append_current(table.concat(args, " ")) + end, } --- `:Heph` entry point. diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index 9453e1a..ddc1dd4 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -33,7 +33,15 @@ function M.apply_keymaps(opts) map("n", "<leader>hj", function() require("heph.journal").open() end, { desc = "heph: today's journal" }) - -- Task/agenda maps are added with their views in slice 11b. + map("n", "<leader>hn", function() + require("heph.view").next() + end, { desc = "heph: what is next (Tactical)" }) + map("n", "<leader>hl", function() + require("heph.view").list() + end, { desc = "heph: task list (Organizational)" }) + map("n", "<leader>hd", function() + require("heph.task").set_state_current("done") + end, { desc = "heph: mark current task done" }) end return M diff --git a/heph.nvim/lua/heph/picker.lua b/heph.nvim/lua/heph/picker.lua new file mode 100644 index 0000000..b7726db --- /dev/null +++ b/heph.nvim/lua/heph/picker.lua @@ -0,0 +1,56 @@ +--- A single selection primitive. Uses built-in `vim.ui.select` so headless e2e +--- needs no plugins; auto-upgrades to Telescope when it is installed and not +--- explicitly disabled. Tests set `vim.g.heph_force_ui_select` and stub +--- `vim.ui.select`, so a picker never blocks in `--headless`. + +local M = {} + +local function telescope_available() + return pcall(require, "telescope") +end + +--- Select one of `items`. `opts.prompt`, `opts.format(item)->string`. +--- `on_choice(item|nil, index|nil)` — nil when cancelled. +function M.select(items, opts, on_choice) + opts = opts or {} + if not vim.g.heph_force_ui_select and telescope_available() then + -- Telescope path: a thin wrapper so fuzzy UX is available when present. + -- (The dropdown is intentionally minimal; richer pickers can come later.) + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + pickers + .new({}, { + prompt_title = opts.prompt or "heph", + finder = finders.new_table({ + results = items, + entry_maker = function(item) + local display = opts.format and opts.format(item) or tostring(item) + return { value = item, display = display, ordinal = display } + end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(bufnr) + actions.select_default:replace(function() + actions.close(bufnr) + local sel = action_state.get_selected_entry() + on_choice(sel and sel.value or nil) + end) + return true + end, + }) + :find() + return + end + + vim.ui.select(items, { + prompt = opts.prompt, + format_item = opts.format, + }, function(choice, idx) + on_choice(choice, idx) + end) +end + +return M diff --git a/heph.nvim/lua/heph/task.lua b/heph.nvim/lua/heph/task.lua new file mode 100644 index 0000000..87be1c5 --- /dev/null +++ b/heph.nvim/lua/heph/task.lua @@ -0,0 +1,90 @@ +--- Task actions (tech-spec §8): capture, attention, state, skip, log. Actions +--- that operate on "the current task" resolve it from the buffer — either the +--- buffer is a `task` node, or it is a task's canonical-context doc, in which +--- case we follow the `canonical-context` backlink to its owning task. + +local rpc = require("heph.rpc") +local util = require("heph.util") + +local M = {} + +--- Capture a committed task. `opts`: attention, do_date, late_on, recurrence, +--- project. Returns the created task. +function M.capture(title, opts) + opts = opts or {} + return rpc.call("task.create", { + title = title, + attention = opts.attention, + do_date = opts.do_date, + late_on = opts.late_on, + recurrence = opts.recurrence, + project_id = opts.project, + }) +end + +--- The committed task id associated with the current buffer, or nil. +function M.current_task_id() + local buf = vim.api.nvim_get_current_buf() + local id = vim.b[buf].heph_node_id + if not id then + return nil + end + if vim.b[buf].heph_node_kind == "task" then + return id + end + -- A canonical-context doc: its owning task is the src of the + -- canonical-context link pointing here. + for _, l in ipairs(rpc.call("links.backlinks", { id = id })) do + if l.link_type == "canonical-context" then + return l.src_id + end + end + return nil +end + +local function with_task(action, what) + local id = M.current_task_id() + if not id then + util.notify("no task associated with this buffer", vim.log.levels.WARN) + return nil + end + action(id) + if what then + util.notify(what) + end + return id +end + +--- Mark the current task done/dropped (recurring tasks roll forward on done). +function M.set_state_current(state) + return with_task(function(id) + rpc.call("task.set_state", { id = id, state = state }) + end, "task " .. state) +end + +--- Set the current task's attention. +function M.set_attention_current(attention) + return with_task(function(id) + rpc.call("task.set_attention", { id = id, attention = attention }) + end, "attention → " .. attention) +end + +--- Skip the current recurring task's occurrence (advance without logging). +function M.skip_current() + return with_task(function(id) + rpc.call("task.skip", { id = id }) + end, "occurrence skipped") +end + +--- Append a line to the current task's log (the resumption breadcrumb). +function M.log_append_current(text) + if not text or #text == 0 then + util.notify("nothing to log", vim.log.levels.WARN) + return nil + end + return with_task(function(id) + rpc.call("log.append", { task_id = id, text = text }) + end, "logged") +end + +return M diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua new file mode 100644 index 0000000..82e6792 --- /dev/null +++ b/heph.nvim/lua/heph/view.lua @@ -0,0 +1,89 @@ +--- Task list views (tech-spec §8): Tactical `next` (the "what is next?" ranking) +--- and Organizational `list` (the whole outstanding set). Both render the same +--- titled rows the daemon returns into a scratch buffer; `<CR>` opens the task +--- under the cursor's canonical-context doc (the one-keystroke jump). + +local rpc = require("heph.rpc") + +local M = {} + +-- buf -> { tasks = <RankedTask[]> }; line N maps to tasks[N]. +M._views = {} + +local function row(t) + local tag = t.attention and ("[" .. t.attention .. "]") or "[ ]" + return string.format("%s %s", tag, t.title) +end + +-- Find or create the named scratch buffer and fill it with task rows. +local function render(name, tasks) + local buf + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_get_name(b) == name then + buf = b + break + end + end + if not buf then + buf = vim.api.nvim_create_buf(false, true) -- unlisted scratch + vim.api.nvim_buf_set_name(buf, name) + end + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "hide" + vim.bo[buf].swapfile = false + + local lines = {} + for _, t in ipairs(tasks) do + lines[#lines + 1] = row(t) + end + if #lines == 0 then + lines = { "(nothing here)" } + end + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + + M._views[buf] = { tasks = tasks } + vim.keymap.set("n", "<CR>", function() + M.open_under_cursor(buf) + end, { buffer = buf, desc = "heph: open task context" }) + vim.api.nvim_set_current_buf(buf) + return buf +end + +--- Open the canonical-context doc of the task on the cursor line. +function M.open_under_cursor(buf) + buf = buf or vim.api.nvim_get_current_buf() + local view = M._views[buf] + if not view then + return + end + local lnum = vim.api.nvim_win_get_cursor(0)[1] + local t = view.tasks[lnum] + if not t then + return + end + require("heph.node").open(t.canonical_context_id or t.node_id) +end + +--- Tactical "what is next?" — render the ranking, return the rows. +function M.next(opts) + opts = opts or {} + local tasks = rpc.call("next", { scope = opts.scope, limit = opts.limit or 5 }) + render("heph://next", tasks) + return tasks +end + +--- Organizational survey — render the outstanding set, return the rows. +function M.list(opts) + opts = opts or {} + local tasks = rpc.call("list", { + scope = opts.scope, + attention = opts.attention, + include_blue = opts.include_blue ~= false, + }) + render("heph://list", tasks) + return tasks +end + +return M diff --git a/heph.nvim/plugin/heph.lua b/heph.nvim/plugin/heph.lua index 40502e2..1e708ab 100644 --- a/heph.nvim/plugin/heph.lua +++ b/heph.nvim/plugin/heph.lua @@ -11,7 +11,7 @@ local grp = vim.api.nvim_create_augroup("heph", { clear = true }) -- `heph://node/<id>` buffers load and save through the daemon (tech-spec §8). vim.api.nvim_create_autocmd("BufReadCmd", { group = grp, - pattern = "heph://*", + pattern = "heph://node/*", callback = function(ev) local ok, err = pcall(require("heph.node").read, ev.buf, ev.match) if not ok then @@ -22,7 +22,7 @@ vim.api.nvim_create_autocmd("BufReadCmd", { vim.api.nvim_create_autocmd("BufWriteCmd", { group = grp, - pattern = "heph://*", + pattern = "heph://node/*", callback = function(ev) local ok, err = pcall(require("heph.node").write, ev.buf, ev.match) if not ok then diff --git a/heph.nvim/tests/e2e/capture_spec.lua b/heph.nvim/tests/e2e/capture_spec.lua new file mode 100644 index 0000000..2bba877 --- /dev/null +++ b/heph.nvim/tests/e2e/capture_spec.lua @@ -0,0 +1,56 @@ +-- Workflow (a): capture a task -> it appears in :Heph next -> open its canonical +-- context -> add a checklist item -> check it -> mark the task done. + +local h = require("e2e.helpers") + +describe("task capture to done", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("captures, surfaces in next, edits the context checklist, and marks done", function() + local task = require("heph.task").capture("Fix roof", { attention = "orange" }) + assert.is_truthy(task.node_id) + + -- Surfaces in the Tactical view. + local ranked = require("heph.view").next() + local viewbuf = vim.api.nvim_get_current_buf() + local present = false + for _, l in ipairs(vim.api.nvim_buf_get_lines(viewbuf, 0, -1, false)) do + if l:find("Fix roof", 1, true) then + present = true + end + end + assert.is_true(present, "task missing from :Heph next") + assert.are.equal(task.node_id, ranked[1].node_id) + assert.is_truthy(ranked[1].canonical_context_id) + + -- Jump to its canonical context from the view (the one-keystroke jump). + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + require("heph.view").open_under_cursor() + local ctxbuf = vim.api.nvim_get_current_buf() + assert.are.equal( + "heph://node/" .. ranked[1].canonical_context_id, + vim.api.nvim_buf_get_name(ctxbuf) + ) + + -- Add a checklist item and save. + vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [ ] buy shingles" }) + h.save(ctxbuf) + local stored = ctx.q:call("node.get", { id = ranked[1].canonical_context_id }) + assert.are.equal("- [ ] buy shingles", stored.body) + + -- Check it off and save. + vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [x] buy shingles" }) + h.save(ctxbuf) + + -- Mark the task done from its context buffer (resolves the owning task). + local done_id = require("heph.task").set_state_current("done") + assert.are.equal(task.node_id, done_id) + assert.are.equal("done", ctx.q:call("task.get", { id = task.node_id }).state) + end) +end) diff --git a/heph.nvim/tests/e2e/recurring_spec.lua b/heph.nvim/tests/e2e/recurring_spec.lua new file mode 100644 index 0000000..eb9a069 --- /dev/null +++ b/heph.nvim/tests/e2e/recurring_spec.lua @@ -0,0 +1,54 @@ +-- Workflow (e): a recurring task with a checklist. Completing it must roll the +-- task forward in place and present the next occurrence with a FRESH, +-- all-unchecked checklist — completion never carries forward (tech-spec §4.4). +-- +-- The e2e daemon uses the real system clock (it's the actual binary), so we +-- assert the do-date *advanced* past the original rather than an exact value; +-- the fresh checklist is the hard requirement. + +local h = require("e2e.helpers") + +describe("recurring task checklist", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("resets the checklist to all-unchecked on the next occurrence", function() + local base = 1717200000000 -- 2024-06-01, safely in the past + local task = require("heph.task").capture("Water plants", { + recurrence = "FREQ=DAILY", + do_date = base, + }) + + -- Its canonical-context doc holds the checklist (the recurrence template). + local ctx_id + for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do + if l.link_type == "canonical-context" then + ctx_id = l.dst_id + end + end + assert.is_truthy(ctx_id) + + -- Add a checklist via the buffer, then check both items off. + local buf = h.open(ctx_id) + h.set_lines(buf, { "- [ ] water fern", "- [ ] water cactus" }) + h.save(buf) + h.set_lines(buf, { "- [x] water fern", "- [x] water cactus" }) + h.save(buf) + + -- Complete the occurrence from its context buffer → rolls forward in place. + require("heph.task").set_state_current("done") + + local t = ctx.q:call("task.get", { id = task.node_id }) + assert.are.equal("outstanding", t.state) -- rolled forward, not done + assert.is_true(t.do_date > base, "do-date should advance to the next occurrence") + + -- The hard requirement: a fresh, all-unchecked checklist. + local stored = ctx.q:call("node.get", { id = ctx_id }) + assert.are.equal("- [ ] water fern\n- [ ] water cactus", stored.body) + end) +end) -- 2.50.1 (Apple Git-155) From b97c387252c5032a43e496eff2569ff8c04c0a9e Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 06:08:41 -0700 Subject: [PATCH 23/91] heph.nvim: context-item promotion + Dagger headless-nvim CI (slice 11c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (TDD): - task.promote {container_id, item_ref, attention?, project?}: mint a committed task from the item_ref-th `- [ ]` context item (1-based, document order via a new extract::context_item_lines) and rewrite that source line into a [[link]] to it. Unit + rpc_socket tests. - resolve_id now excludes canonical-context docs, so [[Task Title]] resolves to the task, not its identically-titled context doc (deterministic; a general fix surfaced by promotion's ULID-tiebreak ambiguity). Plugin: :Heph promote / promote_under_cursor (save-if-dirty → compute item index with a code-fence-aware scanner mirroring extract.rs → task.promote → reload the rewritten buffer). e2e spec (f): promote a context line, assert the new task in next, the source line became a link, and the container backlinks the task. CI via Dagger: a test_nvim function 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 self-contained shim suite (cargo + target cache volumes); build.yaml calls `dagger call test-nvim`. run.lua now fails on zero specs (no false-green). Validated end-to-end: passing suite → exit 0, failing spec → Dagger exit 1. 117 Rust tests + 7 nvim e2e specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .dagger/src/hephaestus_ci/main.py | 54 +++++++++++++++++ .forgejo/workflows/build.yaml | 5 ++ README.md | 2 +- crates/heph-core/src/extract.rs | 25 ++++++++ crates/heph-core/src/sqlite/links.rs | 6 ++ crates/heph-core/src/sqlite/mod.rs | 19 ++++++ crates/heph-core/src/sqlite/tasks.rs | 70 +++++++++++++++++++++++ crates/heph-core/src/store.rs | 12 ++++ crates/heph-core/tests/tasks_and_links.rs | 38 ++++++++++++ crates/hephd/src/remote.rs | 18 ++++++ crates/hephd/src/rpc.rs | 14 +++++ crates/hephd/tests/rpc_socket.rs | 39 +++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 17 ++++-- docs/reference/tech-spec.md | 8 +-- heph.nvim/lua/heph/command.lua | 3 + heph.nvim/lua/heph/config.lua | 3 + heph.nvim/lua/heph/task.lua | 30 ++++++++++ heph.nvim/lua/heph/util.lua | 25 ++++++++ heph.nvim/tests/e2e/promote_spec.lua | 46 +++++++++++++++ heph.nvim/tests/e2e/run.lua | 10 +++- 21 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 heph.nvim/tests/e2e/promote_spec.lua diff --git a/.dagger/src/hephaestus_ci/main.py b/.dagger/src/hephaestus_ci/main.py index e129a44..1833f05 100644 --- a/.dagger/src/hephaestus_ci/main.py +++ b/.dagger/src/hephaestus_ci/main.py @@ -1,9 +1,63 @@ import dagger from dagger import dag, function, object_type +# Pinned Neovim — Debian's packaged nvim is far too old for `vim.uv` (the plugin +# needs >= 0.10), so the e2e container bakes an official release tarball. The +# arch is detected at build time so this runs natively on amd64 (CI) and arm64. +NVIM_VERSION = "v0.11.2" + @object_type class HephaestusCi: + @function + async def test_nvim(self, src: dagger.Directory) -> str: + """Run the heph.nvim headless e2e suite (tech-spec §9). + + Builds the hephd daemon and drives the plugin in headless Neovim against + it, using the repo's self-contained busted-style runner (no external nvim + plugins, no network at test time). Fails non-zero if any spec fails; + returns the suite output. Dev runs the same suite natively via + `mise run test-nvim`; this is the reproducible CI path. + """ + return await ( + dag.container() + .from_("rust:1-bookworm") + .with_exec(["apt-get", "update", "-qq"]) + .with_exec(["apt-get", "install", "-y", "-qq", "curl", "ca-certificates"]) + # Cache cargo downloads + build artifacts across CI runs. + .with_mounted_cache( + "/usr/local/cargo/registry", + dag.cache_volume("heph-cargo-registry"), + ) + .with_exec( + [ + "sh", + "-c", + "set -e; " + 'case "$(uname -m)" in ' + "x86_64) arch=x86_64 ;; " + "aarch64|arm64) arch=arm64 ;; " + '*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; ' + "esac; " + "curl -fsSL " + f"https://github.com/neovim/neovim/releases/download/{NVIM_VERSION}/nvim-linux-$arch.tar.gz " + "| tar -xz -C /opt; " + "ln -s /opt/nvim-linux-$arch /opt/nvim", + ] + ) + .with_env_variable("PATH", "/opt/nvim/bin:$PATH", expand=True) + .with_directory("/workspace", src) + .with_workdir("/workspace") + .with_mounted_cache("/workspace/target", dag.cache_volume("heph-target")) + .with_exec(["cargo", "build", "-p", "hephd"]) + .with_env_variable("HEPHD_BIN", "/workspace/target/debug/hephd") + .with_workdir("/workspace/heph.nvim") + .with_exec( + ["nvim", "--headless", "-u", "NONE", "-c", "luafile tests/e2e/run.lua"] + ) + .stdout() + ) + @function async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File: """Build Quartz docs site. Returns docs tarball.""" diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 5180340..3ff12f4 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -42,3 +42,8 @@ jobs: else echo "No .forgejo/scripts/build hook found; template validation complete." fi + + - name: Run heph.nvim e2e (Dagger) + run: | + echo "Running headless heph.nvim e2e suite via Dagger..." + dagger call test-nvim --src=. diff --git a/README.md b/README.md index 143d637..fe8e7e3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | OIDC client — device-code login, keyring token cache | ✅ done | | `heph.nvim` (primary surface) — RPC client, buffer-backed editing, wiki-link follow, journal (slice 11a) | ✅ done | | `heph.nvim` — Tactical/Organizational task views, capture, attention, done/drop, log (slice 11b) | ✅ done | -| `heph.nvim` — context-item promotion + Dagger CI runner (slice 11c) | ⏳ next | +| `heph.nvim` — context-item promotion + Dagger headless-nvim CI (slice 11c) | ✅ done | ## Architecture diff --git a/crates/heph-core/src/extract.rs b/crates/heph-core/src/extract.rs index cf6f4e4..24b32aa 100644 --- a/crates/heph-core/src/extract.rs +++ b/crates/heph-core/src/extract.rs @@ -102,6 +102,22 @@ pub fn extract(body: &str) -> Extraction { } } +/// The 0-based body line index of each context item, in the **same document +/// order** as [`extract`]'s `context_items` (task markers never fire inside code +/// blocks, so the two lists align 1:1). Promotion uses this to locate the source +/// `- [ ]` line it must rewrite into a link (tech-spec §4.3, §6). +pub fn context_item_lines(body: &str) -> Vec<usize> { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TASKLISTS); + let mut lines = Vec::new(); + for (event, range) in Parser::new_ext(body, options).into_offset_iter() { + if let Event::TaskListMarker(_) = event { + lines.push(body[..range.start].bytes().filter(|&b| b == b'\n').count()); + } + } + lines +} + /// Find `[[target]]` (or `[[target|display]]`) spans in `body`, returning each /// unique, non-empty target in first-seen order. Matches starting inside a /// `code` range are skipped. The `[` / `]` delimiters are ASCII, so byte @@ -218,6 +234,15 @@ mod tests { assert!(!e.context_items[0].checked); } + #[test] + fn context_item_lines_align_with_items_skipping_code() { + let body = "# Notes\n\n- [ ] first\n\n```\n- [ ] fenced\n```\n\n- [x] second\n"; + let lines = context_item_lines(body); + // Two real items (the fenced one is skipped, matching `context_items`). + assert_eq!(lines.len(), extract(body).context_items.len()); + assert_eq!(lines, vec![2, 8]); // 0-based lines of "- [ ] first" / "- [x] second" + } + #[test] fn extraction_is_idempotent() { let body = "# Mixed\n\n- [ ] do [[X]]\n- [x] done\n\nsee [[Y]]\n"; diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 0557464..c5fa04d 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -174,10 +174,16 @@ pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result if by_alias.is_some() { return Ok(by_alias); } + // A title may be shared by a task and its canonical-context doc (they are + // created with the same title). Wiki-links address the first-class node, so + // canonical-context docs are excluded — `[[Task Title]]` resolves to the + // task, never its internal context attachment (deterministic; tech-spec §6). let by_title: Option<String> = conn .query_row( "SELECT id FROM nodes WHERE title = ?1 AND owner_id = ?2 AND tombstoned = 0 + AND id NOT IN (SELECT dst_id FROM links + WHERE type = 'canonical-context' AND tombstoned = 0) ORDER BY created_at, id LIMIT 1", (target, owner), |r| r.get(0), diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 7ed2af2..9c88975 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -231,6 +231,25 @@ impl Store for LocalStore { tasks::set_attention(&self.conn, &self.owner_id, now, node_id, attention) } + fn promote( + &mut self, + container_id: &str, + item_ref: usize, + attention: Option<Attention>, + project_id: Option<String>, + ) -> Result<Task> { + let now = self.clock.now_ms(); + tasks::promote( + &mut self.conn, + &self.owner_id, + now, + container_id, + item_ref, + attention, + project_id, + ) + } + fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>> { let now = self.clock.now_ms(); tasks::next(&self.conn, &self.owner_id, now, scope, limit) diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 4a5164f..351e0cb 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -9,6 +9,7 @@ use serde_json::json; use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; +use crate::extract; use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; @@ -155,6 +156,75 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas Ok(task) } +/// Promote a `- [ ]` context-item line in `container_id`'s body into a committed +/// task, rewriting that source line into a `[[link]]` to the new task (Fork A, +/// tech-spec §4.3, §6). `item_ref` is the **1-based index** of the item among +/// the container's context items in document order (code-fence-aware, matching +/// extraction). +pub(super) fn promote( + conn: &mut Connection, + owner: &str, + now: i64, + container_id: &str, + item_ref: usize, + attention: Option<Attention>, + project_id: Option<String>, +) -> Result<Task> { + let container = + nodes::get(conn, container_id)?.ok_or_else(|| Error::NodeNotFound(container_id.into()))?; + let body = container.body.unwrap_or_default(); + + let idx = item_ref + .checked_sub(1) + .ok_or_else(|| Error::Integrity("item_ref is 1-based".into()))?; + let item = extract::extract(&body) + .context_items + .into_iter() + .nth(idx) + .ok_or_else(|| Error::Integrity(format!("no context item #{item_ref} to promote")))?; + let line = *extract::context_item_lines(&body) + .get(idx) + .ok_or_else(|| Error::Integrity(format!("no context item #{item_ref} to promote")))?; + let title = item.text.trim().to_string(); + if title.is_empty() { + return Err(Error::Integrity( + "cannot promote an empty context item".into(), + )); + } + + // Mint the committed task (its own node + canonical context doc + link). + let task = create( + conn, + owner, + now, + NewTask { + title: title.clone(), + attention, + project_id, + ..Default::default() + }, + )?; + + // Rewrite the source line into a wiki-link to the new task. Updating the + // container re-runs extraction, materializing the container→task `wiki` link + // and dropping the now-promoted context item. + let new_body = rewrite_line(&body, line, &format!("- [[{title}]]")); + nodes::update(conn, owner, now, container_id, None, Some(new_body))?; + + Ok(task) +} + +/// Replace body line `idx` (0-based) with `new_line`, preserving the original +/// line's leading whitespace. An out-of-range `idx` leaves the body unchanged. +fn rewrite_line(body: &str, idx: usize, new_line: &str) -> String { + let mut lines: Vec<String> = body.split('\n').map(str::to_string).collect(); + if let Some(slot) = lines.get_mut(idx) { + let indent: String = slot.chars().take_while(|c| c.is_whitespace()).collect(); + *slot = format!("{indent}{new_line}"); + } + lines.join("\n") +} + /// Fetch a task by node id. pub(super) fn get(conn: &Connection, node_id: &str) -> Result<Option<Task>> { let task = conn diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index a5137f9..5113b61 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -71,6 +71,18 @@ pub trait Store { /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a + /// committed task, rewriting that source line into a `[[link]]` to the new + /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the + /// item among the container's context items in document order. + fn promote( + &mut self, + container_id: &str, + item_ref: usize, + attention: Option<Attention>, + project_id: Option<String>, + ) -> Result<Task>; + /// The Tactical "what is next?" ranking (tech-spec §7), using the store's /// injected clock as `now`. `scope`, when `Some`, restricts to a project /// node id; `red` items always appear regardless of `limit`. diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs index ee17402..e80a2e4 100644 --- a/crates/heph-core/tests/tasks_and_links.rs +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -9,6 +9,44 @@ fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() } +#[test] +fn promote_mints_a_task_and_rewrites_the_line_into_a_link() { + let mut s = store(); + // A container doc with two context items. + let container = s + .create_node(NewNode::doc( + "Errands", + "- [ ] call plumber\n- [ ] water plants", + )) + .unwrap(); + + // Promote the first item. + let task = s + .promote(&container.id, 1, Some(Attention::Orange), None) + .unwrap(); + + // A committed task now exists with the item's text as its title. + let node = s.get_node(&task.node_id).unwrap().unwrap(); + assert_eq!(node.kind, NodeKind::Task); + assert_eq!(node.title, "call plumber"); + assert_eq!(task.attention, Some(Attention::Orange)); + assert_eq!(task.state, TaskState::Outstanding); + + // The source line became a wiki-link; the other item is untouched. + let body = s.get_node(&container.id).unwrap().unwrap().body.unwrap(); + assert_eq!(body, "- [[call plumber]]\n- [ ] water plants"); + + // That link resolves to the new task and is materialized as a backlink. + assert_eq!( + s.resolve_node("call plumber").unwrap().unwrap().id, + task.node_id + ); + let backs = s.backlinks(&task.node_id).unwrap(); + assert!(backs + .iter() + .any(|l| l.src_id == container.id && l.link_type == LinkType::Wiki)); +} + #[test] fn create_task_makes_a_canonical_context_doc_and_link() { let mut s = store(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 3d3cdae..8445292 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -160,6 +160,24 @@ impl Store for RemoteStore { ) } + fn promote( + &mut self, + container_id: &str, + item_ref: usize, + attention: Option<Attention>, + project_id: Option<String>, + ) -> Result<Task> { + self.call_as( + "task.promote", + json!({ + "container_id": container_id, + "item_ref": item_ref, + "attention": attention, + "project": project_id, + }), + ) + } + fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<heph_core::RankedTask>> { self.call_as("next", json!({ "scope": scope, "limit": limit })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 874b98b..b4df8a8 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -135,6 +135,16 @@ struct SetAttentionParams { attention: Attention, } +#[derive(Deserialize)] +struct PromoteParams { + container_id: String, + item_ref: usize, + #[serde(default)] + attention: Option<Attention>, + #[serde(default)] + project: Option<String>, +} + #[derive(Deserialize)] struct NextParams { #[serde(default)] @@ -255,6 +265,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?) } + "task.promote" => { + let p: PromoteParams = parse(params)?; + json!(store.promote(&p.container_id, p.item_ref, p.attention, p.project)?) + } "next" => { let p: NextParams = parse(params)?; json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 47abfa9..7da9264 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -134,6 +134,45 @@ fn task_create_appears_in_next_with_context_link() { assert_eq!(doc["kind"], "doc"); } +#[test] +fn promote_context_item_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let container = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Errands", "body": "- [ ] call plumber\n- [ ] water plants" }), + ) + .unwrap(); + let container_id = container["id"].as_str().unwrap().to_string(); + + // Promote the first context item to a committed task. + let task = c + .call( + "task.promote", + json!({ "container_id": container_id, "item_ref": 1, "attention": "orange" }), + ) + .unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + + // It appears in `next`, and the source line became a link to it. + let ranked = c.call("next", json!({ "limit": 10 })).unwrap(); + assert!(ranked + .as_array() + .unwrap() + .iter() + .any(|t| t["node_id"] == task_id && t["title"] == "call plumber")); + + let body = c.call("node.get", json!({ "id": container_id })).unwrap(); + assert_eq!(body["body"], "- [[call plumber]]\n- [ ] water plants"); + + let resolved = c + .call("node.resolve", json!({ "title": "call plumber" })) + .unwrap(); + assert_eq!(resolved["id"], task_id); +} + #[test] fn errors_are_reported_as_rpc_errors() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 919c122..a69ef67 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -16,3 +16,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - CI runs the Rust suite (fmt/clippy/test) via the project build hook. - `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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index a669055..3bfb903 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -40,7 +40,7 @@ non-tombstoned alias-then-title match — the same mapping the store uses to materialize `wiki` links, so "follow link under cursor" jumps to the *same* node the stored link points at. -## Commands (as of slice 11b) +## Commands (as of slice 11c) | Command | Action | |---|---| @@ -53,6 +53,7 @@ node the stored link points at. | `:Heph capture <title>` | Capture a committed task (pick attention) | | `:Heph attention [color]` | Set the current task's attention | | `:Heph done` / `:Heph drop` / `:Heph skip` | State change on the current task | +| `:Heph promote [attention]` | Promote the `- [ ]` line under the cursor to a committed task | | `:Heph log <text>` | Append a breadcrumb to the current task's log | "Current task" is resolved from the buffer: a `task` node, or a canonical-context @@ -61,7 +62,12 @@ doc whose owning task is followed via its `canonical-context` backlink. The carry titles + the context id, so no N+1 `node.get`). Pickers use built-in `vim.ui.select`, auto-upgrading to Telescope when installed. -Context-item **promotion** (`:Heph promote`) and the CI runner arrive in slice 11c. +**Promotion** (`:Heph promote`) mints a committed task from the `- [ ]` line +under the cursor (the daemon's `task.promote`, `item_ref` = the cursor item's +1-based index among context items, computed code-fence-aware to mirror +`extract.rs`) and rewrites that line into a `[[link]]` to the new task. To keep +that link unambiguous, wiki-link resolution excludes canonical-context docs, so +`[[Task Title]]` resolves to the task, not its identically-titled context doc. ## Testing (tech-spec §9) @@ -71,8 +77,11 @@ state (via an isolated RPC session). It uses a **self-contained busted-style runner** (`tests/e2e/runner.lua`) — no external plugins, no network — so it is deterministic. `mise run test-nvim` builds the daemon and runs the suite against system-installed Neovim; a deliberately failing spec exits non-zero (no -false-green). In CI the same suite runs inside a Dagger container that provides -Neovim + the Rust toolchain (slice 11c). +false-green; the runner also fails if it discovers zero specs). CI runs the same +suite through the **`test-nvim` Dagger function** (`.dagger/`, invoked by +`build.yaml` as `dagger call test-nvim`), which bakes a pinned, arch-detected +Neovim onto a Rust image, builds `hephd`, and runs the suite — reproducible, and +identical to the native `mise run test-nvim` path. ## Related diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 1009e34..4f80f89 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **115 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`mise run test-nvim`, 6 specs), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11b). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-02 — **117 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`mise run test-nvim`, 7 specs; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c). **Done** @@ -347,13 +347,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). - ✅ **`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). **Not yet done (resume order)** -> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a–11b done). The rest are non-blocking polish + an end-of-v1 sweep (§11). +> The Rust backend is feature-complete; `heph.nvim` slices 11a–11c are done — the v1 surface (knowledge base + task views + promotion) works end-to-end with CI. The remainder is the deferred reconcile slice plus non-blocking polish + an end-of-v1 sweep (§11). -1. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; **CI via Dagger** — a `test` function in `.dagger/` (mirroring `build_docs`) bakes a pinned `neovim` + Rust toolchain into a container and runs the e2e suite (the self-contained busted runner needs **no** plenary). Dev stays native: `mise run test-nvim` against system nvim/rustc; Dagger is the CI-only provisioner. The Forgejo workflow shrinks to `dagger call test`. -2. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +1. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 3. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. 4. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index b16133a..028a93d 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -80,6 +80,9 @@ M.subs = { skip = function() require("heph.task").skip_current() end, + promote = function(args) + require("heph.task").promote_under_cursor({ attention = args[1] }) + end, log = function(args) require("heph.task").log_append_current(table.concat(args, " ")) end, diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index ddc1dd4..8a50a0c 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -42,6 +42,9 @@ function M.apply_keymaps(opts) map("n", "<leader>hd", function() require("heph.task").set_state_current("done") end, { desc = "heph: mark current task done" }) + map("n", "<leader>hp", function() + require("heph.task").promote_under_cursor() + end, { desc = "heph: promote context item to a task" }) end return M diff --git a/heph.nvim/lua/heph/task.lua b/heph.nvim/lua/heph/task.lua index 87be1c5..5a4d005 100644 --- a/heph.nvim/lua/heph/task.lua +++ b/heph.nvim/lua/heph/task.lua @@ -76,6 +76,36 @@ function M.skip_current() end, "occurrence skipped") end +--- Promote the context-item line under the cursor to a committed task; the +--- daemon rewrites that line into a `[[link]]` to the new task. `opts.attention` +--- optional. Returns the created task. +function M.promote_under_cursor(opts) + opts = opts or {} + local buf = vim.api.nvim_get_current_buf() + local container_id = vim.b[buf].heph_node_id + if not container_id then + util.notify("not in a heph node buffer", vim.log.levels.WARN) + return nil + end + -- Save first so the daemon's body matches what the user sees on screen. + if vim.bo[buf].modified then + require("heph.node").write(buf, vim.api.nvim_buf_get_name(buf)) + end + local item_ref = util.context_item_index_at_cursor(buf) + if not item_ref then + util.notify("cursor is not on a context item (- [ ])", vim.log.levels.WARN) + return nil + end + local task = rpc.call("task.promote", { + container_id = container_id, + item_ref = item_ref, + attention = opts.attention, + }) + require("heph.node").reload(container_id) -- pull the rewritten body + util.notify("promoted to a task") + return task +end + --- Append a line to the current task's log (the resumption breadcrumb). function M.log_append_current(text) if not text or #text == 0 then diff --git a/heph.nvim/lua/heph/util.lua b/heph.nvim/lua/heph/util.lua index 3e22a0b..5d925c0 100644 --- a/heph.nvim/lua/heph/util.lua +++ b/heph.nvim/lua/heph/util.lua @@ -18,6 +18,31 @@ function M.parse_uri(uri) return uri:match("^heph://([^/]+)/(.+)$") end +--- The 1-based index of the context item on the cursor's line among the +--- buffer's context items (document order, fenced code skipped) — the `item_ref` +--- for `task.promote`. Mirrors heph-core's `extract.rs` ordering. Returns nil if +--- the cursor line is not a `- [ ]` / `- [x]` item. +function M.context_item_index_at_cursor(buf) + local cur = vim.api.nvim_win_get_cursor(0)[1] + local lines = vim.api.nvim_buf_get_lines(buf, 0, cur, false) -- lines 1..cursor + local in_fence, count, last_is_item = false, 0, false + for _, line in ipairs(lines) do + if line:match("^%s*```") then + in_fence = not in_fence + last_is_item = false + elseif not in_fence and line:match("^%s*[-*+]%s+%[[ xX]%]") then + count = count + 1 + last_is_item = true + else + last_is_item = false + end + end + if last_is_item then + return count + end + return nil +end + --- Notify with a consistent `heph:` prefix. function M.notify(msg, level) vim.notify("heph: " .. msg, level or vim.log.levels.INFO) diff --git a/heph.nvim/tests/e2e/promote_spec.lua b/heph.nvim/tests/e2e/promote_spec.lua new file mode 100644 index 0000000..df7e625 --- /dev/null +++ b/heph.nvim/tests/e2e/promote_spec.lua @@ -0,0 +1,46 @@ +-- Workflow (f): promote a context-item line to a committed task. The daemon +-- mints the task and rewrites the source line into a [[link]] to it. + +local h = require("e2e.helpers") + +describe("promote context item", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("mints a task, links the source line, and surfaces it in next", function() + local container = h.create_doc("Errands", "- [ ] call plumber") + local buf = h.open(container.id) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- on the context-item line + + local task = require("heph.task").promote_under_cursor({ attention = "orange" }) + assert.is_truthy(task.node_id) + + -- The promoted task appears in the Tactical ranking. + local found = false + for _, t in ipairs(ctx.q:call("next", { limit = 10 })) do + if t.node_id == task.node_id then + found = true + end + end + assert.is_true(found, "promoted task missing from next") + + -- The source line became a wiki-link to the task (persisted + in-buffer). + assert.are.equal("- [[call plumber]]", ctx.q:call("node.get", { id = container.id }).body) + local reloaded = vim.api.nvim_get_current_buf() + assert.are.equal("- [[call plumber]]", vim.api.nvim_buf_get_lines(reloaded, 0, 1, false)[1]) + + -- ...and the container backlinks the task. + local linked = false + for _, l in ipairs(ctx.q:call("links.backlinks", { id = task.node_id })) do + if l.src_id == container.id and l.link_type == "wiki" then + linked = true + end + end + assert.is_true(linked, "expected a wiki backlink from the container to the task") + end) +end) diff --git a/heph.nvim/tests/e2e/run.lua b/heph.nvim/tests/e2e/run.lua index 0b71254..d3d0a6e 100644 --- a/heph.nvim/tests/e2e/run.lua +++ b/heph.nvim/tests/e2e/run.lua @@ -17,6 +17,14 @@ runner.install_globals() local files = vim.fn.glob(e2e .. "/*_spec.lua", false, true) table.sort(files) -local failed = runner.run_files(files) +-- Guard against a false green: zero specs found (e.g. a path/glob mistake in a +-- container) must fail, not pass silently. +if #files == 0 then + io.stderr:write("heph e2e: no *_spec.lua found under " .. e2e .. "\n") + vim.cmd("cquit 1") + return +end + +local failed = runner.run_files(files) vim.cmd(failed > 0 and "cquit 1" or "quit") -- 2.50.1 (Apple Git-155) From fef0e82d268162e330f96fb5d0c1fc3b4a468eba Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 07:14:15 -0700 Subject: [PATCH 24/91] ci: run build entirely through Dagger; drop prek from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Forgejo k8s job image is a thin Alpine + Dagger orchestrator (no native Rust/Neovim toolchain, no prek) with a DinD sidecar — so CI was failing on step 1 (`prek: command not found`) on every run, and a native cargo hook wouldn't work there either. - build.yaml now runs `dagger call check` (cargo fmt/clippy/test on rust:1-bookworm) + `dagger call test-nvim` (build hephd + headless e2e). - New Dagger `check` function; CARGO_BUILD_JOBS capped on both functions so parallel rustc on heavy crates doesn't OOM the build engine; cargo registry + target caches shared across runs. - prek is intentionally not run in CI — it runs locally via git hooks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .dagger/src/hephaestus_ci/main.py | 38 +++++++++++++++++++++++++++++++ .forgejo/workflows/build.yaml | 36 ++++++++++------------------- docs/reference/tech-spec.md | 2 +- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/.dagger/src/hephaestus_ci/main.py b/.dagger/src/hephaestus_ci/main.py index 1833f05..091a1e7 100644 --- a/.dagger/src/hephaestus_ci/main.py +++ b/.dagger/src/hephaestus_ci/main.py @@ -9,6 +9,43 @@ NVIM_VERSION = "v0.11.2" @object_type class HephaestusCi: + @function + async def check(self, src: dagger.Directory) -> str: + """Rust workspace checks: rustfmt, clippy (-D warnings), and the full + test suite (tech-spec §9). Runs on `rust:1-bookworm` (glibc + a C + toolchain for rusqlite's bundled SQLite); the Alpine job image only + orchestrates `dagger call`. Shares cargo caches with `test_nvim`. + """ + return await ( + dag.container() + .from_("rust:1-bookworm") + .with_exec(["rustup", "component", "add", "clippy", "rustfmt"]) + # Cap parallel rustc — unbounded (= ncpu) spikes memory on heavy + # crates and OOMs the build engine on a many-core runner. + .with_env_variable("CARGO_BUILD_JOBS", "4") + .with_mounted_cache( + "/usr/local/cargo/registry", + dag.cache_volume("heph-cargo-registry"), + ) + .with_directory("/workspace", src) + .with_workdir("/workspace") + .with_mounted_cache("/workspace/target", dag.cache_volume("heph-target")) + .with_exec(["cargo", "fmt", "--all", "--check"]) + .with_exec( + [ + "cargo", + "clippy", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ] + ) + .with_exec(["cargo", "test", "--all"]) + .stdout() + ) + @function async def test_nvim(self, src: dagger.Directory) -> str: """Run the heph.nvim headless e2e suite (tech-spec §9). @@ -46,6 +83,7 @@ class HephaestusCi: ] ) .with_env_variable("PATH", "/opt/nvim/bin:$PATH", expand=True) + .with_env_variable("CARGO_BUILD_JOBS", "4") .with_directory("/workspace", src) .with_workdir("/workspace") .with_mounted_cache("/workspace/target", dag.cache_volume("heph-target")) diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 3ff12f4..ae265e7 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -1,19 +1,15 @@ # Build Workflow # -# Generic CI validation for template-based repositories. -# By default this runs the repo's prek checks, which already cover: -# - workflow linting -# - docs integrity checks -# - formatting/linting hooks configured by the project +# CI validation, run entirely through Dagger. The Forgejo job image is a thin +# Alpine + Dagger orchestrator (no Rust/Neovim toolchain), so all build and test +# work happens in Dagger containers (.dagger/src/hephaestus_ci/main.py): # -# Projects can extend this with an optional executable hook at: -# .forgejo/scripts/build +# - check — cargo fmt --check, clippy -D warnings, cargo test --all +# - test-nvim — build hephd + run the headless heph.nvim e2e suite # -# The optional hook is the place for project-specific validation such as: -# - unit/integration tests -# - package builds -# - schema validation -# - language-specific linters +# prek is intentionally not run here — it runs locally via git hooks +# (`prek install`). Dagger uses the DinD sidecar; both functions share cargo +# cache volumes across runs. name: Build @@ -30,20 +26,12 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Run repository checks + - name: Rust checks (Dagger) run: | - prek run --all-files + echo "Running cargo fmt/clippy/test via Dagger..." + dagger call check --src=. - - name: Run project-specific build hook - run: | - if [ -x .forgejo/scripts/build ]; then - echo "Running project-specific build hook..." - ./.forgejo/scripts/build - else - echo "No .forgejo/scripts/build hook found; template validation complete." - fi - - - name: Run heph.nvim e2e (Dagger) + - name: heph.nvim e2e (Dagger) run: | echo "Running headless heph.nvim e2e suite via Dagger..." dagger call test-nvim --src=. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 4f80f89..4f1536a 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -344,7 +344,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). - ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. -- ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). +- ✅ **CI (§9):** Forgejo `build.yaml` runs **entirely through Dagger** (the k8s job image is a thin Alpine + Dagger orchestrator with a DinD sidecar — no native Rust/nvim toolchain): `dagger call check` (cargo fmt/clippy/test on `rust:1-bookworm`) + `dagger call test-nvim` (build hephd + headless e2e). Cargo parallelism capped (`CARGO_BUILD_JOBS`) to avoid OOMing the build engine; cargo caches shared across runs. `prek` runs locally via git hooks, not in CI. - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). - ✅ **`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). -- 2.50.1 (Apple Git-155) From e3db2ac550b6de6c191e056fe4f020179e66cb79 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 09:32:32 -0700 Subject: [PATCH 25/91] heph.nvim: plug-and-play managed daemon (autostart, self-heal, client/server guardrail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .gitignore | 2 + docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 14 +++- heph.nvim/README.md | 27 ++++++-- heph.nvim/lua/heph/config.lua | 29 +++++++-- heph.nvim/lua/heph/daemon.lua | 71 +++++++++++++++++++++ heph.nvim/lua/heph/init.lua | 37 +++++++++-- heph.nvim/lua/heph/rpc.lua | 29 ++++++++- heph.nvim/plugin/heph.lua | 5 +- heph.nvim/tests/e2e/helpers.lua | 12 ++++ heph.nvim/tests/e2e/managed_daemon_spec.lua | 68 ++++++++++++++++++++ 11 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 heph.nvim/tests/e2e/managed_daemon_spec.lua 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/<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. 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/<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 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, -- <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 | 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 `<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 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) -- 2.50.1 (Apple Git-155) From 652d2e89e619e358fc2b3dd23eeba30edcb3bbef Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 09:39:59 -0700 Subject: [PATCH 26/91] =?UTF-8?q?heph:=20dev/installed=20isolation=20?= =?UTF-8?q?=E2=80=94=20`mise=20run=20dev`=20task=20+=20install=20how-to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mise-tasks/dev runs the working-tree hephd on isolated .dev/ paths (gitignored) so in-repo development never touches the installed daemon's data; point a dev nvim at it via HEPH_SOCKET/HEPH_DB. - docs/how-to/install-heph.md: install heph/hephd from the forge (build from source), the lazy.nvim `dir` setup for the subdir plugin, and dev isolation. - gitignore .dev/ and the transient .claude scheduled-task state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .gitignore | 3 + docs/changelog.d/v1-prototype.feature.md | 1 + docs/how-to/how-to.md | 4 ++ docs/how-to/install-heph.md | 81 ++++++++++++++++++++++++ mise-tasks/dev | 26 ++++++++ 5 files changed, 115 insertions(+) create mode 100644 docs/how-to/install-heph.md create mode 100755 mise-tasks/dev diff --git a/.gitignore b/.gitignore index 917fa09..2c50faa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ __pycache__/ # Rust /target/ +# Dev daemon data (isolated from the installed heph; see `mise run dev`) +/.dev/ + # Linter caches .ruff_cache/ diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 2b732b8..264c966 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -18,3 +18,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. +- 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. diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index b6caa2c..1d6c18c 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -13,3 +13,7 @@ Task-oriented guides for common operations. ## Knowledge Base - [[agent-change-process]] — C0/C1/C2 change classification and Mikado method + +## heph + +- [[install-heph]] — Install `heph`/`hephd` from the forge, set up the Neovim plugin, and isolate in-repo development diff --git a/docs/how-to/install-heph.md b/docs/how-to/install-heph.md new file mode 100644 index 0000000..7fcdf68 --- /dev/null +++ b/docs/how-to/install-heph.md @@ -0,0 +1,81 @@ +--- +title: Install heph (and isolate dev) +modified: 2026-06-02 +tags: + - how-to +--- + +# Install heph (and isolate dev) + +How to install `heph`/`hephd` from the forge and run the `heph.nvim` plugin, +**isolated** from in-repo development so the two never share data. No prebuilt +binaries yet — everything builds from source (works on macOS/arm64, +linux/arm64, linux/amd64). + +## 1. Install the binaries from the forge + +Build and install `heph` + `hephd` to `~/.cargo/bin` (on `PATH`) from a forge +ref. Until v1 is tagged, install from the branch: + +```bash +cargo install --locked \ + --git ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.git \ + --branch feature/v1-prototype \ + heph hephd +``` + +Re-run with `--tag vX.Y.Z` once a release is cut. This needs forge SSH access +(an unlocked 1Password / ssh-agent key). + +The **installed** daemon owns the default paths — socket +`$XDG_RUNTIME_DIR/heph/hephd.sock`, DB `$XDG_DATA_HOME/heph/heph.db` — i.e. your +real data. + +## 2. The Neovim plugin + +`heph.nvim` lives in a subdirectory of the monorepo, and lazy.nvim can't load a +subdir plugin from a bare git URL (it puts the clone *root* on `runtimepath`). +For now, point lazy at a dedicated checkout via `dir`: + +```bash +git clone --branch feature/v1-prototype \ + ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.git \ + ~/.local/share/heph/checkout +``` + +```lua +-- lazy.nvim spec +{ + dir = vim.fn.expand("~/.local/share/heph/checkout/heph.nvim"), + config = function() + require("heph").setup({}) -- plug-and-play: spawns + manages its own hephd + 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.) + +## 3. Isolate development + +In-repo development must not touch the installed store. Run the dev daemon on +separate paths and point a dev Neovim at it: + +```bash +mise run dev # runs the working-tree hephd on .dev/hephd.sock + .dev/heph.db +``` + +```bash +# dev Neovim — the plugin reads these envs and targets the dev daemon +HEPH_SOCKET="$PWD/.dev/hephd.sock" HEPH_DB="$PWD/.dev/heph.db" nvim +``` + +The installed plugin (no envs) talks to the installed daemon; the dev Neovim +talks to the dev daemon. They never share a socket or DB. `.dev/` is gitignored. + +## Related + +- [[heph-nvim]] — the plugin surface and its managed-daemon lifecycle +- [[tech-spec]] — §3.1 runtime modes; the daemon's exclusive DB lock diff --git a/mise-tasks/dev b/mise-tasks/dev new file mode 100755 index 0000000..2ad4ac6 --- /dev/null +++ b/mise-tasks/dev @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Run a development hephd on isolated .dev/ paths (never touches the installed daemon's data)" + +# Dev/installed isolation: the installed heph owns the default XDG socket+DB +# (your real data); this runs the dev working-tree daemon on a separate socket +# and DB under .dev/ (gitignored). Point a dev Neovim at it with: +# +# HEPH_SOCKET="$PWD/.dev/hephd.sock" HEPH_DB="$PWD/.dev/heph.db" nvim +# +# (the plugin reads those envs), so dev edits never reach the installed store. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p .dev + +SOCK="$ROOT/.dev/hephd.sock" +DB="$ROOT/.dev/heph.db" + +echo "dev hephd → socket $SOCK" +echo " db $DB" +echo "point a dev nvim at it: HEPH_SOCKET=$SOCK HEPH_DB=$DB nvim" +echo + +exec cargo run -p hephd -- --mode local --socket "$SOCK" --db "$DB" -- 2.50.1 (Apple Git-155) From b932a814caec526f536ec7ae663a85a470148b47 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 10:23:42 -0700 Subject: [PATCH 27/91] heph.nvim: fix daemon.wait_ready deadlock on a stale socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wait_ready ran the rpc health probe inside a `vim.wait` predicate, and the probe itself uses `vim.wait` — nesting vim.wait inside another vim.wait's predicate deadlocks Neovim. It only bit when the socket file existed: the first launch's socket doesn't exist yet (probe short-circuits), but the second launch hit the stale socket left by the prior daemon and froze in setup(). - wait_ready now probes in a plain Lua loop (deadline via uv.hrtime + a bare vim.wait(50) yield) — never a vim.wait inside a vim.wait predicate. - stop_spawned now unlinks the socket on exit, so a clean exit leaves no stale socket (a crash still can — the wait_ready fix handles that too). Verified: two-launch repro no longer hangs; a crash-left stale socket recovers in ~460ms. 10 e2e specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- heph.nvim/lua/heph/daemon.lua | 42 ++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/heph.nvim/lua/heph/daemon.lua b/heph.nvim/lua/heph/daemon.lua index 7e4968f..9792b92 100644 --- a/heph.nvim/lua/heph/daemon.lua +++ b/heph.nvim/lua/heph/daemon.lua @@ -38,25 +38,30 @@ end --- Wait until `socket` both exists and accepts a real RPC (`health`). The --- existence check alone races the daemon's bind→accept, so we prove liveness ---- with a round-trip on a throwaway session. Returns `true`, or `false, reason`. +--- with a round-trip. Returns `true`, or `false, reason`. +--- +--- The probe runs in a **plain Lua loop**, never inside a `vim.wait` predicate: +--- the rpc round-trip itself uses `vim.wait`, and nesting `vim.wait` inside +--- another `vim.wait`'s predicate deadlocks Neovim (a stale socket made the +--- inner connect-wait re-enter and hang). function M.wait_ready(socket, timeout) timeout = timeout or 5000 - if not vim.wait(timeout, function() - return uv.fs_stat(socket) ~= nil - end, 20) then - return false, "socket never appeared: " .. socket + local rpc = require("heph.rpc") + local deadline = uv.hrtime() + timeout * 1e6 -- ns + while uv.hrtime() < deadline do + if uv.fs_stat(socket) ~= nil then + local session = rpc.new_session(socket) + local ok = pcall(function() + session:call("health", vim.empty_dict(), { timeout = 200 }) + end) + session:close() + if ok then + return true + end + end + vim.wait(50) -- yield ~50ms; no predicate, so not nested end - local session = require("heph.rpc").new_session(socket) - local ok = vim.wait(timeout, function() - return pcall(function() - session:call("health", vim.empty_dict(), { timeout = 200 }) - end) - end, 50) - session:close() - if not ok then - return false, "socket present but not accepting rpc: " .. socket - end - return true + return false, "daemon not ready at " .. socket end --- Ensure a daemon is reachable at `opts.socket`. If one is already serving the @@ -124,6 +129,11 @@ function M.stop_spawned() m.handle:close() end end) + -- hephd doesn't unlink its socket on SIGTERM; remove it so the next launch + -- doesn't probe a stale socket. (A crash still leaves one — wait_ready copes.) + pcall(function() + uv.fs_unlink(m.socket) + end) end return M -- 2.50.1 (Apple Git-155) From acb394989603aa8a4280ee02d6a161d71371e55d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 10:29:58 -0700 Subject: [PATCH 28/91] heph.nvim: regression test for the stale-socket wait_ready deadlock Recreates a crash-left stale socket (spawn a managed daemon, SIGKILL it) and asserts wait_ready returns promptly (a deadlock would freeze the suite) and that a fresh autostart recovers. Verified it fails (suite hangs) against the buggy nested-vim.wait version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- heph.nvim/tests/e2e/managed_daemon_spec.lua | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/heph.nvim/tests/e2e/managed_daemon_spec.lua b/heph.nvim/tests/e2e/managed_daemon_spec.lua index 4bd65e1..e170529 100644 --- a/heph.nvim/tests/e2e/managed_daemon_spec.lua +++ b/heph.nvim/tests/e2e/managed_daemon_spec.lua @@ -54,6 +54,46 @@ describe("managed daemon", function() assert.is_true(require("heph.daemon").is_managed()) end) + it("does not deadlock on a stale socket left by a crash (regression)", function() + -- Bring up a managed daemon, then HARD-kill it so no cleanup runs — leaving + -- a stale socket file with no listener (the second-launch crash scenario: + -- wait_ready ran the rpc probe inside a vim.wait predicate, nesting vim.wait + -- and freezing Neovim). + require("heph").setup({ + socket = t.sock, + db = t.db, + bin = h.hephd_bin(), + autostart = true, + keymaps = false, + }) + require("heph.rpc").call("health", {}) + local m = require("heph.daemon")._managed + m.handle:kill("sigkill") + vim.wait(2000, function() + return m.exited.done + end, 20) + assert.is_truthy(vim.uv.fs_stat(t.sock), "precondition: a stale socket is present") + + -- Probing the stale socket must RETURN promptly (not deadlock). The fix + -- returns in ~200ms; the bug froze here indefinitely. + local start = vim.uv.hrtime() + local ready = require("heph.daemon").wait_ready(t.sock, 200) + local elapsed_ms = (vim.uv.hrtime() - start) / 1e6 + assert.is_false(ready) + assert.is_true(elapsed_ms < 2000, "wait_ready took " .. math.floor(elapsed_ms) .. "ms — possible deadlock") + + -- A fresh autostart recovers despite the stale socket still being there. + require("heph").setup({ + socket = t.sock, + db = t.db, + bin = h.hephd_bin(), + autostart = true, + keymaps = false, + }) + 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, -- 2.50.1 (Apple Git-155) From 9249ca46a1e0f565d8d67c56ad837a0587c1d2a7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 11:07:09 -0700 Subject: [PATCH 29/91] heph.nvim: follow-or-create wiki links + :Heph doc Pressing <CR> on a [[wiki-link]] whose target doesn't exist now creates a doc with that title and opens it (the zettelkasten gesture), and materializes the source's backlink: if the source has unsaved edits, saving re-extracts and links it (and persists the edits); otherwise the wiki link is added directly (a no-op re-save wouldn't re-extract). Adds :Heph doc <title> to create a standalone wiki entry. e2e covers both the saved-source and just-typed-source paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 8 +++- heph.nvim/lua/heph/command.lua | 9 +++++ heph.nvim/lua/heph/link.lua | 24 ++++++++++-- heph.nvim/tests/e2e/follow_link_spec.lua | 49 +++++++++++++++++++++--- 5 files changed, 80 insertions(+), 11 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 264c966..ab010a6 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -18,4 +18,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. +- `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. - 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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 1ec7624..2b59636 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -50,14 +50,18 @@ Beyond the existing methods (tech-spec §6), the plugin relies on **`node.resolve {title} → Node | null`**: an exact, owner-scoped, non-tombstoned alias-then-title match — the same mapping the store uses to materialize `wiki` links, so "follow link under cursor" jumps to the *same* -node the stored link points at. +node the stored link points at. When the target doesn't resolve, follow +**creates** a `doc` with that title (the zettelkasten follow-or-create gesture) +and materializes the source's backlink (saving the source if it has unsaved +edits, else adding the `wiki` link directly). ## Commands (as of slice 11c) | Command | Action | |---|---| | `:Heph today` / `:Heph journal <YYYY-MM-DD>` | Open today's / a dated journal | -| `:Heph follow` (also `<CR>` in a node buffer) | Follow the `[[link]]` under the cursor | +| `:Heph follow` (also `<CR>` in a node buffer) | Follow the `[[link]]` under the cursor — **creating** the target doc if it doesn't exist yet | +| `:Heph doc <title>` | Create (and open) a new wiki doc | | `:Heph open <id>` | Open a node buffer by id | | `:Heph search <query>` | Full-text search; pick a result to open | | `:Heph next [scope]` | Tactical "what is next?" view (`<CR>` opens a task's context) | diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 028a93d..5d18dc2 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -23,6 +23,15 @@ M.subs = { require("heph.node").open(args[1]) end end, + doc = function(args) + local title = table.concat(args, " ") + if #title == 0 then + require("heph.util").notify("usage: :Heph doc <title>", vim.log.levels.WARN) + return + end + local node = require("heph.rpc").call("node.create", { kind = "doc", title = title, body = "" }) + require("heph.node").open(node.id) + end, search = function(args) local query = table.concat(args, " ") if #query == 0 then diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 6ef598a..1af44a4 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -36,8 +36,10 @@ function M.target_under_cursor() end end ---- Follow the `[[link]]` under the cursor to its node. Unresolved links are ---- allowed (tech-spec §5) — an INFO toast, not an error. +--- Follow the `[[link]]` under the cursor to its node, **creating** the target +--- doc if it doesn't exist yet (the zettelkasten follow-or-create gesture). The +--- newly-created doc resolves the source's previously-unresolved wiki-link, so +--- re-saving the source materializes the backlink. function M.follow() local target = M.target_under_cursor() if not target then @@ -46,8 +48,22 @@ function M.follow() end local node = rpc.call("node.resolve", { title = target }) if not node then - util.notify("unresolved link [[" .. target .. "]]", vim.log.levels.INFO) - return + node = rpc.call("node.create", { kind = "doc", title = target, body = "" }) + util.notify("created [[" .. target .. "]]") + -- Materialize the source's wiki-link to the new doc — it was unresolved when + -- the source was saved, so extraction skipped it (tech-spec §5). If the + -- source has unsaved edits, saving re-extracts and materializes it (and + -- persists the edits); otherwise add the link directly (a no-op re-save + -- wouldn't re-extract). + local src = vim.api.nvim_get_current_buf() + local src_id = vim.b[src].heph_node_id + if src_id then + if vim.bo[src].modified then + pcall(require("heph.node").write, src, vim.api.nvim_buf_get_name(src)) + else + pcall(rpc.call, "links.add", { src = src_id, dst = node.id, link_type = "wiki" }) + end + end end require("heph.node").open(node.id) end diff --git a/heph.nvim/tests/e2e/follow_link_spec.lua b/heph.nvim/tests/e2e/follow_link_spec.lua index d9395cf..2079ace 100644 --- a/heph.nvim/tests/e2e/follow_link_spec.lua +++ b/heph.nvim/tests/e2e/follow_link_spec.lua @@ -29,12 +29,51 @@ describe("follow link", function() assert.are.equal(b.id, vim.b[cur].heph_node_id) end) - it("leaves an unresolved [[link]] in place without erroring", function() - local a = h.create_doc("Lonely", "points to [[Nowhere]]") + it("creates the target doc when following an unresolved [[link]]", function() + local a = h.create_doc("Daily", "see [[New Topic]]") local buf = h.open(a.id) - vim.api.nvim_win_set_cursor(0, { 1, 12 }) -- inside [[Nowhere]] + local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[New") + vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) -- inside [[New Topic]] + require("heph.link").follow() - -- Still on the same buffer; no jump happened. - assert.are.equal(buf, vim.api.nvim_get_current_buf()) + + -- A new doc titled "New Topic" was created and opened. + local created = ctx.q:call("node.resolve", { title = "New Topic" }) + assert.is_truthy(created, "expected the target doc to be created") + assert.are.equal("heph://node/" .. created.id, vim.api.nvim_buf_get_name(0)) + + -- ...and the source now backlinks it (the wiki-link materialized). + local linked = false + for _, l in ipairs(ctx.q:call("links.backlinks", { id = created.id })) do + if l.src_id == a.id and l.link_type == "wiki" then + linked = true + end + end + assert.is_true(linked, "expected the source to backlink the created doc") + end) + + it("creates + links from an unsaved [[link]] just typed into the buffer", function() + -- The real gesture: open a note, type a new [[link]], <CR> without :w. + local a = h.create_doc("Journalish", "") + local buf = h.open(a.id) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "ref [[Fresh Note]]" }) + assert.is_true(vim.bo[buf].modified) + local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[Fresh") + vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) + + require("heph.link").follow() + + local created = ctx.q:call("node.resolve", { title = "Fresh Note" }) + assert.is_truthy(created) + assert.are.equal("heph://node/" .. created.id, vim.api.nvim_buf_get_name(0)) + -- The source's pending edit was persisted and the backlink materialized. + assert.are.equal("ref [[Fresh Note]]", ctx.q:call("node.get", { id = a.id }).body) + local linked = false + for _, l in ipairs(ctx.q:call("links.backlinks", { id = created.id })) do + if l.src_id == a.id and l.link_type == "wiki" then + linked = true + end + end + assert.is_true(linked, "expected the source edit saved and backlink materialized") end) end) -- 2.50.1 (Apple Git-155) From e99c28494173789f6730609e7488ff1d450a44f0 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 11:14:12 -0700 Subject: [PATCH 30/91] =?UTF-8?q?heph.nvim:=20:Heph=20home=20=E2=80=94=20a?= =?UTF-8?q?=20base=20index/landing=20page=20for=20the=20zk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single designated "home" doc (open-or-create by title, configurable via opts.home, default "Home") — a stable landing page to grow a map of content around. e2e covers create-on-first-open + idempotent reuse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/heph-nvim.md | 1 + heph.nvim/lua/heph/command.lua | 4 ++++ heph.nvim/lua/heph/config.lua | 2 ++ heph.nvim/lua/heph/home.lua | 27 ++++++++++++++++++++++++ heph.nvim/tests/e2e/home_spec.lua | 27 ++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 heph.nvim/lua/heph/home.lua create mode 100644 heph.nvim/tests/e2e/home_spec.lua diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index ab010a6..fa6eae6 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -18,5 +18,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. -- `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. +- `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. - 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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 2b59636..669315f 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -59,6 +59,7 @@ edits, else adding the `wiki` link directly). | Command | Action | |---|---| +| `:Heph home` | Open the home / index landing page (created on first use; title via `opts.home`) | | `:Heph today` / `:Heph journal <YYYY-MM-DD>` | Open today's / a dated journal | | `:Heph follow` (also `<CR>` in a node buffer) | Follow the `[[link]]` under the cursor — **creating** the target doc if it doesn't exist yet | | `:Heph doc <title>` | Create (and open) a new wiki doc | diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 5d18dc2..3eeecad 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -9,6 +9,10 @@ local ATTENTIONS = { "white", "orange", "red", "blue" } --- subcommand -> handler(args: string[]) M.subs = { -- knowledge base + home = function() + local cfg = require("heph").config or {} + require("heph.home").open(cfg.home) + end, today = function() require("heph.journal").open() end, diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index 6c5d8e0..1ad9da6 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -13,6 +13,8 @@ M.defaults = { autostart = true, --- hephd binary for autostart (on PATH for an installed heph). bin = "hephd", + --- Title of the home / index page (`:Heph home`). + home = "Home", --- Set the default `<leader>h*` keymaps. `false` to opt out. keymaps = true, } diff --git a/heph.nvim/lua/heph/home.lua b/heph.nvim/lua/heph/home.lua new file mode 100644 index 0000000..6d72c5c --- /dev/null +++ b/heph.nvim/lua/heph/home.lua @@ -0,0 +1,27 @@ +--- The home / index page (tech-spec §8): a single designated `doc` that is the +--- base landing page of the knowledge base — a stable place to grow a map of +--- content. Open-or-create by title, so the first `:Heph home` mints it and +--- every later one returns the same doc. + +local rpc = require("heph.rpc") + +local M = {} + +--- Open (creating if absent) the home page titled `title` (default "Home"). +--- Returns the node. +function M.open(title) + title = (title and #title > 0) and title or "Home" + local node = rpc.call("node.resolve", { title = title }) + if not node then + node = rpc.call("node.create", { + kind = "doc", + title = title, + body = "# " .. title .. "\n\n", + }) + require("heph.util").notify("created home page [[" .. title .. "]]") + end + require("heph.node").open(node.id) + return node +end + +return M diff --git a/heph.nvim/tests/e2e/home_spec.lua b/heph.nvim/tests/e2e/home_spec.lua new file mode 100644 index 0000000..170a08f --- /dev/null +++ b/heph.nvim/tests/e2e/home_spec.lua @@ -0,0 +1,27 @@ +-- The home / index landing page: open-or-create by title, idempotent. + +local h = require("e2e.helpers") + +describe("home page", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("creates the home page on first open and reuses it after", function() + local n1 = require("heph.home").open("Home") + assert.is_truthy(n1.id) + assert.are.equal("heph://node/" .. n1.id, vim.api.nvim_buf_get_name(0)) + assert.are.equal("doc", n1.kind) + + -- Reopening returns the SAME doc (open-or-create is idempotent by title). + local n2 = require("heph.home").open("Home") + assert.are.equal(n1.id, n2.id) + + -- It's a real, resolvable wiki target, so other notes can [[Home]] into it. + assert.are.equal(n1.id, ctx.q:call("node.resolve", { title = "Home" }).id) + end) +end) -- 2.50.1 (Apple Git-155) From d0930aa6a3e4c4ac2f6821d3e24299b2741e70c0 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 11:26:45 -0700 Subject: [PATCH 31/91] =?UTF-8?q?heph.nvim:=20:Heph=20journals=20=E2=80=94?= =?UTF-8?q?=20recent-days=20picker=20with=20preview=20+=20@create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dailies picker (zkd-style): lists the last `journal_days` (default 7) days newest-first, previews existing journals and shows "@create" for new ones, and opens the chosen day (creating if new). Journals resolve by their ISO-date title, so no new RPC is needed. picker.select gains an optional Telescope preview pane. e2e covers the recent-days list (exists/@create across a month boundary) and open-on-pick via a stubbed vim.ui.select. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/heph-nvim.md | 1 + heph.nvim/lua/heph/command.lua | 3 ++ heph.nvim/lua/heph/config.lua | 2 + heph.nvim/lua/heph/journal.lua | 43 ++++++++++++++++++ heph.nvim/lua/heph/picker.lua | 14 +++++- heph.nvim/tests/e2e/journal_picker_spec.lua | 50 +++++++++++++++++++++ 7 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 heph.nvim/tests/e2e/journal_picker_spec.lua diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index fa6eae6..33977e2 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -18,5 +18,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. -- `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.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. - 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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 669315f..59db74e 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -61,6 +61,7 @@ edits, else adding the `wiki` link directly). |---|---| | `:Heph home` | Open the home / index landing page (created on first use; title via `opts.home`) | | `:Heph today` / `:Heph journal <YYYY-MM-DD>` | Open today's / a dated journal | +| `:Heph journals` | Pick among recent days (preview existing, `@create` for new); count via `opts.journal_days` | | `:Heph follow` (also `<CR>` in a node buffer) | Follow the `[[link]]` under the cursor — **creating** the target doc if it doesn't exist yet | | `:Heph doc <title>` | Create (and open) a new wiki doc | | `:Heph open <id>` | Open a node buffer by id | diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 3eeecad..af63122 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -19,6 +19,9 @@ M.subs = { journal = function(args) require("heph.journal").open(args[1]) end, + journals = function() + require("heph.journal").pick() + end, follow = function() require("heph.link").follow() end, diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index 1ad9da6..0571b40 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -15,6 +15,8 @@ M.defaults = { bin = "hephd", --- Title of the home / index page (`:Heph home`). home = "Home", + --- How many recent days the `:Heph journals` picker offers. + journal_days = 7, --- Set the default `<leader>h*` keymaps. `false` to opt out. keymaps = true, } diff --git a/heph.nvim/lua/heph/journal.lua b/heph.nvim/lua/heph/journal.lua index 389ab81..691ad5a 100644 --- a/heph.nvim/lua/heph/journal.lua +++ b/heph.nvim/lua/heph/journal.lua @@ -16,4 +16,47 @@ function M.open(date) return node end +local function iso_to_time(iso) + local y, m, d = iso:match("(%d+)-(%d+)-(%d+)") + return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 12 }) +end + +--- Entries for the `n` most recent days ending at `today` (ISO; default today), +--- newest first. Each: `{ date, node|nil, exists }`. Journals are titled by +--- their ISO date, so each day resolves directly. +function M.recent_entries(n, today) + today = (today and #today > 0) and today or util.iso_today() + local t0 = iso_to_time(today) + local entries = {} + for i = 0, n - 1 do + local date = os.date("%Y-%m-%d", t0 - i * 86400) + local node = rpc.call("node.resolve", { title = date }) + entries[#entries + 1] = { date = date, node = node, exists = node ~= nil } + end + return entries +end + +--- Pick among recent journal days — existing days preview their content, new +--- days show `@create` — and open the chosen day's journal (creating if new). +function M.pick(opts) + opts = opts or {} + local days = opts.days or (require("heph").config or {}).journal_days or 7 + require("heph.picker").select(M.recent_entries(days), { + prompt = "heph journals", + format = function(e) + return e.exists and e.date or (e.date .. " @create") + end, + preview = function(e) + if e.exists and e.node and e.node.body and #e.node.body > 0 then + return vim.split(e.node.body, "\n", { plain = true }) + end + return { "@create — new journal for " .. e.date } + end, + }, function(e) + if e then + M.open(e.date) + end + end) +end + return M diff --git a/heph.nvim/lua/heph/picker.lua b/heph.nvim/lua/heph/picker.lua index b7726db..de477c9 100644 --- a/heph.nvim/lua/heph/picker.lua +++ b/heph.nvim/lua/heph/picker.lua @@ -9,18 +9,27 @@ local function telescope_available() return pcall(require, "telescope") end ---- Select one of `items`. `opts.prompt`, `opts.format(item)->string`. +--- Select one of `items`. `opts.prompt`, `opts.format(item)->string`, and an +--- optional `opts.preview(item)->lines` (Telescope only; markdown-rendered). --- `on_choice(item|nil, index|nil)` — nil when cancelled. function M.select(items, opts, on_choice) opts = opts or {} if not vim.g.heph_force_ui_select and telescope_available() then -- Telescope path: a thin wrapper so fuzzy UX is available when present. - -- (The dropdown is intentionally minimal; richer pickers can come later.) local pickers = require("telescope.pickers") local finders = require("telescope.finders") local conf = require("telescope.config").values local actions = require("telescope.actions") local action_state = require("telescope.actions.state") + local previewer = nil + if opts.preview then + previewer = require("telescope.previewers").new_buffer_previewer({ + define_preview = function(self, entry) + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, opts.preview(entry.value) or {}) + vim.bo[self.state.bufnr].filetype = "markdown" + end, + }) + end pickers .new({}, { prompt_title = opts.prompt or "heph", @@ -32,6 +41,7 @@ function M.select(items, opts, on_choice) end, }), sorter = conf.generic_sorter({}), + previewer = previewer, attach_mappings = function(bufnr) actions.select_default:replace(function() actions.close(bufnr) diff --git a/heph.nvim/tests/e2e/journal_picker_spec.lua b/heph.nvim/tests/e2e/journal_picker_spec.lua new file mode 100644 index 0000000..4b171aa --- /dev/null +++ b/heph.nvim/tests/e2e/journal_picker_spec.lua @@ -0,0 +1,50 @@ +-- The recent-days journal picker (the `zkd`-style dailies picker). + +local h = require("e2e.helpers") + +describe("journal picker", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("lists recent days newest-first with @create state", function() + local entries = require("heph.journal").recent_entries(5, "2026-06-02") + assert.are.equal(5, #entries) + assert.are.equal("2026-06-02", entries[1].date) -- newest first + assert.are.equal("2026-05-29", entries[5].date) -- crosses the month boundary + for _, e in ipairs(entries) do + assert.is_false(e.exists) -- nothing created yet + end + + -- Create one day's journal; it now reports as existing (with its node). + ctx.q:call("journal.open_or_create", { date = "2026-05-31" }) + local found + for _, e in ipairs(require("heph.journal").recent_entries(5, "2026-06-02")) do + if e.date == "2026-05-31" then + found = e + end + end + assert.is_true(found.exists) + assert.is_truthy(found.node) + end) + + it("opens the picked day's journal", function() + vim.g.heph_force_ui_select = true + local orig, picked = vim.ui.select, nil + vim.ui.select = function(items, _opts, on_choice) + picked = items[1] -- choose the newest day + on_choice(items[1]) + end + require("heph.journal").pick() + vim.ui.select, vim.g.heph_force_ui_select = orig, nil + + assert.is_truthy(picked) + local buf = vim.api.nvim_get_current_buf() + assert.are.equal("journal", vim.b[buf].heph_node_kind) + assert.are.equal(picked.date, ctx.q:call("node.get", { id = vim.b[buf].heph_node_id }).title) + end) +end) -- 2.50.1 (Apple Git-155) From 0462a6e43b09512845824e379a99cc443d18d4e2 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 11:48:09 -0700 Subject: [PATCH 32/91] heph.nvim: interactive next/list views (add, done, refresh) + key hint The Tactical next and Organizational list buffers are now actionable: - a add a task from the list (prompt title + attention) - d mark the task under the cursor done - r refresh - <CR> open the task's context (as before) A dimmed key hint renders above the rows as a virtual line (extmark), so it's discoverable without taking a task row. e2e covers add-from-list and done-from-list via stubbed vim.ui.input/select. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/heph-nvim.md | 4 +- heph.nvim/lua/heph/view.lua | 103 ++++++++++++++++++---- heph.nvim/tests/e2e/view_actions_spec.lua | 60 +++++++++++++ 4 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 heph.nvim/tests/e2e/view_actions_spec.lua diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 33977e2..7b5fce7 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -18,5 +18,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. -- `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. +- `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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 59db74e..7987101 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -77,7 +77,9 @@ edits, else adding the `wiki` link directly). "Current task" is resolved from the buffer: a `task` node, or a canonical-context doc whose owning task is followed via its `canonical-context` backlink. The `next`/`list` views render the titled rows the daemon returns (`list` enriched to -carry titles + the context id, so no N+1 `node.get`). Pickers use built-in +carry titles + the context id, so no N+1 `node.get`) and 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. Pickers use built-in `vim.ui.select`, auto-upgrading to Telescope when installed. **Promotion** (`:Heph promote`) mints a committed task from the `- [ ]` line diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua index 82e6792..c8915fb 100644 --- a/heph.nvim/lua/heph/view.lua +++ b/heph.nvim/lua/heph/view.lua @@ -1,22 +1,38 @@ --- Task list views (tech-spec §8): Tactical `next` (the "what is next?" ranking) --- and Organizational `list` (the whole outstanding set). Both render the same ---- titled rows the daemon returns into a scratch buffer; `<CR>` opens the task ---- under the cursor's canonical-context doc (the one-keystroke jump). - +--- titled rows the daemon returns into a scratch buffer, and are interactive: +--- <CR> open the task's canonical-context doc +--- a add a new task (prompt title + attention) from the list +--- d mark the task under the cursor done +--- r refresh local rpc = require("heph.rpc") local M = {} --- buf -> { tasks = <RankedTask[]> }; line N maps to tasks[N]. +-- buf -> { tasks = <RankedTask[]>, refresh = fn }; line N maps to tasks[N]. M._views = {} +local hint_ns = vim.api.nvim_create_namespace("heph_view_hint") +local HINT = " <CR> open a add d done r refresh" + +local ATTENTIONS = { "white", "orange", "red", "blue" } + local function row(t) local tag = t.attention and ("[" .. t.attention .. "]") or "[ ]" return string.format("%s %s", tag, t.title) end --- Find or create the named scratch buffer and fill it with task rows. -local function render(name, tasks) +local function task_on_line(buf) + local view = M._views[buf] + if not view then + return nil + end + return view.tasks[vim.api.nvim_win_get_cursor(0)[1]] +end + +-- Find or create the named scratch buffer, fill it, and (re)bind its keymaps. +-- `refresh` re-runs the query+render so actions can reflect their changes. +local function render(name, tasks, refresh) local buf for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b) == name then @@ -37,16 +53,36 @@ local function render(name, tasks) lines[#lines + 1] = row(t) end if #lines == 0 then - lines = { "(nothing here)" } + lines = { "(nothing here — press 'a' to add a task)" } end vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modifiable = false - M._views[buf] = { tasks = tasks } - vim.keymap.set("n", "<CR>", function() + -- A dimmed key hint above the first row — a virtual line, so it isn't a task + -- row (cursor lines still map 1:1 to tasks). + vim.api.nvim_buf_clear_namespace(buf, hint_ns, 0, -1) + vim.api.nvim_buf_set_extmark(buf, hint_ns, 0, 0, { + virt_lines_above = true, + virt_lines = { { { HINT, "Comment" } } }, + }) + + M._views[buf] = { tasks = tasks, refresh = refresh } + local function map(lhs, fn, desc) + vim.keymap.set("n", lhs, fn, { buffer = buf, desc = desc }) + end + map("<CR>", function() M.open_under_cursor(buf) - end, { buffer = buf, desc = "heph: open task context" }) + end, "heph: open task context") + map("a", function() + M.add_from(buf) + end, "heph: add a task") + map("d", function() + M.done_under_cursor(buf) + end, "heph: mark task done") + map("r", function() + M.refresh(buf) + end, "heph: refresh") vim.api.nvim_set_current_buf(buf) return buf end @@ -54,23 +90,52 @@ end --- Open the canonical-context doc of the task on the cursor line. function M.open_under_cursor(buf) buf = buf or vim.api.nvim_get_current_buf() - local view = M._views[buf] - if not view then - return + local t = task_on_line(buf) + if t then + require("heph.node").open(t.canonical_context_id or t.node_id) end - local lnum = vim.api.nvim_win_get_cursor(0)[1] - local t = view.tasks[lnum] +end + +--- Re-run the view's query and re-render in place. +function M.refresh(buf) + local view = M._views[buf or vim.api.nvim_get_current_buf()] + if view and view.refresh then + view.refresh() + end +end + +--- Add a task from the list: prompt a title, pick an attention, capture, refresh. +function M.add_from(buf) + vim.ui.input({ prompt = "New task: " }, function(title) + if not title or #title == 0 then + return + end + require("heph.picker").select(ATTENTIONS, { prompt = "attention for: " .. title }, function(attention) + require("heph.task").capture(title, { attention = attention }) + require("heph.util").notify("captured: " .. title) + M.refresh(buf) + end) + end) +end + +--- Mark the task on the cursor line done, then refresh. +function M.done_under_cursor(buf) + local t = task_on_line(buf) if not t then return end - require("heph.node").open(t.canonical_context_id or t.node_id) + rpc.call("task.set_state", { id = t.node_id, state = "done" }) + require("heph.util").notify("done: " .. t.title) + M.refresh(buf) end --- Tactical "what is next?" — render the ranking, return the rows. function M.next(opts) opts = opts or {} local tasks = rpc.call("next", { scope = opts.scope, limit = opts.limit or 5 }) - render("heph://next", tasks) + render("heph://next", tasks, function() + M.next(opts) + end) return tasks end @@ -82,7 +147,9 @@ function M.list(opts) attention = opts.attention, include_blue = opts.include_blue ~= false, }) - render("heph://list", tasks) + render("heph://list", tasks, function() + M.list(opts) + end) return tasks end diff --git a/heph.nvim/tests/e2e/view_actions_spec.lua b/heph.nvim/tests/e2e/view_actions_spec.lua new file mode 100644 index 0000000..1f1d998 --- /dev/null +++ b/heph.nvim/tests/e2e/view_actions_spec.lua @@ -0,0 +1,60 @@ +-- Interactive task-list actions: add a task from the list, mark done from it. + +local h = require("e2e.helpers") + +describe("task list actions", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("adds a task from the list buffer", function() + require("heph.view").list() + local buf = vim.api.nvim_get_current_buf() + + vim.g.heph_force_ui_select = true + local orig_input, orig_select = vim.ui.input, vim.ui.select + vim.ui.input = function(_o, cb) + cb("Buy milk") + end + vim.ui.select = function(_items, _o, cb) + cb("orange") + end + require("heph.view").add_from(buf) + vim.ui.input, vim.ui.select, vim.g.heph_force_ui_select = orig_input, orig_select, nil + + -- The task was created with the chosen attention... + local found + for _, t in ipairs(ctx.q:call("list", {})) do + if t.title == "Buy milk" then + found = t + end + end + assert.is_truthy(found, "task not created") + assert.are.equal("orange", found.attention) + + -- ...and the list refreshed to show it. + local present = false + for _, l in ipairs(vim.api.nvim_buf_get_lines(vim.api.nvim_get_current_buf(), 0, -1, false)) do + if l:find("Buy milk", 1, true) then + present = true + end + end + assert.is_true(present, "added task missing from the refreshed list") + end) + + it("marks the task under the cursor done from the list buffer", function() + local t = ctx.q:call("task.create", { title = "Ship it", attention = "red" }) + require("heph.view").list() + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + require("heph.view").done_under_cursor(buf) + + assert.are.equal("done", ctx.q:call("task.get", { id = t.node_id }).state) + assert.are.equal(0, #ctx.q:call("list", {})) -- gone from the refreshed list + end) +end) -- 2.50.1 (Apple Git-155) From 95948d5563214dedceae8a50fcbf8d88d9695b6a Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 12:01:36 -0700 Subject: [PATCH 33/91] heph.nvim: make the view key-hint actually visible (header line, not virt-line) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit virt_lines_above on the first line renders above the top screen row, which can't be scrolled into view — so the hint was invisible. Use a real header line at the top of next/list (dimmed via an extmark highlight); task rows start at line 2, and the cursor lands on the first task. Specs updated for the +1 offset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- heph.nvim/lua/heph/view.lua | 20 ++++++++++++-------- heph.nvim/tests/e2e/capture_spec.lua | 5 +++-- heph.nvim/tests/e2e/view_actions_spec.lua | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua index c8915fb..5c1063f 100644 --- a/heph.nvim/lua/heph/view.lua +++ b/heph.nvim/lua/heph/view.lua @@ -27,7 +27,8 @@ local function task_on_line(buf) if not view then return nil end - return view.tasks[vim.api.nvim_win_get_cursor(0)[1]] + -- line 1 is the key-hint header; task rows start at line 2. + return view.tasks[vim.api.nvim_win_get_cursor(0)[1] - 1] end -- Find or create the named scratch buffer, fill it, and (re)bind its keymaps. @@ -48,23 +49,23 @@ local function render(name, tasks, refresh) vim.bo[buf].bufhidden = "hide" vim.bo[buf].swapfile = false - local lines = {} + -- Line 1 is a dimmed key hint; task rows follow. + local lines = { HINT } for _, t in ipairs(tasks) do lines[#lines + 1] = row(t) end - if #lines == 0 then - lines = { "(nothing here — press 'a' to add a task)" } + if #tasks == 0 then + lines[#lines + 1] = "(nothing here — press 'a' to add a task)" end vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modifiable = false - -- A dimmed key hint above the first row — a virtual line, so it isn't a task - -- row (cursor lines still map 1:1 to tasks). vim.api.nvim_buf_clear_namespace(buf, hint_ns, 0, -1) vim.api.nvim_buf_set_extmark(buf, hint_ns, 0, 0, { - virt_lines_above = true, - virt_lines = { { { HINT, "Comment" } } }, + end_row = 0, + end_col = #HINT, + hl_group = "Comment", }) M._views[buf] = { tasks = tasks, refresh = refresh } @@ -84,6 +85,9 @@ local function render(name, tasks, refresh) M.refresh(buf) end, "heph: refresh") vim.api.nvim_set_current_buf(buf) + if #tasks > 0 then + pcall(vim.api.nvim_win_set_cursor, 0, { 2, 0 }) -- land on the first task row + end return buf end diff --git a/heph.nvim/tests/e2e/capture_spec.lua b/heph.nvim/tests/e2e/capture_spec.lua index 2bba877..f4891a7 100644 --- a/heph.nvim/tests/e2e/capture_spec.lua +++ b/heph.nvim/tests/e2e/capture_spec.lua @@ -29,8 +29,9 @@ describe("task capture to done", function() assert.are.equal(task.node_id, ranked[1].node_id) assert.is_truthy(ranked[1].canonical_context_id) - -- Jump to its canonical context from the view (the one-keystroke jump). - vim.api.nvim_win_set_cursor(0, { 1, 0 }) + -- Jump to its canonical context from the view (line 1 is the hint header, + -- task rows start at line 2). + vim.api.nvim_win_set_cursor(0, { 2, 0 }) require("heph.view").open_under_cursor() local ctxbuf = vim.api.nvim_get_current_buf() assert.are.equal( diff --git a/heph.nvim/tests/e2e/view_actions_spec.lua b/heph.nvim/tests/e2e/view_actions_spec.lua index 1f1d998..110c57a 100644 --- a/heph.nvim/tests/e2e/view_actions_spec.lua +++ b/heph.nvim/tests/e2e/view_actions_spec.lua @@ -50,7 +50,7 @@ describe("task list actions", function() local t = ctx.q:call("task.create", { title = "Ship it", attention = "red" }) require("heph.view").list() local buf = vim.api.nvim_get_current_buf() - vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) -- line 1 is the hint header require("heph.view").done_under_cursor(buf) -- 2.50.1 (Apple Git-155) From 7188daeb324b545376725599cddbd8c503d321f7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 12:27:43 -0700 Subject: [PATCH 34/91] =?UTF-8?q?docs:=20refresh=20=C2=A714=20tracker=20+?= =?UTF-8?q?=20README/AGENTS=20for=20the=20heph.nvim=20UX=20iteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin is built, installed, and well past 11a–11c. Record the post-11c UX iteration (managed daemon + self-heal, follow-or-create, home/index, dailies picker, interactive views, dev isolation, fully-Dagger CI) as done, and reset the "not yet done" backlog to lead with the highest-value next work: task-scheduling UX (do-date/late-on/recurrence from the editor), then more surfacing (backlinks/tags/health/log-read), 11d (deferred), and the heph.nvim repo split. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- AGENTS.md | 4 ++-- README.md | 7 ++++--- docs/reference/tech-spec.md | 17 +++++++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index caec98b..6841801 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ See [[agent-change-process]] for the full methodology. ## Project Structure -A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; the **Rust backend is feature-complete** (all three runtime modes + sync + OIDC auth) — the remaining v1 work is `heph.nvim`. The live progress tracker is **[[tech-spec]] §14**. +A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; the **Rust backend is feature-complete** (all three runtime modes + sync + OIDC auth) and **`heph.nvim` is built and installed** (knowledge base + tasks + a plug-and-play managed daemon). Remaining v1 work is task-scheduling UX + polish (the live tracker is **[[tech-spec]] §14**). ``` ./Cargo.toml # workspace manifest (shared deps + members) @@ -49,7 +49,7 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too # recurrence, "what is next?" ranking, op-log/HLC/CRDT (yrs) sync ./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc + OIDC auth ./crates/heph/ # CLI (thin client of hephd): next/task/doc/get/export/search/journal/auth -./heph.nvim/ # Neovim plugin (planned, next slice): primary surface; replaces obsidian.nvim +./heph.nvim/ # Neovim plugin: primary surface; replaces obsidian.nvim (Lua + headless e2e) ./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content ./docs/changelog.d/ # towncrier fragments for noteworthy changes ./.dagger/ # Dagger module (src/hephaestus_ci/) backing docs builds and releases diff --git a/README.md b/README.md index fe8e7e3..3357640 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — nearly feature-complete** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and op exchange is authenticated end-to-end with OIDC** (Authentik): the hub verifies bearer tokens (JWKS/RS256) and enforces single-tenant ownership, and `heph auth login` runs the device-code flow, caching tokens in the OS keyring. The offline-first everyday config (`local` + `hub_url`) converges with a `yrs` text-CRDT merging bodies. Remaining: the Neovim plugin (the primary surface). Built test-first (112 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — nearly feature-complete** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and op exchange is authenticated end-to-end with OIDC** (Authentik): the hub verifies bearer tokens (JWKS/RS256) and enforces single-tenant ownership, and `heph auth login` runs the device-code flow, caching tokens in the OS keyring. The offline-first everyday config (`local` + `hub_url`) converges with a `yrs` text-CRDT merging bodies. The **`heph.nvim` plugin is built and installed on the dev machine** — knowledge base (journals, wiki-links with follow-or-create, a home/index page, a dailies picker), tasks (capture, the "what is next?" view, interactive list, promotion), and a plug-and-play self-healing daemon. CI is green, fully through Dagger. Built test-first (117 Rust + 17 nvim e2e). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -27,6 +27,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | `heph.nvim` (primary surface) — RPC client, buffer-backed editing, wiki-link follow, journal (slice 11a) | ✅ done | | `heph.nvim` — Tactical/Organizational task views, capture, attention, done/drop, log (slice 11b) | ✅ done | | `heph.nvim` — context-item promotion + Dagger headless-nvim CI (slice 11c) | ✅ done | +| `heph.nvim` — managed daemon (plug-and-play, self-heal), follow-or-create, home/index, dailies picker, interactive views; installed + dev-isolated | ✅ done | ## Architecture @@ -35,7 +36,7 @@ A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph-core`** — the library: data model, the `Store` trait + SQLite store, markdown parsing/extraction, recurrence, the "what is next?" engine, and the sync engine (op-log, hybrid logical clocks, CRDT/LWW merge, conflict detection). Synchronous and clock-injected (no ambient wall-clock reads) so ranking and merge are deterministic. - **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica; a syncing spoke when given `--hub-url`), **`server`** (also the sync hub: an HTTP endpoint others sync against), **`client`** (thin, remote, no replica — proxies to a `--server-url`) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and background sync. - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). -- **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. +- **`heph.nvim/`** — the Neovim plugin, the primary editing/agenda surface (a thin client of the daemon; see [docs/reference/heph-nvim.md](docs/reference/heph-nvim.md) and [docs/how-to/install-heph.md](docs/how-to/install-heph.md)). **Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate — and clients obtain tokens via the OAuth 2.0 device-code flow (`heph auth login`), cached in the OS keyring. Local-only instances need no auth. @@ -88,7 +89,7 @@ mise run ai-docs # docs AI agents read firs ./crates/heph-core/ # core library: model, store, extraction, recurrence, ranking, sync ./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc ./crates/heph/ # CLI: thin client of the daemon -./heph.nvim/ # Neovim plugin (planned) +./heph.nvim/ # Neovim plugin (primary surface) — Lua, headless e2e harness ./docs/ # Diataxis docs (design, tech-spec, how-to), Quartz config ./.forgejo/ # CI build + release workflows and hooks ./.dagger/ # Dagger module backing docs builds/releases diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 4f1536a..3d8f94a 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-02 — **117 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`mise run test-nvim`, 7 specs; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-02 — **117 Rust tests** (`cargo test --all`) + **17 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** @@ -348,14 +348,23 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). - ✅ **`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. + - **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. + - **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI. **Not yet done (resume order)** > The Rust backend is feature-complete; `heph.nvim` slices 11a–11c are done — the v1 surface (knowledge base + task views + promotion) works end-to-end with CI. The remainder is the deferred reconcile slice plus non-blocking polish + an end-of-v1 sweep (§11). -1. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -3. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -4. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **Task-scheduling UX (§7, §8) — the highest-value next gap:** the plugin can only set a task's **title + attention** today. The engine fully supports **do-date / late-on / recurrence / project**, but nothing in the editor sets them — so the ranking has no urgency signals to work with from nvim. Surface these (capture-with-fields and an edit affordance), and show do/late in the `next`/`list` rows. +2. ⏳ **More surfacing of existing engine features (§6, §8):** a **backlinks/links** picker (navigate the graph back), **tags**, a **`health`** working-set view (orange-vs-6, active-vs-~30), and **log read** (`log.tail`) — the resumption breadcrumb on return. +3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +4. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). +5. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +6. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related -- 2.50.1 (Apple Git-155) From 0aa7e725a550a5d7f406a787e8fd12e6dfed297a Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 19:21:05 -0700 Subject: [PATCH 35/91] docs: revise to a three-surface model (CLI/TUI/nvim) from a Todoist study MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Study of the owner's live Todoist (387 tasks, 34 hierarchical projects) grounds a surface-strategy revision: structured task fields suit CLI flags, large-set triage suits an interactive TUI, and context/KB suits nvim — so v1 adopts three surfaces, each to its strength. Supersedes the earlier "heph.nvim is the primary surface" framing. - design.md: new §6.2.1 (Todoist study) + revised §4 (three-surface model) - tech-spec §1/§8: reframe surface roles; new §8.1 (planned heph-tui agenda) - tech-spec §14: reorder remaining work (CLI-complete task surface in progress, heph-tui next, nvim navigation polish, tags/hierarchy deferred) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.doc.md | 1 + docs/explanation/design.md | 26 ++++++++++++++- docs/reference/tech-spec.md | 49 +++++++++++++++++++--------- 3 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 docs/changelog.d/v1-prototype.doc.md diff --git a/docs/changelog.d/v1-prototype.doc.md b/docs/changelog.d/v1-prototype.doc.md new file mode 100644 index 0000000..8867085 --- /dev/null +++ b/docs/changelog.d/v1-prototype.doc.md @@ -0,0 +1 @@ +- Surface strategy revised to a **three-surface model** ([[design]] §4, tech-spec §1/§8), grounded in a study of the owner's live Todoist usage (387 active tasks, 34 hierarchical projects — [[design]] §6.2.1): the **CLI** is task capture/scripting plus the complete daemon API, a planned **`heph-tui`** terminal UI (tech-spec §8.1) is the primary task agenda/triage surface, and **`heph.nvim`** is the primary context/knowledge-base surface. The study also confirms do-date-not-due-date (zero deadlines used), that task descriptions are already full of unresolved `[[wiki-links]]` (the exact task↔KB fusion heph provides), and surfaces new needs (project hierarchy, natural-language recurrence) while showing tags are negligible. diff --git a/docs/explanation/design.md b/docs/explanation/design.md index ff7b711..54d30a3 100644 --- a/docs/explanation/design.md +++ b/docs/explanation/design.md @@ -89,7 +89,11 @@ A **recurring task** carries an **RFC-5545 RRULE** and acts as a recurring **def Layers, top to bottom: -- **Surfaces (thin clients):** the **nvim plugin (`heph.nvim`)** is the **primary surface for v1** — a full "org-mode"-style experience (markdown buffers backed by `doc` nodes; agenda / "what is next" / capture / linking / journaling as plugin commands). It is the explicit **successor to obsidian.nvim**, which the owner currently drives the ZK with (telescope picker, `<Leader>o*` verbs, `<Enter>` to follow `[[wiki-links]]`, dailies, multi-state checkboxes) and rarely uses Obsidian-proper alongside. heph.nvim must reach that feature parity (see §6.5) and replace it. The **CLI** (`heph`) is a secondary/utility surface (export, scripting, admin) — explicitly *not* a focus, though it shares the same command surface. The **web UI** is the occasional hub-served surface. A later iOS/Watch client talks to the hub directly. +- **Surfaces (thin clients) — a three-surface model (revised 2026-06 after the §6.2.1 Todoist study; supersedes the earlier "nvim is *the* primary surface" framing).** Tasks and knowledge pull in different interaction directions, so each surface plays to its strength rather than one trying to do everything: + - **`heph.nvim` — the primary *context / knowledge-base* surface.** The full "org-mode"-style experience (markdown buffers backed by `doc` nodes; wiki-links, journaling, the canonical-context doc, per-task log, checklists). The explicit **successor to obsidian.nvim** (telescope picker, follow `[[wiki-links]]` on `<Enter>`, dailies, multi-state checkboxes); must reach that parity (§6.5). It surfaces tasks for **navigation/reading and context**, not as the primary place to *edit* structured task fields. + - **`heph` CLI — capture/scripting + the complete daemon API.** Every structured task field is a flag (`-a red --do tomorrow --late fri --recur weekly --project Maintenance`), which is exactly what command-line flags are good at and dissolves the "how do I edit N structured fields" problem. The CLI implements the *entire* API (admin, sync, conflicts, export) so it is also the scripting/automation surface. + - **`heph-tui` — the primary *task agenda / triage* surface (planned, [[tech-spec]] §8.1).** The §6.2.1 study shows the dominant task activity is *interactive triage of a large set* (daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns that, and **launches into nvim** for a task's context (and nvim launches back). Not yet built. + - 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. - **Core crate (Rust lib):** data model, query engine, markdown parsing + wiki-link extraction, and sync logic. Linked by both `hephd` and the hub server. @@ -249,6 +253,26 @@ The owner's old rule — "avoid ncurses and interactive UIs; write atomic code a > 🔒 **DECIDED ranking mechanics ([[tech-spec]] §7):** `do_date` is a *boolean candidacy filter only* (null ⇒ "now"), **never** an urgency input; **`late_on` is the sole urgency signal** (a global "now a problem" top tier); within a band, FIFO by `created_at` — **age never becomes urgency**. The order is expressed as a reorderable named-dimension list so it can be retuned without touching the engine. +### 6.2.1 Todoist study (2026-06): empirical confirmation + new requirements + +> 📊 **Snapshot of the owner's live Todoist** (read via the blumeops `blumeops-tasks` API pattern), to ground heph against how the discipline of §6.2 *actually* manifests at scale. 387 active tasks, 34 projects. + +**Confirms the model:** + +- **Projects = contexts, used heavily and hierarchically.** 34 projects organized into top-level life-areas (`Life`, `Work`, `Coding`, `Camano`, `Culture`) → sub-projects (`Child`, `Blumeops`, `Daily Routine`, `Maintenance`, `Movies`…). The *hierarchy* is new information (§6.2 listed projects flat). +- **Huge backlog, tiny hot set.** Priority split p1=2, p2=8, p4(default)=229, p3=148 — i.e. the genuinely "hot" set is ~10, the rest is white/blue backlog. The dominant *activity* is therefore **triage of a large set**, not editing single tasks — a direct argument for an interactive agenda surface (§4, [[tech-spec]] §8.1). +- **Do-date, not due-date — validated to the hilt.** **Zero** Todoist *deadlines* are used; only 107/387 carry a due (do) date. `late_on` will be genuinely rare. +- **Recurring checklists are real** ("Prep For Day", every day @ 07:00, 6 children) — exactly the §3.3 reset-each-occurrence mechanism. +- **Daily rituals exist as tasks**: "Excess Tasks to On Deck" (= the blue keep/drop review), "Coordinate with Allison", "Check Personal Email" (= orange reconfirm). The §6.2 working-set rituals are alive in the data; the TUI should make them first-class filters. + +**The strongest validation — descriptions are already heph.** Real task descriptions contain `see [[review-services]]`, `See [[run-1password-backup]]`, `docs/how-to/plans/migrate-forgejo-from-brew`, plus URLs and phone numbers. The owner is **hand-rolling task↔knowledge-base wiki-links inside Todoist descriptions that cannot resolve.** heph's task → canonical-context-`doc` fusion (§6.3) is precisely the thing being worked around. (89/387 tasks carry a description; the task description ≡ heph's canonical context doc body.) + +**New requirements surfaced:** + +- **Project hierarchy** — a project needs an optional parent project. (Model it with the existing `parent` link; deeper hierarchy-aware `scope` is a later refinement.) +- **Natural-language recurrence** — 95/107 dated tasks recur, expressed as `every 3 days`, `every 6 months`, `every workday`, `every April 15`, `every other wed`. heph stores RFC-5545 RRULE; capture should accept the common NL forms and compile them (the easy subset; time-of-day like "at 08:00" deferred — heph's `do_date` is date-grained for ranking). +- **Tags are noise** — 7 labels, **5 task-uses across 387**. Confidently **defer** a tag surface; it is not load-bearing. + ### 6.3 Two kinds of task: commitments vs. context items > 🔒 **DECIDED (shape).** A **commitment axis** orthogonal to the §6.2 attention-states. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 3d8f94a..8ff1ce9 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -16,8 +16,11 @@ Hephaestus (heph) is a self-hosted personal context-management system that unifi - **`heph-core`** — Rust library: data model, the `Store` abstraction + local SQLite store, query engine, markdown parsing/extraction, recurrence, and the sync engine (op-log, HLC, CRDT merge, conflict detection). - **`hephd`** — Rust daemon; one binary, three runtime modes (`local` / `server` / `client`, §3.1). Always serves a JSON-RPC API over a local unix socket to local surfaces; in `server` mode it additionally exposes an authenticated network endpoint and runs as the sync hub. -- **`heph`** — Rust CLI: utility/admin surface (export, scripting, smoke tests, `heph conflicts`). -- **`heph.nvim`** — Lua Neovim plugin: the primary user surface ("org-mode"-style); a thin client of the local `hephd`. +- **`heph`** — Rust CLI: **task capture/scripting + the complete daemon API** (every RPC method has a command), plus admin/export/`heph conflicts`. Structured task fields are flags (`-a red --do tomorrow --recur weekly`). +- **`heph.nvim`** — Lua Neovim plugin: the primary **context / knowledge-base** surface ("org-mode"-style — docs, wiki-links, journals, the canonical-context doc, checklists); a thin client of the local `hephd`. Surfaces tasks for navigation/context, not structured-field editing. +- **`heph-tui`** *(planned, §8.1)* — Rust terminal UI: the primary **task agenda / triage** surface (the dominant task activity per [[design]] §6.2.1); launches into `heph.nvim` for a task's context and back. + +> **Surface model (revised 2026-06, [[design]] §4 / §6.2.1).** Tasks and knowledge pull in different interaction directions, so v1 uses **three surfaces**, each to its strength: **CLI** = capture/scripting + complete API; **TUI** = interactive task agenda/triage; **nvim** = context/knowledge base. This supersedes the earlier "heph.nvim is *the* primary surface" framing. ## 2. Development approach @@ -228,21 +231,33 @@ This is the **Tactical** ranking only; Strategic/Organizational are other plugin `blue` (on-deck) is hidden from `next` by design; surfaced only by `list` (§6). `health()` exposes the working-set tensions (orange vs. 6, active vs. ~30, on-deck count) **with over-threshold deltas**, honestly — never masking overload nor manufacturing calm. -## 8. heph.nvim surface (v1) +## 8. heph.nvim surface (v1) — context / knowledge base -Replaces obsidian.nvim. Telescope-backed. **Tactical / Strategic / Organizational are named plugin-side views** composed from the mode-agnostic daemon primitives (§6) — the daemon never infers a mode. The session **goal stack** is plugin state (no persistent stack in v1; the durable re-seed is the per-task breadcrumb, `log.tail`). Core commands/gestures: +Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-base surface** ([[design]] §4): docs, wiki-links, journals, the canonical-context doc, checklists, per-task log. Task **agenda/triage** is the TUI's job (§8.1) and structured-field *editing* is the CLI's (flags); nvim surfaces tasks for **navigation and context**. **Tactical / Strategic / Organizational are named plugin-side views** composed from the mode-agnostic daemon primitives (§6) — the daemon never infers a mode. The session **goal stack** is plugin state (no persistent stack in v1; the durable re-seed is the per-task breadcrumb, `log.tail`). Core commands/gestures: -- Follow `[[wiki-link]]` under cursor on `<Enter>`. +- Follow `[[wiki-link]]` under cursor on `<Enter>` (follow-or-create). - Search / quick-switch / tags / backlinks / outgoing links (pickers). -- Daily journal picker (create/open dated `journal` nodes). -- Task capture; show "what is next" (`:Heph next`, Tactical); `list` views (Organizational); set attention; mark done/dropped. -- Open a task's canonical context doc; edit context-item checkboxes (Fork A) in the buffer (derived on `:w`). +- Daily journal picker (create/open dated `journal` nodes); home/index page. +- Show "what is next" (`:Heph next`, Tactical) and `list` views (Organizational) for **navigation** — `<CR>` jumps to a task's canonical context. *(Showing do/late in rows and a clean jump-to-context gesture is a small future polish item.)* +- Open a task's canonical context doc; edit context-item checkboxes (Fork A) in the buffer (derived on `:w`); context-item **promotion**. - Per-task log quick-append without leaving the current buffer. +- Lightweight task mutation (capture, attention, done/drop/skip) remains available, but the **primary** task-mutation surface is the CLI/TUI. The eventual richer nvim task-edit story is **frontmatter-as-edit-surface** (the task buffer presents scalars as editable YAML frontmatter parsed back on `:w` — `export` already serializes this), *not* bespoke form widgets — a later slice. -**Deferred (fast-follow, scaffolded):** guided working-set rituals — the Blue keep/drop review and Orange daily reconfirm — are pure compositions of `list` + `set_attention` + `set_state(dropped)`; v1 ships `health()` reporting and manual review. +**Deferred (fast-follow, scaffolded):** guided working-set rituals — the Blue keep/drop review and Orange daily reconfirm — are pure compositions of `list` + `set_attention` + `set_state(dropped)`; v1 ships `health()` reporting and manual review. (These become first-class **filters in the TUI**, §8.1.) **Known-hard:** reconciling an incoming CRDT body delta into a *dirty* buffer (unsaved local edits, cursor position) — the §9 "update arrives while a buffer is open" case — is genuinely fiddly under Fork A; expect to iterate. +## 8.1 heph-tui surface — task agenda / triage (planned) + +> **Status: planned, not yet built.** The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it. + +- **Crate `crates/heph-tui`** — `ratatui` + `crossterm`, a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. +- **Layout** — three panes: **projects/contexts** (the §6.2.1 hierarchy) · **task list** (`next`/`list` rows with attention + human do/late) · **preview** (canonical-context doc body / `log.tail`). +- **Gestures** — `j/k` move · `a` add · `x` done · `space` skip · `A` cycle attention · `e` reschedule (do/late) · `b` push-to-blue · saved filters for the **daily rituals** (Top of Mind, On-Deck review, per-project) — the [[design]] §6.2 "filters = saved views" made interactive. +- **TUI ↔ nvim handoff** — `o`/`<CR>` launches `$EDITOR` (nvim) on the task's canonical-context doc (`nvim` with a `+lua` call opening `heph://node/<ctx-id>`, or a temp `.md` round-tripped through `node.update`); a nvim command (e.g. `:Heph agenda`) shells back to the TUI. +- **Testing** — TDD against a real daemon; headless smoke via `ratatui`'s `TestBackend`. +- **Prereqs** (surface-agnostic, landing first): the CLI-complete task surface (human dates, `list`/state/`edit`, recurrence) and `task.set_schedule` (reschedule) — both in the current slice. + ## 9. Testing strategy (TDD, layered) All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts/build` to run `cargo test` and the nvim e2e suite; `prek` already runs in `build.yaml`). **The Forgejo runner image must provide `neovim` + `plenary.nvim`** for the headless e2e suite. @@ -357,14 +372,16 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi **Not yet done (resume order)** -> The Rust backend is feature-complete; `heph.nvim` slices 11a–11c are done — the v1 surface (knowledge base + task views + promotion) works end-to-end with CI. The remainder is the deferred reconcile slice plus non-blocking polish + an end-of-v1 sweep (§11). +> The Rust backend is feature-complete; `heph.nvim` slices 11a–11c + a UX iteration are done. **Surface strategy revised 2026-06 to a three-surface model** ([[design]] §4 / §6.2.1, grounded in a study of the owner's live Todoist): **CLI = capture/scripting + complete API**, **TUI = primary task agenda/triage** (to build), **nvim = context/KB**. Remaining work is reordered accordingly. -1. ⏳ **Task-scheduling UX (§7, §8) — the highest-value next gap:** the plugin can only set a task's **title + attention** today. The engine fully supports **do-date / late-on / recurrence / project**, but nothing in the editor sets them — so the ranking has no urgency signals to work with from nvim. Surface these (capture-with-fields and an edit affordance), and show do/late in the `next`/`list` rows. -2. ⏳ **More surfacing of existing engine features (§6, §8):** a **backlinks/links** picker (navigate the graph back), **tags**, a **`health`** working-set view (orange-vs-6, active-vs-~30), and **log read** (`log.tail`) — the resumption breadcrumb on return. -3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -4. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). -5. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -6. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **CLI-complete task surface (§1, §6, §7) — IN PROGRESS (this slice):** make `heph` implement the **entire daemon API** with ergonomic task management — human date parse+display (`tomorrow`/`+3d`/`fri`/ISO), recurrence presets + easy NL subset, `list`, `done`/`drop`/`skip`, `attention`, **`edit`** (new backend **`task.set_schedule`** — the missing *reschedule* capability), `promote`, `health`, `log`, project-by-name, links/backlinks, sync, conflicts. (Replaces the old "task-scheduling UX in nvim" item — structured-field entry belongs on the CLI/TUI, not nvim buffers; [[design]] §4.) +2. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; daily-ritual filters (orange reconfirm, blue review); launches into nvim for context and back. Planned in §8.1. +3. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). +4. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (hierarchy-aware `scope`, `project list` needing a list-by-kind RPC) is a refinement. +5. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +6. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). +7. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +8. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related -- 2.50.1 (Apple Git-155) From 70d5af5bdcc5a0bcf0cd55c20f6196edc1e6901d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 19:26:25 -0700 Subject: [PATCH 36/91] =?UTF-8?q?feat(core):=20task.set=5Fschedule=20?= =?UTF-8?q?=E2=80=94=20reschedule=20do-date/late-on/recurrence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to change a task's do-date, late-on, or recurrence after creation (only attention/state had setters) — a real reschedule gap. Add a single patch method covering the three schedule scalars with no setter. - model: SchedulePatch with double-option fields (absent=leave, null=clear, value=set), serde-skips absent fields so the distinction round-trips - Store::set_task_schedule + LocalStore/RemoteStore impls; sqlite set_schedule overlays present fields then records the LWW task.set op (sync-correct) - rpc dispatch: task.set_schedule (id + flattened patch) - tests: core set/clear/leave + missing-task; rpc_socket round-trip asserting the absent/null/value semantics over the wire Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/lib.rs | 2 +- crates/heph-core/src/model.rs | 50 ++++++++++++++++ crates/heph-core/src/sqlite/mod.rs | 9 ++- crates/heph-core/src/sqlite/tasks.rs | 33 ++++++++++- crates/heph-core/src/store.rs | 10 +++- crates/heph-core/tests/tasks_and_links.rs | 69 ++++++++++++++++++++++- crates/hephd/src/remote.rs | 11 +++- crates/hephd/src/rpc.rs | 9 ++- crates/hephd/tests/rpc_socket.rs | 30 ++++++++++ 9 files changed, 213 insertions(+), 10 deletions(-) diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 72bb5bc..acf7fe7 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -28,7 +28,7 @@ pub use extract::{extract, ContextItem, Extraction}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, - NodeKind, SyncCursors, Task, TaskState, + NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; pub use oplog::Op; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index f6d7a5e..423c96b 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -248,6 +248,56 @@ pub struct NewTask { pub project_id: Option<String>, } +/// A partial update to a task's schedule scalars (tech-spec §6 `task.set_schedule`). +/// +/// Each field is a **double option** so the three states are distinct: absent +/// (`None`) = leave unchanged; present-`null` (`Some(None)`) = clear; present +/// value (`Some(Some(v))`) = set. Attention has its own setter; project edits +/// are link add/remove — this struct covers the scalars with no setter today +/// (the "reschedule" gap). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SchedulePatch { + /// Earliest-actionable date, epoch ms (candidacy gate only, §7). + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "double_option" + )] + pub do_date: Option<Option<i64>>, + /// Lateness-problem marker, epoch ms (the sole urgency signal, §7). + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "double_option" + )] + pub late_on: Option<Option<i64>>, + /// RRULE for a recurring definition (§4.4); clear to make non-recurring. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "double_option" + )] + pub recurrence: Option<Option<String>>, +} + +impl SchedulePatch { + /// True when the patch touches nothing (every field absent). + pub fn is_empty(&self) -> bool { + self.do_date.is_none() && self.late_on.is_none() && self.recurrence.is_none() + } +} + +/// Deserialize a present field (even an explicit `null`) into `Some(...)`, so a +/// missing key (via `#[serde(default)]`) stays `None`. This is what makes the +/// [`SchedulePatch`] double-option distinguish "absent" from "null". +fn double_option<'de, T, D>(deserializer: D) -> std::result::Result<Option<Option<T>>, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + /// Working-set health — the §6.2 tensions, surfaced honestly (tech-spec §7). /// Never masks overload nor manufactures calm. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 9c88975..262ebb6 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -30,8 +30,8 @@ use crate::clock::Clock; use crate::error::{Error, Result}; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task, - TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -231,6 +231,11 @@ impl Store for LocalStore { tasks::set_attention(&self.conn, &self.owner_id, now, node_id, attention) } + fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task> { + let now = self.clock.now_ms(); + tasks::set_schedule(&self.conn, &self.owner_id, now, node_id, patch) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 351e0cb..e108c94 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -10,7 +10,9 @@ use serde_json::json; use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; use crate::extract; -use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; +use crate::model::{ + Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, +}; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; use crate::recurrence; @@ -496,3 +498,32 @@ pub(super) fn set_attention( record_set(conn, owner, now, node_id)?; require(conn, node_id) } + +/// Apply a partial schedule update (do-date / late-on / recurrence) — the +/// "reschedule" path (tech-spec §6). Reads the current row, overlays the +/// present `patch` fields (a double-option per field: absent = leave, `null` = +/// clear, value = set), writes all three columns, and records the LWW op. +pub(super) fn set_schedule( + conn: &Connection, + owner: &str, + now: i64, + node_id: &str, + patch: SchedulePatch, +) -> Result<Task> { + let mut task = require(conn, node_id)?; + if let Some(v) = patch.do_date { + task.do_date = v; + } + if let Some(v) = patch.late_on { + task.late_on = v; + } + if let Some(v) = patch.recurrence { + task.recurrence = v; + } + conn.execute( + "UPDATE tasks SET do_date = ?1, late_on = ?2, recurrence = ?3 WHERE node_id = ?4", + (&task.do_date, &task.late_on, &task.recurrence, node_id), + )?; + record_set(conn, owner, now, node_id)?; + require(conn, node_id) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 5113b61..ddbeb1a 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -6,8 +6,8 @@ use crate::error::Result; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task, - TaskState, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + SyncCursors, Task, TaskState, }; use crate::oplog::Op; use crate::ranking::RankedTask; @@ -71,6 +71,12 @@ pub trait Store { /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>; + /// Apply a partial update to a task's schedule scalars — do-date, late-on, + /// recurrence (tech-spec §6 `task.set_schedule`). Each [`SchedulePatch`] + /// field is a double option: absent = unchanged, `null` = clear, value = + /// set. This is the "reschedule" path (the scalars with no dedicated setter). + fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task>; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a /// committed task, rewriting that source line into a `[[link]]` to the new /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs index e80a2e4..c089311 100644 --- a/crates/heph-core/tests/tasks_and_links.rs +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -2,13 +2,80 @@ //! wiki-link materialization (tech-spec §4–§6, slice 3). use heph_core::{ - Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store, TaskState, + Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, SchedulePatch, Store, + TaskState, }; fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() } +#[test] +fn set_schedule_sets_clears_and_leaves_fields_per_double_option() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Renew passport".into(), + do_date: Some(1_000), + late_on: Some(2_000), + recurrence: Some("FREQ=YEARLY".into()), + ..Default::default() + }) + .unwrap(); + let id = task.node_id; + + // Set do_date, clear recurrence, leave late_on untouched (absent). + let updated = s + .set_task_schedule( + &id, + SchedulePatch { + do_date: Some(Some(5_000)), + recurrence: Some(None), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.do_date, Some(5_000), "do_date set"); + assert_eq!(updated.late_on, Some(2_000), "late_on left unchanged"); + assert_eq!(updated.recurrence, None, "recurrence cleared"); + + // An empty patch is a no-op (leaves everything). + let same = s.set_task_schedule(&id, SchedulePatch::default()).unwrap(); + assert_eq!(same.do_date, Some(5_000)); + assert_eq!(same.late_on, Some(2_000)); + + // Clearing do_date makes the task always-actionable again. + let cleared = s + .set_task_schedule( + &id, + SchedulePatch { + do_date: Some(None), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.do_date, None); + + // Adding a recurrence back makes it a recurring definition. + let recurring = s + .set_task_schedule( + &id, + SchedulePatch { + recurrence: Some(Some("FREQ=WEEKLY".into())), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(recurring.recurrence.as_deref(), Some("FREQ=WEEKLY")); +} + +#[test] +fn set_schedule_on_a_missing_task_is_not_found() { + let mut s = store(); + let err = s.set_task_schedule("nope", SchedulePatch::default()); + assert!(err.is_err(), "expected NodeNotFound, got {err:?}"); +} + #[test] fn promote_mints_a_task_and_rewrites_the_line_into_a_link() { let mut s = store(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 8445292..f4b8248 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -17,8 +17,8 @@ use serde::de::DeserializeOwned; use serde_json::{json, Value}; use heph_core::{ - Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, Store, - SyncCursors, Task, TaskState, + Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, + SchedulePatch, Store, SyncCursors, Task, TaskState, }; use crate::oauth::{self, TokenStore}; @@ -160,6 +160,13 @@ impl Store for RemoteStore { ) } + fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task> { + // Serialize the patch (absent fields are skipped), then inject `id`. + let mut params = serde_json::to_value(&patch).expect("SchedulePatch serializes"); + params["id"] = json!(node_id); + self.call_as("task.set_schedule", params) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index b4df8a8..7c2bcad 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, LinkType, NewNode, NewTask, Store, TaskState}; +use heph_core::{Attention, LinkType, NewNode, NewTask, SchedulePatch, Store, TaskState}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -261,6 +261,13 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: SetAttentionParams = parse(params)?; json!(store.set_task_attention(&p.id, p.attention)?) } + "task.set_schedule" => { + // `id` + the flattened `SchedulePatch` arrive in one object; parse + // the id, then the patch (which ignores the extra `id` key). + let id: IdParam = parse(params.clone())?; + let patch: SchedulePatch = parse(params)?; + json!(store.set_task_schedule(&id.id, patch)?) + } "task.skip" => { let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 7da9264..62cbb27 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -134,6 +134,36 @@ fn task_create_appears_in_next_with_context_link() { assert_eq!(doc["kind"], "doc"); } +#[test] +fn task_set_schedule_patches_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let task = c + .call( + "task.create", + json!({ "title": "Renew passport", "do_date": 1000, "late_on": 2000, "recurrence": "FREQ=YEARLY" }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + // Present value sets, explicit null clears, absent field is left alone. + let updated = c + .call( + "task.set_schedule", + json!({ "id": id, "do_date": 5000, "recurrence": null }), + ) + .unwrap(); + assert_eq!(updated["do_date"], 5000, "do_date set"); + assert_eq!(updated["late_on"], 2000, "late_on untouched (absent)"); + assert!(updated["recurrence"].is_null(), "recurrence cleared"); + + // The change is durable. + let got = c.call("task.get", json!({ "id": id })).unwrap(); + assert_eq!(got["do_date"], 5000); + assert!(got["recurrence"].is_null()); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); -- 2.50.1 (Apple Git-155) From 07e4d786b3a795fa869941adca0d332b9ec7ef98 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 19:36:50 -0700 Subject: [PATCH 37/91] =?UTF-8?q?feat(cli):=20complete=20task=20surface=20?= =?UTF-8?q?=E2=80=94=20human=20dates,=20recurrence,=20full=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make heph a real task driver and the complete daemon-API surface (the three-surface model's capture/scripting role). Structured fields are flags. - datespec: human date parsing (today/tomorrow/+3d/fri/ISO, injectable today for deterministic tests) + compact display; recurrence presets + the common Todoist-style natural-language forms ("every 3 days", "every fri", "every April 15") + raw RRULE passthrough. Table-driven unit tests. - main: new commands covering every RPC — list, done/drop/skip, attention, edit (reschedule via task.set_schedule), promote, show, log (append/tail), health, node update/rm, resolve, links/backlinks, link add, project add [--parent], sync [--status], conflicts [resolve]. task/next/list show human dates; projects referenced by name (resolved, errors if absent). - tests/cli.rs: real-socket process tests for the new verbs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- Cargo.lock | 1 + crates/heph/Cargo.toml | 1 + crates/heph/src/datespec.rs | 372 ++++++++++++++++ crates/heph/src/main.rs | 538 +++++++++++++++++++++-- crates/heph/tests/cli.rs | 139 ++++++ docs/changelog.d/v1-prototype.feature.md | 1 + 6 files changed, 1026 insertions(+), 26 deletions(-) create mode 100644 crates/heph/src/datespec.rs diff --git a/Cargo.lock b/Cargo.lock index c1f43a9..0f455f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,6 +1135,7 @@ name = "heph" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "clap", "heph-core", "hephd", diff --git a/crates/heph/Cargo.toml b/crates/heph/Cargo.toml index dabed91..97b8aaa 100644 --- a/crates/heph/Cargo.toml +++ b/crates/heph/Cargo.toml @@ -18,6 +18,7 @@ hephd = { path = "../hephd" } clap.workspace = true serde_json.workspace = true anyhow.workspace = true +chrono.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/heph/src/datespec.rs b/crates/heph/src/datespec.rs new file mode 100644 index 0000000..cb01493 --- /dev/null +++ b/crates/heph/src/datespec.rs @@ -0,0 +1,372 @@ +//! Human-friendly date and recurrence parsing for the CLI (tech-spec §1, §8). +//! +//! `heph-core` is clock-pure (no ambient wall-clock reads); the CLI is a client, +//! so it may read the local clock. Date parsing is split so the logic is +//! deterministically testable: the pure functions take `today` / a year, and the +//! thin wrappers supply `Local::now()`. +//! +//! Recurrence input mirrors how the owner thinks in Todoist (see [[design]] +//! §6.2.1): presets, the common natural-language forms, or a raw RRULE. + +use anyhow::{bail, Context, Result}; +use chrono::{Datelike, Days, Local, Months, NaiveDate, TimeZone, Weekday}; + +// --------------------------------------------------------------------------- +// Dates +// --------------------------------------------------------------------------- + +/// Parse a human date spec **relative to `today`** into a `NaiveDate`. Accepts: +/// `today`, `tomorrow`/`tom`, `yesterday`; `+Nd`/`+Nw`/`+Nm` (and bare `+N` = +/// days); weekday names (`mon`..`sun`, the soonest such day on/after today); +/// ISO `YYYY-MM-DD`. +pub fn parse_date(input: &str, today: NaiveDate) -> Result<NaiveDate> { + let s = input.trim().to_lowercase(); + if s.is_empty() { + bail!("empty date"); + } + match s.as_str() { + "today" | "now" => return Ok(today), + "tomorrow" | "tom" => return Ok(today + Days::new(1)), + "yesterday" => return Ok(today - Days::new(1)), + _ => {} + } + if let Some(wd) = parse_weekday(&s) { + return Ok(soonest_weekday(today, wd)); + } + if let Some(rest) = s.strip_prefix('+') { + return parse_offset(rest, today); + } + NaiveDate::parse_from_str(&s, "%Y-%m-%d").with_context(|| { + format!("unrecognized date: {input:?} (try today, tomorrow, +3d, fri, or YYYY-MM-DD)") + }) +} + +/// `parse_date` against the local `today`. +pub fn parse_date_today(input: &str) -> Result<NaiveDate> { + parse_date(input, Local::now().date_naive()) +} + +/// Parse a human date spec to epoch milliseconds at **local midnight** — the +/// form `do_date`/`late_on` are stored in (a date-grained candidacy gate, §7). +pub fn parse_date_ms(input: &str) -> Result<i64> { + Ok(to_epoch_ms(parse_date_today(input)?)) +} + +/// Local-midnight epoch ms for a date. Falls back gracefully across a DST gap. +pub fn to_epoch_ms(date: NaiveDate) -> i64 { + let midnight = date.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid"); + match Local.from_local_datetime(&midnight).earliest() { + Some(dt) => dt.timestamp_millis(), + None => midnight.and_utc().timestamp_millis(), + } +} + +/// Compact display of an epoch-ms date: `MM-DD` within the current year, +/// `YYYY-MM-DD` otherwise. +pub fn fmt_date(ms: i64) -> String { + let date = match Local.timestamp_millis_opt(ms).earliest() { + Some(dt) => dt.date_naive(), + None => return ms.to_string(), + }; + fmt_naive(date, Local::now().date_naive().year()) +} + +fn fmt_naive(date: NaiveDate, this_year: i32) -> String { + if date.year() == this_year { + format!("{:02}-{:02}", date.month(), date.day()) + } else { + date.format("%Y-%m-%d").to_string() + } +} + +fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> { + let rest = rest.trim(); + let (num, unit) = rest.split_at( + rest.find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()), + ); + let n: u64 = num + .parse() + .with_context(|| format!("not a relative date offset: +{rest}"))?; + match unit.trim() { + "" | "d" | "day" | "days" => Ok(today + Days::new(n)), + "w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)), + "m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)), + other => bail!("unknown offset unit {other:?} (use d, w, or m)"), + } +} + +/// Map a weekday name (full or common abbreviation) to a `Weekday`. Matches +/// only recognized weekday tokens — not arbitrary prefixes (so "months" is not +/// "mon"). +fn parse_weekday(s: &str) -> Option<Weekday> { + match s { + "mon" | "monday" => Some(Weekday::Mon), + "tue" | "tues" | "tuesday" => Some(Weekday::Tue), + "wed" | "weds" | "wednesday" => Some(Weekday::Wed), + "thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu), + "fri" | "friday" => Some(Weekday::Fri), + "sat" | "saturday" => Some(Weekday::Sat), + "sun" | "sunday" => Some(Weekday::Sun), + _ => None, + } +} + +/// The soonest date on/after `today` whose weekday is `wd`. +fn soonest_weekday(today: NaiveDate, wd: Weekday) -> NaiveDate { + let mut d = today; + for _ in 0..7 { + if d.weekday() == wd { + return d; + } + d = d + Days::new(1); + } + today // unreachable: a weekday occurs within 7 days +} + +// --------------------------------------------------------------------------- +// Recurrence +// --------------------------------------------------------------------------- + +/// Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE +/// (anything containing `FREQ=`), presets (`daily|weekly|monthly|yearly| +/// weekdays`), and the common natural-language forms the owner uses in Todoist +/// (§6.2.1): `every N (day|week|month|year)s`, `every <weekday>`, `every other +/// <weekday|unit>`, `every workday`, `every <Month> <day>`. A trailing +/// `at <time>` is ignored (heph's do-date is date-grained). +pub fn parse_recurrence(spec: &str) -> Result<String> { + let raw = spec.trim(); + if raw.to_uppercase().contains("FREQ=") { + return Ok(raw.to_string()); + } + + // Normalize: lowercase, drop a trailing "at <time>" clause. + let mut s = raw.to_lowercase(); + if let Some(at) = s.find(" at ") { + s.truncate(at); + } + let s = s.trim(); + + // Presets. + match s { + "daily" | "day" => return Ok("FREQ=DAILY".into()), + "weekly" | "week" => return Ok("FREQ=WEEKLY".into()), + "monthly" | "month" => return Ok("FREQ=MONTHLY".into()), + "yearly" | "annually" | "year" => return Ok("FREQ=YEARLY".into()), + "weekdays" | "workdays" | "workday" | "weekday" => { + return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into()) + } + _ => {} + } + + let body = s.strip_prefix("every ").unwrap_or(s).trim(); + + // "every other <...>" → INTERVAL=2. + if let Some(rest) = body.strip_prefix("other ") { + return interval_form(2, rest.trim()); + } + // "every workday/weekday". + if body == "workday" || body == "weekday" || body == "workdays" || body == "weekdays" { + return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into()); + } + // "every <weekday>". + if let Some(wd) = parse_weekday(body) { + return Ok(format!("FREQ=WEEKLY;BYDAY={}", byday(wd))); + } + // "every <Month> <day>". + if let Some((m, d)) = parse_month_day(body) { + return Ok(format!("FREQ=YEARLY;BYMONTH={m};BYMONTHDAY={d}")); + } + // "every <unit>" / "every N <unit>s". + let mut it = body.split_whitespace(); + let first = it.next().unwrap_or(""); + if let Ok(n) = first.parse::<u32>() { + return interval_form(n, it.next().unwrap_or("")); + } + interval_form(1, first) +} + +/// `FREQ=...[;INTERVAL=n]` for a unit word, or a weekday with an interval. +fn interval_form(n: u32, unit: &str) -> Result<String> { + if let Some(wd) = parse_weekday(unit) { + let mut r = format!("FREQ=WEEKLY;BYDAY={}", byday(wd)); + if n != 1 { + r = format!("FREQ=WEEKLY;INTERVAL={n};BYDAY={}", byday(wd)); + } + return Ok(r); + } + let freq = match unit { + "day" | "days" => "DAILY", + "week" | "weeks" => "WEEKLY", + "month" | "months" => "MONTHLY", + "year" | "years" => "YEARLY", + other => bail!( + "unrecognized recurrence {other:?} (try daily/weekly/monthly/yearly, \ + 'every 3 days', 'every fri', or a raw RRULE)" + ), + }; + Ok(if n == 1 { + format!("FREQ={freq}") + } else { + format!("FREQ={freq};INTERVAL={n}") + }) +} + +fn byday(wd: Weekday) -> &'static str { + match wd { + Weekday::Mon => "MO", + Weekday::Tue => "TU", + Weekday::Wed => "WE", + Weekday::Thu => "TH", + Weekday::Fri => "FR", + Weekday::Sat => "SA", + Weekday::Sun => "SU", + } +} + +/// Parse "april 15" / "apr 15" (and the reversed "15 april") → (month, day). +fn parse_month_day(s: &str) -> Option<(u32, u32)> { + let toks: Vec<&str> = s.split_whitespace().collect(); + if toks.len() != 2 { + return None; + } + let month = |t: &str| -> Option<u32> { + match &t[..t.len().min(3)] { + "jan" => Some(1), + "feb" => Some(2), + "mar" => Some(3), + "apr" => Some(4), + "may" => Some(5), + "jun" => Some(6), + "jul" => Some(7), + "aug" => Some(8), + "sep" => Some(9), + "oct" => Some(10), + "nov" => Some(11), + "dec" => Some(12), + _ => None, + } + }; + let day = |t: &str| { + t.trim_end_matches(|c: char| !c.is_ascii_digit()) + .parse::<u32>() + .ok() + }; + if let (Some(m), Some(d)) = (month(toks[0]), day(toks[1])) { + return Some((m, d)); + } + if let (Some(d), Some(m)) = (day(toks[0]), month(toks[1])) { + return Some((m, d)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + // 2026-06-02 is a Tuesday. + fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 6, 2).unwrap() + } + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + #[test] + fn parse_date_keywords_and_offsets() { + let t = today(); + assert_eq!(parse_date("today", t).unwrap(), d(2026, 6, 2)); + assert_eq!(parse_date("tomorrow", t).unwrap(), d(2026, 6, 3)); + assert_eq!(parse_date("yesterday", t).unwrap(), d(2026, 6, 1)); + assert_eq!(parse_date("+3d", t).unwrap(), d(2026, 6, 5)); + assert_eq!(parse_date("+2w", t).unwrap(), d(2026, 6, 16)); + assert_eq!(parse_date("+1m", t).unwrap(), d(2026, 7, 2)); + assert_eq!(parse_date("+5", t).unwrap(), d(2026, 6, 7)); + } + + #[test] + fn parse_date_weekdays_are_soonest_on_or_after_today() { + let t = today(); // Tuesday + assert_eq!( + parse_date("tue", t).unwrap(), + d(2026, 6, 2), + "today if it matches" + ); + assert_eq!(parse_date("fri", t).unwrap(), d(2026, 6, 5)); + assert_eq!( + parse_date("mon", t).unwrap(), + d(2026, 6, 8), + "wraps to next week" + ); + } + + #[test] + fn parse_date_iso_and_errors() { + assert_eq!(parse_date("2026-12-25", today()).unwrap(), d(2026, 12, 25)); + assert!(parse_date("someday", today()).is_err()); + assert!(parse_date("", today()).is_err()); + } + + #[test] + fn fmt_naive_elides_the_current_year() { + assert_eq!(fmt_naive(d(2026, 6, 5), 2026), "06-05"); + assert_eq!(fmt_naive(d(2027, 1, 9), 2026), "2027-01-09"); + } + + #[test] + fn recurrence_presets_and_raw() { + assert_eq!(parse_recurrence("daily").unwrap(), "FREQ=DAILY"); + assert_eq!(parse_recurrence("weekly").unwrap(), "FREQ=WEEKLY"); + assert_eq!(parse_recurrence("monthly").unwrap(), "FREQ=MONTHLY"); + assert_eq!(parse_recurrence("yearly").unwrap(), "FREQ=YEARLY"); + assert_eq!( + parse_recurrence("weekdays").unwrap(), + "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" + ); + // Raw RRULE passes through untouched. + assert_eq!( + parse_recurrence("FREQ=DAILY;INTERVAL=2").unwrap(), + "FREQ=DAILY;INTERVAL=2" + ); + } + + #[test] + fn recurrence_natural_language() { + assert_eq!(parse_recurrence("every day").unwrap(), "FREQ=DAILY"); + assert_eq!( + parse_recurrence("every 3 days").unwrap(), + "FREQ=DAILY;INTERVAL=3" + ); + assert_eq!( + parse_recurrence("every 2 weeks").unwrap(), + "FREQ=WEEKLY;INTERVAL=2" + ); + assert_eq!( + parse_recurrence("every 6 months").unwrap(), + "FREQ=MONTHLY;INTERVAL=6" + ); + assert_eq!( + parse_recurrence("every fri").unwrap(), + "FREQ=WEEKLY;BYDAY=FR" + ); + assert_eq!( + parse_recurrence("every other wed").unwrap(), + "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE" + ); + assert_eq!( + parse_recurrence("every other day").unwrap(), + "FREQ=DAILY;INTERVAL=2" + ); + assert_eq!( + parse_recurrence("every workday at 08:00").unwrap(), + "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" + ); + assert_eq!( + parse_recurrence("every April 15").unwrap(), + "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15" + ); + assert!(parse_recurrence("every blue moon").is_err()); + } +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 7ab71eb..7773347 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -1,16 +1,22 @@ //! `heph` — the CLI surface (tech-spec §1). A thin client of the local -//! `hephd`: it never touches SQLite, only the daemon socket. Secondary to -//! `heph.nvim`; for scripting, admin, smoke tests, and `export`. +//! `hephd`: it never touches SQLite, only the daemon socket. +//! +//! Per the three-surface model ([[design]] §4), the CLI is the **task +//! capture/scripting** surface and exposes the **complete daemon API** — every +//! RPC method has a command. Structured task fields are flags, with human dates +//! (`--do tomorrow`) and recurrence (`--recur weekly`) parsed in [`datespec`]. use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use heph_core::{Node, RankedTask, Task}; use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; +mod datespec; + #[derive(Parser, Debug)] #[command(name = "heph", version, about)] struct Cli { @@ -38,21 +44,113 @@ enum Command { /// The task title. title: String, /// Attention-state: white|orange|red|blue. - #[arg(long)] + #[arg(short = 'a', long)] attention: Option<String>, - /// Earliest-actionable date, epoch ms. + /// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD. #[arg(long)] - do_date: Option<i64>, - /// Lateness-problem marker, epoch ms. + do_date: Option<String>, + /// Late-on (the sole urgency marker): same date forms as --do-date. #[arg(long)] - late_on: Option<i64>, - /// Project node id to file it under. + late_on: Option<String>, + /// Project name to file it under (must already exist). #[arg(long)] project: Option<String>, - /// RFC-5545 RRULE for a recurring task. + /// Recurrence: a preset (daily|weekly|monthly|yearly|weekdays) or NL + /// ("every 3 days", "every fri"). #[arg(long)] - recurrence: Option<String>, + recur: Option<String>, + /// A raw RFC-5545 RRULE (overrides --recur). + #[arg(long)] + rrule: Option<String>, }, + /// Enumerate the outstanding set (the Organizational survey). + List { + /// Restrict to a project node id. + #[arg(long)] + scope: Option<String>, + /// Only this attention-state: white|orange|red|blue. + #[arg(short = 'a', long)] + attention: Option<String>, + /// Hide on-deck (blue) items. + #[arg(long)] + no_blue: bool, + }, + /// Mark a task done (recurring tasks roll forward). + Done { + /// Task node id. + id: String, + }, + /// Mark a task dropped. + Drop { + /// Task node id. + id: String, + }, + /// Skip a recurring task's current occurrence (advance without logging). + Skip { + /// Task node id. + id: String, + }, + /// Set a task's attention-state. + Attention { + /// Task node id. + id: String, + /// white|orange|red|blue. + attention: String, + }, + /// Reschedule a task: change do-date / late-on / recurrence (use `none` to + /// clear a field; omit to leave it unchanged). Also re-attentions / re-files. + Edit { + /// Task node id. + id: String, + /// Do-date or `none`. + #[arg(long)] + do_date: Option<String>, + /// Late-on or `none`. + #[arg(long)] + late_on: Option<String>, + /// Recurrence (preset/NL) or `none`. + #[arg(long)] + recur: Option<String>, + /// A raw RRULE or `none`. + #[arg(long)] + rrule: Option<String>, + /// Set attention: white|orange|red|blue. + #[arg(short = 'a', long)] + attention: Option<String>, + /// File under a project (by name; adds an in-project link). + #[arg(long)] + project: Option<String>, + }, + /// Promote a context-item line into a committed task. + Promote { + /// The container node id (the doc holding the `- [ ]` line). + container_id: String, + /// 1-based index of the context item to promote (document order). + item_ref: usize, + /// Attention for the new task: white|orange|red|blue. + #[arg(short = 'a', long)] + attention: Option<String>, + /// Project name to file the new task under. + #[arg(long)] + project: Option<String>, + }, + /// Show a node (and, if it is a task, its scalars). + Show { + /// Node id. + id: String, + }, + /// Append to a task's log (with text) or print its latest entries (without). + Log { + /// Task node id. + id: String, + /// Text to append; if omitted, the latest entries are printed. + text: Vec<String>, + /// How many entries to print (read mode). + #[arg(short = 'n', long, default_value_t = 10)] + n: usize, + }, + /// Working-set health — the §6.2 tensions (orange vs 6, active vs ~30, …). + Health, /// Create a document node. Doc { /// The document title. @@ -61,11 +159,21 @@ enum Command { #[arg(long)] body: Option<String>, }, + /// Node operations (update, tombstone). + Node { + #[command(subcommand)] + action: NodeAction, + }, /// Fetch a node by id and print it as JSON. Get { /// Node id. id: String, }, + /// Resolve a wiki-link title to a node (exact, alias-then-title). + Resolve { + /// The title or alias. + title: String, + }, /// Full-text search over titles and bodies. Search { /// FTS5 query. @@ -76,6 +184,37 @@ enum Command { /// The ISO date. date: String, }, + /// List a node's outgoing links. + Links { + /// Node id. + id: String, + }, + /// List a node's backlinks. + Backlinks { + /// Node id. + id: String, + }, + /// Link operations (add). + Link { + #[command(subcommand)] + action: LinkAction, + }, + /// Project operations (add). + Project { + #[command(subcommand)] + action: ProjectAction, + }, + /// Force a sync cycle (or show sync status with --status). + Sync { + /// Show status instead of syncing. + #[arg(long)] + status: bool, + }, + /// List merge conflicts, or resolve one. + Conflicts { + #[command(subcommand)] + action: Option<ConflictAction>, + }, /// Export the store to a directory tree of .md files. Export { /// Destination directory (created if needed). @@ -88,6 +227,62 @@ enum Command { }, } +#[derive(Subcommand, Debug)] +enum NodeAction { + /// Update a node's title and/or body. + Update { + /// Node id. + id: String, + /// New title. + #[arg(long)] + title: Option<String>, + /// New markdown body (or `-` to read the body from stdin). + #[arg(long)] + body: Option<String>, + }, + /// Tombstone (soft-delete) a node. + Rm { + /// Node id. + id: String, + }, +} + +#[derive(Subcommand, Debug)] +enum LinkAction { + /// Add a typed link between two nodes. + Add { + /// Source node id. + src: String, + /// Destination node id. + dst: String, + /// Link type: blocks|parent|tagged|in-project|context-of|… + link_type: String, + }, +} + +#[derive(Subcommand, Debug)] +enum ProjectAction { + /// Create a project node (optionally under a parent project). + Add { + /// Project name. + name: String, + /// Parent project name (creates a `parent` link). + #[arg(long)] + parent: Option<String>, + }, +} + +#[derive(Subcommand, Debug)] +enum ConflictAction { + /// Resolve a conflict by choosing the local or remote value. + Resolve { + /// Conflict id. + id: String, + /// `local` or `remote`. + choice: String, + }, +} + #[derive(Subcommand, Debug)] enum AuthAction { /// Log in via the device-code flow; caches the bearer token for hub sync. @@ -158,13 +353,7 @@ fn main() -> Result<()> { match cli.command { Command::Next { scope, limit } => { let result = client.call("next", json!({ "scope": scope, "limit": limit }))?; - let tasks: Vec<RankedTask> = serde_json::from_value(result)?; - if tasks.is_empty() { - println!("Nothing actionable right now."); - } - for t in &tasks { - println!("{}", format_row(t)); - } + print_rows(result)?; } Command::Task { title, @@ -172,22 +361,142 @@ fn main() -> Result<()> { do_date, late_on, project, - recurrence, + recur, + rrule, } => { + let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?; + let project_id = resolve_project(&mut client, project.as_deref())?; let result = client.call( "task.create", json!({ "title": title, "attention": attention, - "do_date": do_date, - "late_on": late_on, - "project_id": project, + "do_date": opt_date_ms(do_date.as_deref())?, + "late_on": opt_date_ms(late_on.as_deref())?, + "project_id": project_id, "recurrence": recurrence, }), )?; let task: Task = serde_json::from_value(result)?; println!("Created task {} \"{title}\"", task.node_id); } + Command::List { + scope, + attention, + no_blue, + } => { + let result = client.call( + "list", + json!({ "scope": scope, "attention": attention, "include_blue": !no_blue }), + )?; + print_rows(result)?; + } + Command::Done { id } => { + set_state(&mut client, &id, "done")?; + } + Command::Drop { id } => { + set_state(&mut client, &id, "dropped")?; + } + Command::Skip { id } => { + client.call("task.skip", json!({ "id": id }))?; + println!("Skipped occurrence of {id}"); + } + Command::Attention { id, attention } => { + client.call( + "task.set_attention", + json!({ "id": id, "attention": attention }), + )?; + println!("{id} attention → {attention}"); + } + Command::Edit { + id, + do_date, + late_on, + recur, + rrule, + attention, + project, + } => { + let mut patch = serde_json::Map::new(); + patch.insert("id".into(), json!(id)); + if let Some(v) = sched_date(do_date.as_deref())? { + patch.insert("do_date".into(), v); + } + if let Some(v) = sched_date(late_on.as_deref())? { + patch.insert("late_on".into(), v); + } + let recur_spec = recur.or(rrule); + if let Some(v) = sched_recurrence(recur_spec.as_deref())? { + patch.insert("recurrence".into(), v); + } + if patch.len() > 1 { + client.call("task.set_schedule", Value::Object(patch))?; + } + if let Some(a) = attention { + client.call("task.set_attention", json!({ "id": id, "attention": a }))?; + } + if let Some(pid) = resolve_project(&mut client, project.as_deref())? { + client.call( + "links.add", + json!({ "src": id, "dst": pid, "link_type": "in-project" }), + )?; + } + println!("Edited task {id}"); + } + Command::Promote { + container_id, + item_ref, + attention, + project, + } => { + let project_id = resolve_project(&mut client, project.as_deref())?; + let result = client.call( + "task.promote", + json!({ + "container_id": container_id, + "item_ref": item_ref, + "attention": attention, + "project": project_id, + }), + )?; + let task: Task = serde_json::from_value(result)?; + println!("Promoted item {item_ref} → task {}", task.node_id); + } + Command::Show { id } => { + let node = client.call("node.get", json!({ "id": id }))?; + println!("{}", serde_json::to_string_pretty(&node)?); + if node.get("kind").and_then(Value::as_str) == Some("task") { + let task = client.call("task.get", json!({ "id": id }))?; + println!("task: {}", serde_json::to_string_pretty(&task)?); + } + } + Command::Log { id, text, n } => { + if text.is_empty() { + let entries = client.call("log.tail", json!({ "task_id": id, "n": n }))?; + let entries: Vec<String> = serde_json::from_value(entries)?; + if entries.is_empty() { + println!("(no log entries)"); + } + for e in &entries { + println!("{e}"); + } + } else { + let line = text.join(" "); + client.call("log.append", json!({ "task_id": id, "text": line }))?; + println!("Logged to {id}"); + } + } + Command::Health => { + let h = client.call("health", json!({}))?; + println!( + "orange {} active {} on-deck {} conflicts {} sync {}", + num(&h, "orange_count"), + num(&h, "active_count"), + num(&h, "on_deck_count"), + num(&h, "conflict_count"), + h.get("sync_status").and_then(Value::as_str).unwrap_or("?"), + ); + } Command::Doc { title, body } => { let result = client.call( "node.create", @@ -196,10 +505,34 @@ fn main() -> Result<()> { let node: Node = serde_json::from_value(result)?; println!("Created doc {} \"{}\"", node.id, node.title); } + Command::Node { action } => match action { + NodeAction::Update { id, title, body } => { + let body = read_body_arg(body)?; + let result = client.call( + "node.update", + json!({ "id": id, "title": title, "body": body }), + )?; + let node: Node = serde_json::from_value(result)?; + println!("Updated {} \"{}\"", node.id, node.title); + } + NodeAction::Rm { id } => { + client.call("node.tombstone", json!({ "id": id }))?; + println!("Tombstoned {id}"); + } + }, Command::Get { id } => { let result = client.call("node.get", json!({ "id": id }))?; println!("{}", serde_json::to_string_pretty(&result)?); } + Command::Resolve { title } => { + let result = client.call("node.resolve", json!({ "title": title }))?; + if result.is_null() { + println!("(no match)"); + } else { + let node: Node = serde_json::from_value(result)?; + println!("{} [{}] {}", node.id, node.kind.as_str(), node.title); + } + } Command::Search { query } => { let result = client.call("search", json!({ "query": query }))?; let nodes: Vec<Node> = serde_json::from_value(result)?; @@ -215,6 +548,62 @@ fn main() -> Result<()> { let node: Node = serde_json::from_value(result)?; println!("Journal {} ({})", node.title, node.id); } + Command::Links { id } => { + print_links(client.call("links.outgoing", json!({ "id": id }))?); + } + Command::Backlinks { id } => { + print_links(client.call("links.backlinks", json!({ "id": id }))?); + } + Command::Link { action } => match action { + LinkAction::Add { + src, + dst, + link_type, + } => { + client.call( + "links.add", + json!({ "src": src, "dst": dst, "link_type": link_type }), + )?; + println!("Linked {src} -[{link_type}]-> {dst}"); + } + }, + Command::Project { action } => match action { + ProjectAction::Add { name, parent } => { + let result = + client.call("node.create", json!({ "kind": "project", "title": name }))?; + let node: Node = serde_json::from_value(result)?; + if let Some(parent) = parent { + let pid = resolve_project(&mut client, Some(&parent))? + .context("parent project not found")?; + client.call( + "links.add", + json!({ "src": node.id, "dst": pid, "link_type": "parent" }), + )?; + } + println!("Created project {} \"{}\"", node.id, node.title); + } + }, + Command::Sync { status } => { + let method = if status { "sync.status" } else { "sync.now" }; + let result = client.call(method, json!({}))?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + Command::Conflicts { action } => match action { + None => { + let result = client.call("conflicts.list", json!({}))?; + let arr = result.as_array().cloned().unwrap_or_default(); + if arr.is_empty() { + println!("No conflicts."); + } + for c in &arr { + println!("{}", serde_json::to_string(c)?); + } + } + Some(ConflictAction::Resolve { id, choice }) => { + client.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?; + println!("Resolved {id} → {choice}"); + } + }, Command::Export { dir } => { let path = dir .to_str() @@ -229,7 +618,104 @@ fn main() -> Result<()> { Ok(()) } -/// One concise Tactical row: attention tag, title, and do/late context. +/// Parse an optional human date into epoch-ms JSON (for `task.create`). +fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> { + spec.map(datespec::parse_date_ms).transpose() +} + +/// A `task.set_schedule` date field value: `Some(null)` to clear (`"none"`), +/// `Some(<ms>)` to set, `None` to omit (leave unchanged). +fn sched_date(spec: Option<&str>) -> Result<Option<Value>> { + match spec { + None => Ok(None), + Some("none") => Ok(Some(Value::Null)), + Some(s) => Ok(Some(json!(datespec::parse_date_ms(s)?))), + } +} + +/// A `task.set_schedule` recurrence value, same tri-state as [`sched_date`]. +fn sched_recurrence(spec: Option<&str>) -> Result<Option<Value>> { + match spec { + None => Ok(None), + Some("none") => Ok(Some(Value::Null)), + Some(s) => Ok(Some(json!(datespec::parse_recurrence(s)?))), + } +} + +/// Recurrence for `task.create`: a raw `--rrule` wins, else parse `--recur`. +fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result<Option<String>> { + if let Some(raw) = rrule { + return Ok(Some(raw.to_string())); + } + recur.map(datespec::parse_recurrence).transpose() +} + +/// Resolve a project **name** to its node id, erroring if it isn't a project. +fn resolve_project(client: &mut Client, name: Option<&str>) -> Result<Option<String>> { + let Some(name) = name else { return Ok(None) }; + let result = client.call("node.resolve", json!({ "title": name }))?; + if result.is_null() { + bail!("no project named {name:?} (create it with: heph project add {name:?})"); + } + let node: Node = serde_json::from_value(result)?; + if node.kind.as_str() != "project" { + bail!( + "{name:?} resolves to a {} node, not a project", + node.kind.as_str() + ); + } + Ok(Some(node.id)) +} + +/// `--body -` reads the body from stdin; otherwise pass it through. +fn read_body_arg(body: Option<String>) -> Result<Option<String>> { + match body.as_deref() { + Some("-") => { + use std::io::Read; + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s)?; + Ok(Some(s)) + } + _ => Ok(body), + } +} + +fn set_state(client: &mut Client, id: &str, state: &str) -> Result<()> { + client.call("task.set_state", json!({ "id": id, "state": state }))?; + println!("{id} → {state}"); + Ok(()) +} + +fn num(v: &Value, key: &str) -> u64 { + v.get(key).and_then(Value::as_u64).unwrap_or(0) +} + +/// Print a list of `RankedTask` rows (shared by `next` and `list`). +fn print_rows(result: Value) -> Result<()> { + let tasks: Vec<RankedTask> = serde_json::from_value(result)?; + if tasks.is_empty() { + println!("Nothing actionable right now."); + } + for t in &tasks { + println!("{}", format_row(t)); + } + Ok(()) +} + +fn print_links(result: Value) { + let arr = result.as_array().cloned().unwrap_or_default(); + if arr.is_empty() { + println!("(no links)"); + } + for l in &arr { + let ty = l.get("link_type").and_then(Value::as_str).unwrap_or("?"); + let src = l.get("src_id").and_then(Value::as_str).unwrap_or("?"); + let dst = l.get("dst_id").and_then(Value::as_str).unwrap_or("?"); + println!("{src} -[{ty}]-> {dst}"); + } +} + +/// One concise Tactical row: attention tag, title, and human do/late context. fn format_row(t: &RankedTask) -> String { let tag = t .attention @@ -237,15 +723,15 @@ fn format_row(t: &RankedTask) -> String { .unwrap_or_else(|| "[ ]".to_string()); let mut extra = Vec::new(); if let Some(d) = t.do_date { - extra.push(format!("do:{d}")); + extra.push(format!("do:{}", datespec::fmt_date(d))); } if let Some(l) = t.late_on { - extra.push(format!("late:{l}")); + extra.push(format!("late:{}", datespec::fmt_date(l))); } let suffix = if extra.is_empty() { String::new() } else { format!(" ({})", extra.join(", ")) }; - format!("{tag} {}{suffix}", t.title) + format!("{tag} {}{suffix} {}", t.title, t.node_id) } diff --git a/crates/heph/tests/cli.rs b/crates/heph/tests/cli.rs index 8a552b4..ceed77b 100644 --- a/crates/heph/tests/cli.rs +++ b/crates/heph/tests/cli.rs @@ -76,6 +76,145 @@ fn next_on_empty_store_is_friendly() { assert!(out.contains("Nothing actionable"), "{out}"); } +/// The first whitespace token of a "Created task <id> …" line. +fn created_id(out: &str) -> String { + out.split_whitespace() + .nth(2) + .expect("id in output") + .to_string() +} + +#[test] +fn task_with_iso_do_date_shows_formatted_date_in_next() { + let (socket, _dir) = spawn_daemon(); + // A do-date in the past (relative to the daemon's fixed 2024-01-01 clock) + // so it is actionable and appears in `next`. + let (out, ok) = heph( + &socket, + &[ + "task", + "Renew passport", + "-a", + "orange", + "--do-date", + "2023-12-25", + ], + ); + assert!(ok, "{out}"); + + let (out, ok) = heph(&socket, &["next"]); + assert!(ok); + assert!(out.contains("Renew passport"), "{out}"); + // Within 2023 vs the 2024 clock → full YYYY-MM-DD form. + assert!( + out.contains("do:2023-12-25"), + "expected formatted do-date: {out}" + ); +} + +#[test] +fn edit_reschedules_and_clears_fields() { + let (socket, _dir) = spawn_daemon(); + let (out, _) = heph( + &socket, + &[ + "task", + "Taxes", + "--do-date", + "2023-01-01", + "--recur", + "yearly", + ], + ); + let id = created_id(&out); + + // Reschedule do-date and clear recurrence. + let (out, ok) = heph( + &socket, + &["edit", &id, "--do-date", "2023-06-01", "--recur", "none"], + ); + assert!(ok, "{out}"); + + let (out, ok) = heph(&socket, &["show", &id]); + assert!(ok, "{out}"); + assert!( + out.contains("\"recurrence\": null"), + "recurrence cleared: {out}" + ); + assert!(out.contains("\"do_date\""), "{out}"); +} + +#[test] +fn list_and_done_and_health() { + let (socket, _dir) = spawn_daemon(); + let (out, _) = heph(&socket, &["task", "Mow lawn", "-a", "red"]); + let id = created_id(&out); + + let (out, ok) = heph(&socket, &["list"]); + assert!(ok, "{out}"); + assert!(out.contains("Mow lawn"), "{out}"); + + let (out, ok) = heph(&socket, &["health"]); + assert!(ok, "{out}"); + assert!(out.contains("active"), "{out}"); + + let (out, ok) = heph(&socket, &["done", &id]); + assert!(ok, "{out}"); + assert!(out.contains("done"), "{out}"); + + // Done tasks drop out of `next`. + let (out, ok) = heph(&socket, &["next"]); + assert!(ok); + assert!( + !out.contains("Mow lawn"), + "completed task should be gone: {out}" + ); +} + +#[test] +fn recur_preset_makes_a_recurring_task() { + let (socket, _dir) = spawn_daemon(); + let (out, _) = heph(&socket, &["task", "Standup", "--recur", "weekdays"]); + let id = created_id(&out); + let (out, ok) = heph(&socket, &["show", &id]); + assert!(ok, "{out}"); + assert!(out.contains("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"), "{out}"); +} + +#[test] +fn project_add_then_file_a_task_under_it() { + let (socket, _dir) = spawn_daemon(); + let (out, ok) = heph(&socket, &["project", "add", "Maintenance"]); + assert!(ok, "{out}"); + + // A task can be filed under the project by name. + let (out, ok) = heph( + &socket, + &["task", "Replace filter", "--project", "Maintenance"], + ); + assert!(ok, "{out}"); + + // An unknown project name is a clear error, not a silent miss. + let (out, ok) = heph(&socket, &["task", "Whatever", "--project", "Nonexistent"]); + assert!(!ok, "expected failure for unknown project: {out}"); +} + +#[test] +fn log_append_then_tail() { + let (socket, _dir) = spawn_daemon(); + let (out, _) = heph(&socket, &["task", "Investigate bug"]); + let id = created_id(&out); + + let (_, ok) = heph( + &socket, + &["log", &id, "looked", "at", "the", "stack", "trace"], + ); + assert!(ok); + let (out, ok) = heph(&socket, &["log", &id]); + assert!(ok, "{out}"); + assert!(out.contains("looked at the stack trace"), "{out}"); +} + #[test] fn export_writes_markdown_files() { let (socket, dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 7b5fce7..e1f144f 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -20,3 +20,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. - `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. -- 2.50.1 (Apple Git-155) From f122c9e6a4bc1612a3c746358bc5c90db62d124a Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 19:50:19 -0700 Subject: [PATCH 38/91] feat(cli): heph project list (+ node.list RPC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a list-by-kind primitive so projects (and later tags) can be enumerated. - core: Store::list_nodes(kind?) — owner-scoped, non-tombstoned, title-sorted; sqlite nodes::list; LocalStore/RemoteStore impls - rpc: node.list {kind?} dispatch - cli: `heph project list` - tests: core list_nodes (kind filter, case-insensitive sort, tombstone exclusion) + cli project_list (projects only, not tasks) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/mod.rs | 6 +++++- crates/heph-core/src/sqlite/nodes.rs | 17 ++++++++++++++++ crates/heph-core/src/store.rs | 7 ++++++- crates/heph-core/tests/search.rs | 30 ++++++++++++++++++++++++++++ crates/heph/src/main.rs | 12 +++++++++++ crates/heph/tests/cli.rs | 15 ++++++++++++++ crates/hephd/src/remote.rs | 6 +++++- crates/hephd/src/rpc.rs | 12 ++++++++++- 8 files changed, 101 insertions(+), 4 deletions(-) diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 262ebb6..45c8c9e 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -30,7 +30,7 @@ use crate::clock::Clock; use crate::error::{Error, Result}; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; use crate::oplog::Op; @@ -277,6 +277,10 @@ impl Store for LocalStore { nodes::search(&self.conn, &self.owner_id, query) } + fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>> { + nodes::list(&self.conn, &self.owner_id, kind) + } + fn journal_open_or_create(&mut self, date: &str) -> Result<Node> { let now = self.clock.now_ms(); nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index ba04907..c919e7d 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -332,6 +332,23 @@ pub(super) fn search(conn: &Connection, owner: &str, query: &str) -> Result<Vec< Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) } +/// List non-tombstoned, owner-scoped nodes, optionally filtered by `kind`, +/// ordered by title (case-insensitive). Used by surfaces to enumerate projects, +/// tags, etc. (tech-spec §6 `node.list`). +pub(super) fn list(conn: &Connection, owner: &str, kind: Option<NodeKind>) -> Result<Vec<Node>> { + let mut sql = format!("SELECT {COLUMNS} FROM nodes WHERE owner_id = ?1 AND tombstoned = 0"); + if kind.is_some() { + sql.push_str(" AND kind = ?2"); + } + sql.push_str(" ORDER BY title COLLATE NOCASE"); + let mut stmt = conn.prepare(&sql)?; + let rows = match kind { + Some(k) => stmt.query_map((owner, k.as_str()), from_row)?, + None => stmt.query_map([owner], from_row)?, + }; + Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) +} + /// A node's aliases (wiki-link names), sorted. Empty until aliases are written. pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> { let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index ddbeb1a..3a1d20c 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -6,7 +6,7 @@ use crate::error::Result; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; use crate::oplog::Op; @@ -40,6 +40,11 @@ pub trait Store { /// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3). fn tombstone_node(&mut self, id: &str) -> Result<()>; + /// List non-tombstoned nodes (owner-scoped), optionally filtered by `kind`, + /// ordered by title. The enumeration surfaces (projects, tags) build on this + /// (tech-spec §6 `node.list`). + fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>>; + /// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an /// alias match first, then an exact, owner-scoped, non-tombstoned title /// match; `None` if nothing matches (an unresolved link is allowed, §5). diff --git a/crates/heph-core/tests/search.rs b/crates/heph-core/tests/search.rs index dac586c..ab3f012 100644 --- a/crates/heph-core/tests/search.rs +++ b/crates/heph-core/tests/search.rs @@ -6,6 +6,36 @@ fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() } +#[test] +fn list_nodes_filters_by_kind_sorts_by_title_and_excludes_tombstoned() { + let mut s = store(); + let proj = |s: &mut LocalStore, t: &str| { + s.create_node(NewNode { + kind: NodeKind::Project, + title: t.into(), + body: None, + }) + .unwrap() + .id + }; + proj(&mut s, "Maintenance"); + proj(&mut s, "child"); // lowercase → tests case-insensitive sort + let gone = proj(&mut s, "Archived"); + s.create_node(NewNode::doc("Just a doc", "not a project")) + .unwrap(); + s.tombstone_node(&gone).unwrap(); + + // kind=project excludes the doc and the tombstoned project, title-sorted. + let projects = s.list_nodes(Some(NodeKind::Project)).unwrap(); + let titles: Vec<&str> = projects.iter().map(|n| n.title.as_str()).collect(); + assert_eq!(titles, vec!["child", "Maintenance"]); + + // No filter returns every non-tombstoned node (incl. the doc). + let all = s.list_nodes(None).unwrap(); + assert!(all.iter().any(|n| n.title == "Just a doc")); + assert!(!all.iter().any(|n| n.id == gone), "tombstoned excluded"); +} + #[test] fn search_matches_title_and_body() { let mut s = store(); diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 7773347..6288c72 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -270,6 +270,8 @@ enum ProjectAction { #[arg(long)] parent: Option<String>, }, + /// List all projects. + List, } #[derive(Subcommand, Debug)] @@ -582,6 +584,16 @@ fn main() -> Result<()> { } println!("Created project {} \"{}\"", node.id, node.title); } + ProjectAction::List => { + let result = client.call("node.list", json!({ "kind": "project" }))?; + let nodes: Vec<Node> = serde_json::from_value(result)?; + if nodes.is_empty() { + println!("No projects."); + } + for n in &nodes { + println!("{} {}", n.id, n.title); + } + } }, Command::Sync { status } => { let method = if status { "sync.status" } else { "sync.now" }; diff --git a/crates/heph/tests/cli.rs b/crates/heph/tests/cli.rs index ceed77b..67ea3b0 100644 --- a/crates/heph/tests/cli.rs +++ b/crates/heph/tests/cli.rs @@ -199,6 +199,21 @@ fn project_add_then_file_a_task_under_it() { assert!(!ok, "expected failure for unknown project: {out}"); } +#[test] +fn project_list_shows_projects_only() { + let (socket, _dir) = spawn_daemon(); + heph(&socket, &["project", "add", "Maintenance"]); + heph(&socket, &["project", "add", "Coding"]); + // A task (and its context doc) must not show up in the project list. + heph(&socket, &["task", "Some task"]); + + let (out, ok) = heph(&socket, &["project", "list"]); + assert!(ok, "{out}"); + assert!(out.contains("Maintenance"), "{out}"); + assert!(out.contains("Coding"), "{out}"); + assert!(!out.contains("Some task"), "tasks are not projects: {out}"); +} + #[test] fn log_append_then_tail() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index f4b8248..550faba 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -17,7 +17,7 @@ use serde::de::DeserializeOwned; use serde_json::{json, Value}; use heph_core::{ - Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, + Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Result, SchedulePatch, Store, SyncCursors, Task, TaskState, }; @@ -209,6 +209,10 @@ impl Store for RemoteStore { self.call_as("search", json!({ "query": query })) } + fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>> { + self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) })) + } + fn journal_open_or_create(&mut self, date: &str) -> Result<Node> { self.call_as("journal.open_or_create", json!({ "date": date })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 7c2bcad..cbd8320 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, LinkType, NewNode, NewTask, SchedulePatch, Store, TaskState}; +use heph_core::{Attention, LinkType, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -114,6 +114,12 @@ struct ResolveParams { title: String, } +#[derive(Deserialize)] +struct NodeListParams { + #[serde(default)] + kind: Option<NodeKind>, +} + #[derive(Deserialize)] struct UpdateParams { id: String, @@ -245,6 +251,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: ResolveParams = parse(params)?; json!(store.resolve_node(&p.title)?) } + "node.list" => { + let p: NodeListParams = parse(params)?; + json!(store.list_nodes(p.kind)?) + } "task.create" => { let p: NewTask = parse(params)?; json!(store.create_task(p)?) -- 2.50.1 (Apple Git-155) From 2d4e4ae4d7ef3a08fee17a628c5710b49eab89bf Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 20:02:22 -0700 Subject: [PATCH 39/91] =?UTF-8?q?feat(cli):=20parse=20"every=20Nth"=20recu?= =?UTF-8?q?rrence=20=E2=86=92=20monthly=20by=20day-of-month?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todoist uses "every 5th" for monthly-on-the-5th; map it to FREQ=MONTHLY;BYMONTHDAY=N (1..=31). Surfaced by the Todoist import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph/src/datespec.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/heph/src/datespec.rs b/crates/heph/src/datespec.rs index cb01493..d37ed21 100644 --- a/crates/heph/src/datespec.rs +++ b/crates/heph/src/datespec.rs @@ -177,6 +177,10 @@ pub fn parse_recurrence(spec: &str) -> Result<String> { if let Some((m, d)) = parse_month_day(body) { return Ok(format!("FREQ=YEARLY;BYMONTH={m};BYMONTHDAY={d}")); } + // "every <Nth>" → monthly on that day of the month ("every 5th"). + if let Some(d) = parse_monthday_ordinal(body) { + return Ok(format!("FREQ=MONTHLY;BYMONTHDAY={d}")); + } // "every <unit>" / "every N <unit>s". let mut it = body.split_whitespace(); let first = it.next().unwrap_or(""); @@ -224,6 +228,19 @@ fn byday(wd: Weekday) -> &'static str { } } +/// Parse a day-of-month ordinal like "5th", "1st", "22nd", "3rd" → 1..=31. +fn parse_monthday_ordinal(s: &str) -> Option<u32> { + let digits = s.trim_end_matches(|c: char| c.is_ascii_alphabetic()); + let suffix = &s[digits.len()..]; + if !matches!(suffix, "st" | "nd" | "rd" | "th") { + return None; + } + match digits.parse::<u32>() { + Ok(d @ 1..=31) => Some(d), + _ => None, + } +} + /// Parse "april 15" / "apr 15" (and the reversed "15 april") → (month, day). fn parse_month_day(s: &str) -> Option<(u32, u32)> { let toks: Vec<&str> = s.split_whitespace().collect(); @@ -367,6 +384,14 @@ mod tests { parse_recurrence("every April 15").unwrap(), "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15" ); + assert_eq!( + parse_recurrence("every 5th").unwrap(), + "FREQ=MONTHLY;BYMONTHDAY=5" + ); + assert_eq!( + parse_recurrence("every 22nd").unwrap(), + "FREQ=MONTHLY;BYMONTHDAY=22" + ); assert!(parse_recurrence("every blue moon").is_err()); } } -- 2.50.1 (Apple Git-155) From 0b32ed43979b0e3e6560ea90af7ace89062f3967 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 20:15:05 -0700 Subject: [PATCH 40/91] =?UTF-8?q?feat(tooling):=20mise=20run=20import-todo?= =?UTF-8?q?ist=20=E2=80=94=20seed=20a=20heph=20store=20from=20Todoist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the one-off Todoist importer into a documented, repeatable mise task. Self-contained (spawns its own hephd), dry-run by default into a throwaway store, `-- --commit` writes into the real store after backing it up. Auth via TODOIST_TOKEN or TODOIST_OP_REF (op://). Mapping per design §6.2.1: project hierarchy (+ Inbox→unfiled), priority→attention by meaning, due→do-date, NL recurrence, descriptions + sub-tasks→canonical-context doc. - mise-tasks/import-todoist - docs/how-to/import-todoist.md (+ how-to index, reference mise-tasks table) - changelog fragment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.infra.md | 1 + docs/how-to/how-to.md | 1 + docs/how-to/import-todoist.md | 81 +++++++ docs/reference/reference.md | 1 + mise-tasks/import-todoist | 310 +++++++++++++++++++++++++ 5 files changed, 394 insertions(+) create mode 100644 docs/changelog.d/v1-prototype.infra.md create mode 100644 docs/how-to/import-todoist.md create mode 100755 mise-tasks/import-todoist diff --git a/docs/changelog.d/v1-prototype.infra.md b/docs/changelog.d/v1-prototype.infra.md new file mode 100644 index 0000000..7392070 --- /dev/null +++ b/docs/changelog.d/v1-prototype.infra.md @@ -0,0 +1 @@ +- `mise run import-todoist` — a one-way importer that seeds a heph store from your Todoist projects + active tasks (project hierarchy, priority→attention, do-dates, natural-language recurrence, descriptions + sub-tasks as context items). Dry-run by default; `-- --commit` writes into your real store after backing it up. See [[import-todoist]]. diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index 1d6c18c..49715b9 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -17,3 +17,4 @@ 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 +- [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`) diff --git a/docs/how-to/import-todoist.md b/docs/how-to/import-todoist.md new file mode 100644 index 0000000..0f5c971 --- /dev/null +++ b/docs/how-to/import-todoist.md @@ -0,0 +1,81 @@ +--- +title: Import tasks from Todoist +modified: 2026-06-02 +tags: + - how-to +--- + +# Import tasks from Todoist + +A one-way seeding tool that mirrors your Todoist projects + active tasks into a +heph store via the `heph` CLI. It does **not** sync back — run it once to +bootstrap, then live in heph. Implemented as `mise run import-todoist` +(`mise-tasks/import-todoist`). + +## Authentication + +Set one of: + +- `TODOIST_TOKEN` — your Todoist API token directly; or +- `TODOIST_OP_REF` — a 1Password `op://…` reference read via the `op` CLI. + +```bash +export TODOIST_OP_REF="op://<vault>/<item>/credential" +``` + +(Put it in your shell profile so it's always available.) + +## Dry-run first (the default) + +By default the tool imports into a **throwaway** store and prints a summary, so +you can see exactly what would happen without touching your real data: + +```bash +mise run import-todoist +``` + +``` +DRY RUN: importing into a throwaway store (…); nothing real is touched. + +=== IMPORT SUMMARY === +projects created : 33/33 +tasks created : 317/317 top-level +sub-tasks : 71 attached as context items +descriptions : 93 context docs written +recurrences : 95 applied, 0 fell back +``` + +Anything the recurrence parser can't handle is listed (imported without +recurrence) so you can fix those by hand later with `heph edit <id> --recur …`. + +## Commit to your real store + +```bash +mise run import-todoist -- --commit +``` + +This backs up your DB to `heph.db.bak-<timestamp>` first, then imports into your +real store (default `~/.local/share/heph/heph.db`, or `$HEPH_DB`). **Close any +open Neovim / stop `hephd` first** — the daemon holds an exclusive lock on the +DB, so the import can't open it while another daemon is running. (Alternatively, +target a running daemon directly with `-- --commit --socket <path>`.) + +To undo: `cp heph.db.bak-<timestamp> heph.db`. + +## How Todoist maps to heph + +The full rationale is in [[design]] §6.2.1. In short: + +| Todoist | heph | +|---|---| +| project (hierarchical) | `project add --parent`; **Inbox → unfiled** | +| priority p1/p2/p3/p4 | attention **red / orange / blue / white** (by the meaning of the p3=backlog, p4=default convention) | +| `due.date` | `--do-date` (a candidacy gate, not a deadline) | +| recurring `due.string` | `--recur` (natural-language parser) | +| description + sub-tasks | the task's canonical-context doc (sub-tasks as `- [ ]` context items, Fork A) | +| labels / sections | skipped (labels are negligible in practice) | + +## Related + +- [[install-heph]] — install `heph`/`hephd` and the plugin +- [[design]] — §6.2.1 the Todoist study behind the mapping diff --git a/docs/reference/reference.md b/docs/reference/reference.md index bbbd30e..2e88d96 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -57,6 +57,7 @@ Technical reference material for the repository tooling that ships with this pro | `mise run docs-check-links` | Validate wiki-links against existing doc filenames | | `mise run docs-mikado` | Inspect active Mikado chains and resume C2 work | | `mise run docs-preview <tarball>` | Extract and serve a released docs tarball locally | +| `mise run import-todoist` | Seed a heph store from Todoist (dry-run by default; `-- --commit` to write) — see [[import-todoist]] | | `mise run mikado-branch-invariant-check` | Validate `mikado/*` branch commit discipline | | `mise run pr-comments <pr_number>` | List unresolved PR comments | | `mise run runner-logs [run_number]` | List Forgejo Actions runs or fetch logs for a job | diff --git a/mise-tasks/import-todoist b/mise-tasks/import-todoist new file mode 100755 index 0000000..4f93721 --- /dev/null +++ b/mise-tasks/import-todoist @@ -0,0 +1,310 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// +#MISE description="Import Todoist projects + active tasks into heph (dry-run by default; --commit to write)" +#USAGE flag "--commit" help="Write into your real heph store (default: a throwaway dry-run)" +#USAGE flag "--socket <socket>" help="Import via an already-running daemon instead of spawning one" +"""Import Todoist projects + active tasks into a heph store via the `heph` CLI. + +A one-way seeding tool (Todoist -> heph); it does not sync back. By default it +runs a **dry-run** into a throwaway store and prints a summary, so you can see +exactly what would happen. Pass `--commit` to write into your real store (it +backs the DB up first). + +Mapping (see docs/explanation/design.md §6.2.1): + - project hierarchy -> `heph project add --parent`; Todoist Inbox -> unfiled + - priority -> attention by MEANING of the owner's convention: + p1 -> red, p2 -> orange, p4 (default) -> white, p3 (backlog) -> blue + - due.date -> --do-date; recurring due.string -> --recur (NL parser; a form + it can't parse imports without recurrence and is reported) + - description + sub-tasks -> the task's canonical-context doc + (sub-tasks become `- [ ]` context items, Fork A) + - labels/sections -> skipped (labels are negligible in practice) + +Auth: set TODOIST_TOKEN, or TODOIST_OP_REF to a 1Password `op://` reference +(read via the `op` CLI). Requires installed `heph`/`hephd` on PATH. + +Usage: + mise run import-todoist # dry-run, prints a summary + mise run import-todoist -- --commit # write into your real store (backs up) +""" + +import argparse +import collections +import contextlib +import datetime +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.parse +import urllib.request +from pathlib import Path + +BASE = "https://api.todoist.com/api/v1" +# Priority (API: 4=p1 urgent, 3=p2, 2=p3 backlog, 1=p4 default) -> attention, +# preserving the owner's convention (p3 is backlog, p4 is the normal default). +ATT = {4: "red", 3: "orange", 1: "white", 2: "blue"} + + +def fail(msg): + print(f"error: {msg}", file=sys.stderr) + sys.exit(1) + + +def get_token(): + t = os.environ.get("TODOIST_TOKEN", "").strip() + if t: + return t + ref = os.environ.get("TODOIST_OP_REF", "").strip() + if ref: + r = subprocess.run(["op", "read", ref], capture_output=True, text=True) + if r.returncode != 0: + fail(f"`op read {ref}` failed: {r.stderr.strip()}") + return r.stdout.strip() + fail("set TODOIST_TOKEN, or TODOIST_OP_REF to a 1Password op:// reference") + + +def default_db(): + if os.environ.get("HEPH_DB"): + return Path(os.environ["HEPH_DB"]) + base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return Path(base) / "heph" / "heph.db" + + +def api(tok, path, **params): + items, cursor = [], None + while True: + p = dict(params) + if cursor: + p["cursor"] = cursor + url = f"{BASE}/{path}" + ("?" + urllib.parse.urlencode(p) if p else "") + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {tok}"}) + data = json.load(urllib.request.urlopen(req, timeout=30)) + if isinstance(data, list): + items += data + break + items += data.get("results", []) + cursor = data.get("next_cursor") + if not cursor: + break + return items + + +@contextlib.contextmanager +def spawn_daemon(db, socket): + if shutil.which("hephd") is None: + fail("`hephd` not found on PATH (install it: see docs/how-to/install-heph.md)") + proc = subprocess.Popen( + ["hephd", "--mode", "local", "--db", str(db), "--socket", str(socket)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + for _ in range(200): + if Path(socket).exists(): + break + if proc.poll() is not None: + out = proc.stdout.read() if proc.stdout else "" + fail( + f"hephd failed to start on {db} — is another daemon (or an open " + f"Neovim) using it? Close it and retry, or pass --socket.\n{out}" + ) + time.sleep(0.05) + else: + fail("hephd did not create its socket in time") + yield + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +def heph(socket, *args, stdin=None): + r = subprocess.run( + ["heph", "--socket", str(socket), *args], + capture_output=True, + text=True, + input=stdin, + ) + return r.stdout, r.returncode == 0, r.stderr.strip() + + +def is_inbox(p): + return p.get("is_inbox_project") or p.get("name", "").lower() == "inbox" + + +def proj_depth(p, byid): + d, cur = 0, p + while cur.get("parent_id") and byid.get(cur["parent_id"]): + d += 1 + cur = byid[cur["parent_id"]] + return d + + +def checklist(parent_id, children, out, level=0): + for c in sorted(children.get(parent_id, []), key=lambda t: t.get("child_order", 0)): + out.append(" " * level + f"- [ ] {c['content']}") + checklist(c["id"], children, out, level + 1) + + +def run_import(socket, tok): + projects = api(tok, "projects") + byid = {p["id"]: p for p in projects} + inbox_ids = {p["id"] for p in projects if is_inbox(p)} + pname = {p["id"]: p["name"] for p in projects if p["id"] not in inbox_ids} + + proj_made = 0 + for p in sorted(projects, key=lambda p: (proj_depth(p, byid), p["name"])): + if is_inbox(p): + continue + args = ["project", "add", p["name"]] + par = byid.get(p.get("parent_id")) + if par and not is_inbox(par): + args += ["--parent", par["name"]] + _, ok, err = heph(socket, *args) + if ok: + proj_made += 1 + else: + print(f" ! project {p['name']!r}: {err}") + + tasks = api(tok, "tasks") + children = collections.defaultdict(list) + for t in tasks: + if t.get("parent_id"): + children[t["parent_id"]].append(t) + toplevel = [t for t in tasks if not t.get("parent_id")] + + made = subtasks = described = recurred = 0 + recur_fallback, errors = [], [] + + for t in toplevel: + args = ["task", t["content"], "-a", ATT.get(t["priority"], "white")] + if pname.get(t["project_id"]): + args += ["--project", pname[t["project_id"]]] + due = t.get("due") or {} + if due.get("date"): + args += ["--do-date", due["date"][:10]] + rec = due["string"] if due.get("is_recurring") and due.get("string") else None + out, ok, err = heph(socket, *(args + (["--recur", rec] if rec else []))) + if not ok and rec: # retry without the unparseable recurrence + recur_fallback.append((t["content"], rec)) + out, ok, err = heph(socket, *args) + elif ok and rec: + recurred += 1 + if not ok: + errors.append((t["content"], err)) + continue + made += 1 + node_id = out.split()[2] + + body = [] + if t.get("description"): + body.append(t["description"]) + kids = [] + checklist(t["id"], children, kids) + if kids: + body.append("\n".join(kids)) + subtasks += len(kids) + if body: + links, _, _ = heph(socket, "links", node_id) + ctx = next( + ( + ln.split("-> ")[-1].strip() + for ln in links.splitlines() + if "[canonical-context]" in ln + ), + None, + ) + if ctx: + _, uok, uerr = heph( + socket, "node", "update", ctx, "--body", "-", stdin="\n\n".join(body) + ) + if uok: + described += 1 + else: + errors.append((t["content"] + " (desc)", uerr)) + + return { + "projects": (proj_made, len(projects) - len(inbox_ids)), + "tasks": (made, len(toplevel)), + "subtasks": subtasks, + "described": described, + "recurred": recurred, + "recur_fallback": recur_fallback, + "errors": errors, + } + + +def report(s): + print("\n=== IMPORT SUMMARY ===") + print(f"projects created : {s['projects'][0]}/{s['projects'][1]}") + print(f"tasks created : {s['tasks'][0]}/{s['tasks'][1]} top-level") + print(f"sub-tasks : {s['subtasks']} attached as context items") + print(f"descriptions : {s['described']} context docs written") + print( + f"recurrences : {s['recurred']} applied, " + f"{len(s['recur_fallback'])} fell back" + ) + if s["recur_fallback"]: + print("\n-- recurrence strings heph couldn't parse (imported without recurrence):") + for c, r in s["recur_fallback"]: + print(f" {r!r:30} — {c[:50]}") + if s["errors"]: + print(f"\n-- {len(s['errors'])} errors:") + for c, e in s["errors"][:20]: + print(f" {c[:50]}: {e}") + + +def main(): + ap = argparse.ArgumentParser(add_help=False) + ap.add_argument("--commit", action="store_true") + ap.add_argument("--socket", default=None) + args = ap.parse_args() + + if shutil.which("heph") is None: + fail("`heph` not found on PATH (install it: see docs/how-to/install-heph.md)") + tok = get_token() + + if args.socket: + target = "an existing daemon" if args.commit else "an existing daemon (dry-run flag ignored)" + print(f"Importing into {target} at {args.socket} …") + report(run_import(args.socket, tok)) + return + + if args.commit: + db = default_db() + if not db.parent.exists(): + db.parent.mkdir(parents=True, exist_ok=True) + if db.exists(): + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + bak = db.with_name(db.name + f".bak-{stamp}") + shutil.copy2(db, bak) + print(f"backed up {db} → {bak}") + with tempfile.TemporaryDirectory() as tmp: + sock = Path(tmp) / "import.sock" + print(f"COMMIT: importing into your real store {db} …") + with spawn_daemon(db, sock): + report(run_import(sock, tok)) + return + + # Default: a throwaway dry-run. + with tempfile.TemporaryDirectory() as tmp: + db = Path(tmp) / "heph.db" + sock = Path(tmp) / "d.sock" + print(f"DRY RUN: importing into a throwaway store ({db}); nothing real is touched.") + print(" re-run with `-- --commit` to write into your real store.") + with spawn_daemon(db, sock): + report(run_import(sock, tok)) + + +if __name__ == "__main__": + main() -- 2.50.1 (Apple Git-155) From 1315b9ce187a1e7752c37a64f486b224b6ccd287 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 21:07:11 -0700 Subject: [PATCH 41/91] docs: daemon lifecycle becomes an explicit service (connect-only surfaces) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- docs/changelog.d/v1-prototype.feature.md | 1 + docs/explanation/design.md | 1 + docs/how-to/how-to.md | 1 + docs/how-to/install-heph.md | 17 ++++-- docs/how-to/run-the-daemon.md | 66 ++++++++++++++++++++++++ docs/reference/heph-nvim.md | 26 ++++++---- docs/reference/tech-spec.md | 2 +- 7 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 docs/how-to/run-the-daemon.md diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index e1f144f..aca928b 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -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). diff --git a/docs/explanation/design.md b/docs/explanation/design.md index 54d30a3..5fb93ef 100644 --- a/docs/explanation/design.md +++ b/docs/explanation/design.md @@ -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. diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index 49715b9..265cd37 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -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`) diff --git a/docs/how-to/install-heph.md b/docs/how-to/install-heph.md index 7fcdf68..0c034b6 100644 --- a/docs/how-to/install-heph.md +++ b/docs/how-to/install-heph.md @@ -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 diff --git a/docs/how-to/run-the-daemon.md b/docs/how-to/run-the-daemon.md new file mode 100644 index 0000000..8ada221 --- /dev/null +++ b/docs/how-to/run-the-daemon.md @@ -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 diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 7987101..db4b19b 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -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 diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 8ff1ce9..2ebf50d 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -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. -- 2.50.1 (Apple Git-155) From 0cfe627055cb4af12ee4752e4666c9fe11b917aa Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 21:14:50 -0700 Subject: [PATCH 42/91] =?UTF-8?q?feat(cli):=20heph=20daemon=20=E2=80=94=20?= =?UTF-8?q?manage=20hephd=20as=20a=20launchd/systemd=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces are connect-only; the daemon now runs as an explicit OS service so it can be shared without any surface owning its lifecycle. - service.rs: heph daemon start/stop/restart/status/uninstall, idempotent; launchd LaunchAgent (macOS) / systemd user service (Linux); resolves hephd next to heph else on PATH; pure plist/unit render fns unit-tested - main.rs: Command::Daemon handled before connecting (like auth) - hephd: default socket is now a STABLE <data-dir>/heph/hephd.sock when XDG_RUNTIME_DIR is unset (was $TMPDIR — fragile for a persistent service; macOS prunes /var/folders and the path varied per session) - tech-spec §14: CLI + daemon-service done entries Verified live on macOS: start/restart/stop/uninstall + CLI reaches the store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph/src/main.rs | 11 + crates/heph/src/service.rs | 414 ++++++++++++++++++++++++++++++++++++ crates/hephd/src/lib.rs | 20 +- docs/reference/tech-spec.md | 4 +- 4 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 crates/heph/src/service.rs diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 6288c72..257f637 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -16,6 +16,7 @@ use heph_core::{Node, RankedTask, Task}; use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; mod datespec; +mod service; #[derive(Parser, Debug)] #[command(name = "heph", version, about)] @@ -220,6 +221,11 @@ enum Command { /// Destination directory (created if needed). dir: PathBuf, }, + /// Manage the hephd daemon as an OS service (launchd / systemd). + Daemon { + #[command(subcommand)] + action: service::DaemonAction, + }, /// Authenticate this device with a sync hub (OAuth 2.0 device-code flow). Auth { #[command(subcommand)] @@ -344,6 +350,10 @@ fn run_auth(action: AuthAction) -> Result<()> { fn main() -> Result<()> { let cli = Cli::parse(); + // `daemon` manages the OS service; it must not connect to a daemon. + if let Command::Daemon { action } = &cli.command { + return service::run(action); + } // `auth` runs locally (device-code flow + keyring); it needs no daemon. if let Command::Auth { action } = cli.command { return run_auth(action); @@ -626,6 +636,7 @@ fn main() -> Result<()> { println!("Exported {count} nodes to {}", dir.display()); } Command::Auth { .. } => unreachable!("auth is handled before connecting"), + Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), } Ok(()) } diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs new file mode 100644 index 0000000..3e8f96c --- /dev/null +++ b/crates/heph/src/service.rs @@ -0,0 +1,414 @@ +//! `heph daemon` — manage `hephd` as an OS service (tech-spec §3, [[design]] §4). +//! +//! Surfaces are connect-only; the daemon runs as an explicit service so it can +//! be shared by the CLI, TUI, and `heph.nvim` without any one of them owning its +//! lifecycle. macOS uses a launchd **LaunchAgent**, Linux a **systemd user +//! service**. All verbs are idempotent. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use clap::Subcommand; + +use hephd::{default_db_path, default_socket_path}; + +/// launchd label / systemd-independent identifier. +const LABEL: &str = "org.hephaestus.hephd"; + +#[derive(Subcommand, Debug)] +pub enum DaemonAction { + /// Install (if needed) and start the daemon service. + Start, + /// Stop the daemon now (it may restart at next login; use `uninstall` to + /// stop it for good). + Stop, + /// Restart the daemon — run this after upgrading the binary. + Restart, + /// Show whether the service is installed and running. + Status, + /// Stop and remove the service entirely. + Uninstall, +} + +/// Resolved locations the service definition needs. +struct Paths { + hephd: PathBuf, + db: PathBuf, + socket: PathBuf, + log: PathBuf, +} + +fn paths() -> Result<Paths> { + let db = default_db_path(); + let log = db + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("hephd.log"); + Ok(Paths { + hephd: resolve_hephd()?, + db, + socket: default_socket_path(), + log, + }) +} + +/// Find `hephd`: next to the running `heph` binary first, else on `PATH`. +fn resolve_hephd() -> Result<PathBuf> { + if let Ok(exe) = std::env::current_exe() { + if let Some(sib) = exe.parent().map(|d| d.join("hephd")) { + if sib.is_file() { + return Ok(sib); + } + } + } + which("hephd").context("`hephd` not found next to `heph` or on PATH") +} + +/// Minimal `which`: first PATH entry containing an executable `name`. +fn which(name: &str) -> Result<PathBuf> { + let path = std::env::var_os("PATH").context("PATH is unset")?; + for dir in std::env::split_paths(&path) { + let cand = dir.join(name); + if cand.is_file() { + return Ok(cand); + } + } + bail!("{name} not found on PATH") +} + +enum Manager { + Launchd, + Systemd, +} + +fn manager() -> Result<Manager> { + if cfg!(target_os = "macos") { + Ok(Manager::Launchd) + } else if cfg!(target_os = "linux") { + if Path::new("/run/systemd/system").exists() { + Ok(Manager::Systemd) + } else { + bail!("no systemd detected — run `hephd` yourself (e.g. via your init system)") + } + } else { + bail!("`heph daemon` supports macOS (launchd) and systemd Linux; run `hephd` yourself") + } +} + +pub fn run(action: &DaemonAction) -> Result<()> { + let p = paths()?; + match manager()? { + Manager::Launchd => launchd(action, &p), + Manager::Systemd => systemd(action, &p), + } +} + +// -------------------------------------------------------------------------- +// Rendering (pure — unit-tested) +// -------------------------------------------------------------------------- + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { + let arg = |p: &Path| xml_escape(&p.to_string_lossy()); + format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>{label}</string> + <key>ProgramArguments</key> + <array> + <string>{hephd}</string> + <string>--mode</string> + <string>local</string> + <string>--db</string> + <string>{db}</string> + <string>--socket</string> + <string>{socket}</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>StandardOutPath</key> + <string>{log}</string> + <key>StandardErrorPath</key> + <string>{log}</string> +</dict> +</plist> +"#, + label = LABEL, + hephd = arg(hephd), + db = arg(db), + socket = arg(socket), + log = arg(log), + ) +} + +fn systemd_unit(hephd: &Path, db: &Path, socket: &Path) -> String { + format!( + "[Unit]\n\ + Description=heph daemon (hephd)\n\ + After=default.target\n\ + \n\ + [Service]\n\ + ExecStart={hephd} --mode local --db {db} --socket {socket}\n\ + Restart=on-failure\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + hephd = hephd.display(), + db = db.display(), + socket = socket.display(), + ) +} + +// -------------------------------------------------------------------------- +// Shared helpers +// -------------------------------------------------------------------------- + +/// Write `contents` to `path` (creating parents) only if it differs — keeps the +/// service file stable so reloads aren't forced needlessly. Returns whether it +/// changed. +fn write_if_changed(path: &Path, contents: &str) -> Result<bool> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + if std::fs::read_to_string(path).ok().as_deref() == Some(contents) { + return Ok(false); + } + std::fs::write(path, contents)?; + Ok(true) +} + +/// Run a command, returning success and captured stderr (for benign-failure +/// tolerance like "service not loaded"). +fn run_cmd(prog: &str, args: &[&str]) -> Result<(bool, String)> { + let out = Command::new(prog) + .args(args) + .output() + .with_context(|| format!("failed to run `{prog}`"))?; + Ok(( + out.status.success(), + String::from_utf8_lossy(&out.stderr).into_owned(), + )) +} + +// -------------------------------------------------------------------------- +// launchd (macOS) +// -------------------------------------------------------------------------- + +fn launchd_plist_path() -> Result<PathBuf> { + let home = std::env::var_os("HOME").context("HOME is unset")?; + Ok(PathBuf::from(home) + .join("Library/LaunchAgents") + .join(format!("{LABEL}.plist"))) +} + +fn uid() -> Result<String> { + let out = Command::new("id") + .arg("-u") + .output() + .context("failed to run `id -u`")?; + if !out.status.success() { + bail!("could not determine uid"); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn launchd_loaded(domain_target: &str) -> bool { + run_cmd("launchctl", &["print", domain_target]) + .map(|(ok, _)| ok) + .unwrap_or(false) +} + +fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { + let plist = launchd_plist_path()?; + let uid = uid()?; + let domain = format!("gui/{uid}"); + let target = format!("gui/{uid}/{LABEL}"); + + match action { + DaemonAction::Start => { + write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; + if launchd_loaded(&target) { + println!("heph daemon already running ({LABEL})."); + } else { + let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; + if !ok { + bail!("launchctl bootstrap failed: {}", err.trim()); + } + println!("heph daemon started ({LABEL})."); + } + } + DaemonAction::Stop => { + let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?; + println!("heph daemon stopped (still installed; `uninstall` to remove)."); + } + DaemonAction::Restart => { + write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?; + let _ = run_cmd("launchctl", &["bootout", &target])?; + let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; + if !ok { + bail!("launchctl bootstrap failed: {}", err.trim()); + } + println!("heph daemon restarted ({LABEL})."); + } + DaemonAction::Status => { + let installed = plist.exists(); + let running = launchd_loaded(&target); + print_status(installed, running, p, &plist); + } + DaemonAction::Uninstall => { + let _ = run_cmd("launchctl", &["bootout", &target])?; + if plist.exists() { + std::fs::remove_file(&plist)?; + } + println!("heph daemon uninstalled ({LABEL})."); + } + } + Ok(()) +} + +fn plist_str(p: &Path) -> Result<String> { + Ok(p.to_str() + .context("plist path is not valid UTF-8")? + .to_string()) +} + +// -------------------------------------------------------------------------- +// systemd (Linux, user service) +// -------------------------------------------------------------------------- + +const UNIT: &str = "heph.service"; + +fn systemd_unit_path() -> Result<PathBuf> { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .context("HOME/XDG_CONFIG_HOME unset")?; + Ok(base.join("systemd/user").join(UNIT)) +} + +fn sc(args: &[&str]) -> Result<(bool, String)> { + let mut full = vec!["--user"]; + full.extend_from_slice(args); + run_cmd("systemctl", &full) +} + +fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> { + let unit = systemd_unit_path()?; + match action { + DaemonAction::Start => { + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?; + sc(&["daemon-reload"])?; + let (ok, err) = sc(&["enable", "--now", UNIT])?; + if !ok { + bail!("systemctl enable --now failed: {}", err.trim()); + } + println!("heph daemon started ({UNIT})."); + } + DaemonAction::Stop => { + sc(&["stop", UNIT])?; + println!("heph daemon stopped (still enabled; `uninstall` to remove)."); + } + DaemonAction::Restart => { + write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?; + sc(&["daemon-reload"])?; + let (ok, err) = sc(&["restart", UNIT])?; + if !ok { + bail!("systemctl restart failed: {}", err.trim()); + } + println!("heph daemon restarted ({UNIT})."); + } + DaemonAction::Status => { + let installed = unit.exists(); + let running = sc(&["is-active", UNIT]).map(|(ok, _)| ok).unwrap_or(false); + print_status(installed, running, p, &unit); + } + DaemonAction::Uninstall => { + sc(&["disable", "--now", UNIT])?; + if unit.exists() { + std::fs::remove_file(&unit)?; + } + sc(&["daemon-reload"])?; + println!("heph daemon uninstalled ({UNIT})."); + } + } + Ok(()) +} + +fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path) { + println!("installed : {}", if installed { "yes" } else { "no" }); + println!("running : {}", if running { "yes" } else { "no" }); + println!("service : {}", service_file.display()); + println!("hephd : {}", p.hephd.display()); + println!("db : {}", p.db.display()); + println!("socket : {}", p.socket.display()); + println!("log : {}", p.log.display()); + if !running { + println!("\n(start it with `heph daemon start`)"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launchd_plist_has_label_args_and_paths() { + let plist = launchd_plist( + Path::new("/usr/local/bin/hephd"), + Path::new("/home/e/.local/share/heph/heph.db"), + Path::new("/tmp/heph/hephd.sock"), + Path::new("/home/e/.local/share/heph/hephd.log"), + ); + assert!(plist.contains("<string>org.hephaestus.hephd</string>")); + assert!(plist.contains("<string>/usr/local/bin/hephd</string>")); + assert!(plist.contains("<string>--mode</string>")); + assert!(plist.contains("<string>/home/e/.local/share/heph/heph.db</string>")); + assert!(plist.contains("<string>/tmp/heph/hephd.sock</string>")); + assert!(plist.contains("<key>RunAtLoad</key>")); + assert!(plist.contains("<key>KeepAlive</key>")); + assert!(plist.contains("hephd.log")); + } + + #[test] + fn systemd_unit_has_execstart_and_install() { + let unit = systemd_unit( + Path::new("/usr/local/bin/hephd"), + Path::new("/home/e/.local/share/heph/heph.db"), + Path::new("/run/user/1000/heph/hephd.sock"), + ); + assert!(unit.contains( + "ExecStart=/usr/local/bin/hephd --mode local \ + --db /home/e/.local/share/heph/heph.db \ + --socket /run/user/1000/heph/hephd.sock" + )); + assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("WantedBy=default.target")); + } + + #[test] + fn xml_escape_escapes_markup() { + assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d"); + } + + #[test] + fn write_if_changed_is_idempotent() { + let dir = std::env::temp_dir().join(format!("heph-svc-test-{}", std::process::id())); + let f = dir.join("unit"); + let _ = std::fs::remove_dir_all(&dir); + assert!(write_if_changed(&f, "one").unwrap(), "first write changes"); + assert!(!write_if_changed(&f, "one").unwrap(), "same content no-op"); + assert!(write_if_changed(&f, "two").unwrap(), "new content changes"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 60f7de3..ef80eb1 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -42,12 +42,22 @@ pub(crate) fn blocking_agent() -> ureq::Agent { } /// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to -/// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3). +/// a **stable** path next to the data store (`<data-dir>/heph/hephd.sock`) when +/// `XDG_RUNTIME_DIR` is unset — notably on macOS (tech-spec §3). +/// +/// The fallback is deliberately *not* the system temp dir: the daemon is a +/// long-lived OS service ([[design]] §4), and a temp-dir socket is fragile (it +/// varies per login session and macOS periodically prunes `/var/folders`). A +/// path beside the DB is deterministic across every process and reboot, so the +/// service and its clients always agree. pub fn default_socket_path() -> PathBuf { - let base = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - base.join("heph").join("hephd.sock") + if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(rt).join("heph").join("hephd.sock"); + } + default_db_path() + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("hephd.sock") } /// Default store path: `$XDG_DATA_HOME/heph/heph.db`, falling back to diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 2ebf50d..21c9aa2 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -358,7 +358,9 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. - ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). - ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. -- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. +- ✅ **CLI (§1) — the complete daemon API + task driver:** `heph` covers every RPC (next/list/task/done/drop/skip/attention/**edit**/promote/show/log/health/doc/node/get/resolve/search/journal/links/backlinks/link/project[+list]/sync/conflicts/export/auth) with **human dates** (`--do-date tomorrow|+3d|fri|ISO`) and **recurrence** (presets + NL + raw `--rrule`; `datespec`). Reschedule is the new **`task.set_schedule`** RPC. Process-tested over a real socket. +- ✅ **Daemon as an OS service (§3, [[design]] §4):** **`heph daemon start/stop/restart/status/uninstall`** idempotently manages a launchd agent (macOS) / systemd user service (Linux) running `hephd` on the default store; render fns unit-tested, verified live on macOS. The default socket is now a **stable** `<data-dir>/heph/hephd.sock` (was `$TMPDIR`-based) so a persistent service and its clients always agree. Surfaces are **connect-only** (no auto-spawn). +- ✅ **Todoist importer (tooling):** `mise run import-todoist` seeds a store from Todoist (dry-run default, `-- --commit` to write) — see [[import-todoist]]. - ✅ **CI (§9):** Forgejo `build.yaml` runs **entirely through Dagger** (the k8s job image is a thin Alpine + Dagger orchestrator with a DinD sidecar — no native Rust/nvim toolchain): `dagger call check` (cargo fmt/clippy/test on `rust:1-bookworm`) + `dagger call test-nvim` (build hephd + headless e2e). Cargo parallelism capped (`CARGO_BUILD_JOBS`) to avoid OOMing the build engine; cargo caches shared across runs. `prek` runs locally via git hooks, not in CI. - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). - ✅ **`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). -- 2.50.1 (Apple Git-155) From cdd4d9f62a8afc83d67cfcf12affbe4d37dd2b41 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 21:21:28 -0700 Subject: [PATCH 43/91] =?UTF-8?q?heph.nvim:=20rip=20out=20auto-spawn=20?= =?UTF-8?q?=E2=80=94=20connect-only=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon is now an OS service (`heph daemon`); the plugin no longer spawns or supervises one. Removes the managed-daemon machinery entirely. - delete lua/heph/daemon.lua (spawn/ensure/stop_spawned/self-heal) - init.lua: connect-only; probe `health` once and guide to `heph daemon start` - rpc.lua: drop set_respawn + respawn-on-drop; a dropped connection just reconnects once (e.g. after `heph daemon restart`), never spawns - config.lua: drop autostart/bin/db; stable socket fallback (data-dir, matches hephd::default_socket_path), keep $HEPH_SOCKET for dev isolation - tests: spawn/wait_ready move into the e2e harness (test infra); rework managed_daemon_spec into a connect-only spec (connect / clean-fail / reconnect) 16 nvim e2e specs pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- heph.nvim/lua/heph/config.lua | 34 ++--- heph.nvim/lua/heph/daemon.lua | 139 -------------------- heph.nvim/lua/heph/init.lua | 49 +++---- heph.nvim/lua/heph/rpc.lua | 16 +-- heph.nvim/tests/e2e/helpers.lua | 65 +++++++-- heph.nvim/tests/e2e/managed_daemon_spec.lua | 119 ++++------------- 6 files changed, 114 insertions(+), 308 deletions(-) delete mode 100644 heph.nvim/lua/heph/daemon.lua diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index 0571b40..0a951dd 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -4,15 +4,8 @@ local M = {} M.defaults = { --- Path to hephd's unix socket. `nil` → `$HEPH_SOCKET`, else the daemon default. + --- The plugin is connect-only; run the daemon with `heph daemon start`. socket = nil, - --- 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", --- Title of the home / index page (`:Heph home`). home = "Home", --- How many recent days the `:Heph journals` picker offers. @@ -22,27 +15,24 @@ M.defaults = { } --- 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. +--- default — `$XDG_RUNTIME_DIR/heph/hephd.sock`, else a **stable** +--- `<data-dir>/heph/hephd.sock` (matching `hephd::default_socket_path`; not a +--- temp dir, since the daemon is a persistent service). `$HEPH_SOCKET` 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 local xdg = vim.env.XDG_RUNTIME_DIR - local base = (xdg and #xdg > 0) and xdg or (vim.env.TMPDIR or "/tmp") - 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 + if xdg and #xdg > 0 then + return (xdg:gsub("/+$", "")) .. "/heph/hephd.sock" end - return nil + local data = vim.env.XDG_DATA_HOME + if not (data and #data > 0) then + data = (vim.env.HOME or "") .. "/.local/share" + end + return (data:gsub("/+$", "")) .. "/heph/hephd.sock" end --- Apply the default keymaps (no-op when `opts.keymaps` is false). diff --git a/heph.nvim/lua/heph/daemon.lua b/heph.nvim/lua/heph/daemon.lua deleted file mode 100644 index 9792b92..0000000 --- a/heph.nvim/lua/heph/daemon.lua +++ /dev/null @@ -1,139 +0,0 @@ ---- Locate, spawn, and wait on a `hephd` daemon. Shared by optional autostart ---- and by the e2e harness (so test readiness uses the same definition the ---- plugin does). - -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) - local args = { "--mode", "local" } - if opts.db then - table.insert(args, "--db") - table.insert(args, opts.db) - end - if opts.socket then - table.insert(args, "--socket") - table.insert(args, opts.socket) - end - local handle, pid = uv.spawn(opts.bin or "hephd", { - args = args, - stdio = { nil, nil, opts.stderr }, - }, function(code, signal) - if opts.on_exit then - opts.on_exit(code, signal) - end - end) - if not handle then - error("heph: failed to spawn hephd (bin=" .. (opts.bin or "hephd") .. ")") - end - return { handle = handle, pid = pid } -end - ---- Wait until `socket` both exists and accepts a real RPC (`health`). The ---- existence check alone races the daemon's bind→accept, so we prove liveness ---- with a round-trip. Returns `true`, or `false, reason`. ---- ---- The probe runs in a **plain Lua loop**, never inside a `vim.wait` predicate: ---- the rpc round-trip itself uses `vim.wait`, and nesting `vim.wait` inside ---- another `vim.wait`'s predicate deadlocks Neovim (a stale socket made the ---- inner connect-wait re-enter and hang). -function M.wait_ready(socket, timeout) - timeout = timeout or 5000 - local rpc = require("heph.rpc") - local deadline = uv.hrtime() + timeout * 1e6 -- ns - while uv.hrtime() < deadline do - if uv.fs_stat(socket) ~= nil then - local session = rpc.new_session(socket) - local ok = pcall(function() - session:call("health", vim.empty_dict(), { timeout = 200 }) - end) - session:close() - if ok then - return true - end - end - vim.wait(50) -- yield ~50ms; no predicate, so not nested - end - return false, "daemon not ready at " .. socket -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) - -- hephd doesn't unlink its socket on SIGTERM; remove it so the next launch - -- doesn't probe a stale socket. (A crash still leaves one — wait_ready copes.) - pcall(function() - uv.fs_unlink(m.socket) - end) -end - -return M diff --git a/heph.nvim/lua/heph/init.lua b/heph.nvim/lua/heph/init.lua index d6e9cb4..c29e605 100644 --- a/heph.nvim/lua/heph/init.lua +++ b/heph.nvim/lua/heph/init.lua @@ -11,48 +11,29 @@ M.config = nil --- Configure the plugin. `opts.socket` overrides the daemon socket path; --- `opts.keymaps = false` disables the default keymaps. Idempotent. +--- +--- The plugin is **connect-only** — it never spawns or supervises a `hephd`. +--- Run the daemon as an OS service with `heph daemon start` ([[run-the-daemon]]); +--- this just connects to it. If nothing is serving the socket, we notify once +--- with guidance and let later calls retry (a plain reconnect, never a spawn). 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 local rpc = require("heph.rpc") - local daemon = require("heph.daemon") rpc.setup(cfg.socket) - if cfg.autostart then - -- 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.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 + -- A cheap liveness probe so a missing daemon is reported up front, not as a + -- cryptic error on the first command. + local ok = pcall(function() + rpc.call("health", {}) + end) + if not ok then + require("heph.util").notify( + "no hephd at " .. cfg.socket .. " — run `heph daemon start`", + vim.log.levels.WARN + ) end config.apply_keymaps(cfg) diff --git a/heph.nvim/lua/heph/rpc.lua b/heph.nvim/lua/heph/rpc.lua index 0bec263..64add69 100644 --- a/heph.nvim/lua/heph/rpc.lua +++ b/heph.nvim/lua/heph/rpc.lua @@ -182,13 +182,6 @@ function M.session() return M._default end ---- 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 @@ -196,16 +189,15 @@ local function is_connection_error(msg) 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). +--- Blocking call on the default session. The plugin is connect-only: on a +--- dropped connection we drop the dead session and **reconnect once** (e.g. the +--- daemon was restarted via `heph daemon restart`) — we never spawn a daemon. function M.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) + if is_connection_error(result) then M.session():close() -- drop the dead connection so the retry reconnects return M.session():call(method, params, opts) end diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua index 73b3a8c..f1a2dcb 100644 --- a/heph.nvim/tests/e2e/helpers.lua +++ b/heph.nvim/tests/e2e/helpers.lua @@ -3,11 +3,52 @@ --- Step builders (create doc/task, open, edit, save) are reusable across specs. local rpc = require("heph.rpc") -local daemon = require("heph.daemon") +local uv = vim.uv or vim.loop local M = {} local counter = 0 +--- Spawn a `local`-mode hephd against `db` listening on `socket` (test infra — +--- the plugin itself is connect-only; the daemon is normally an OS service). +local function spawn(opts) + local args = { "--mode", "local", "--db", opts.db, "--socket", opts.socket } + local handle, pid = uv.spawn(opts.bin, { + args = args, + stdio = { nil, nil, opts.stderr }, + }, function(code, signal) + if opts.on_exit then + opts.on_exit(code, signal) + end + end) + if not handle then + error("heph-test: failed to spawn hephd (bin=" .. tostring(opts.bin) .. ")") + end + return { handle = handle, pid = pid } +end + +--- Wait until `socket` exists and answers `health`. Plain Lua loop — never a +--- `vim.wait` predicate (the rpc round-trip uses `vim.wait`; nesting deadlocks). +local function wait_ready(socket, timeout) + timeout = timeout or 5000 + local deadline = uv.hrtime() + timeout * 1e6 + while uv.hrtime() < deadline do + if uv.fs_stat(socket) ~= nil then + local session = rpc.new_session(socket) + local ok = pcall(function() + session:call("health", vim.empty_dict(), { timeout = 200 }) + end) + session:close() + if ok then + return true + end + end + vim.wait(50) + end + return false, "daemon not ready at " .. socket +end + +M.wait_ready = wait_ready + local function repo_root() -- ":p" makes this absolute regardless of how the runner was launched. local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p") @@ -35,7 +76,7 @@ local function unique_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. +--- tests of the no-daemon-running case). `rm` removes it. function M.tmp() local dir = unique_dir() return { dir = dir, sock = dir .. "/s", db = dir .. "/db", rm = function() @@ -45,12 +86,9 @@ function M.tmp() 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() - local dir = unique_dir() - local sock = dir .. "/s" - local db = dir .. "/db" +--- Start a daemon on explicit paths and bind the plugin's rpc to it. Returns a +--- `ctx` with `dir, sock, db, daemon, exited, q` (an isolated assert session). +function M.start_on(dir, sock, db) assert(#sock < 104, "socket path too long for sun_path: " .. sock) local bin = M.hephd_bin() assert( @@ -59,7 +97,7 @@ function M.start() ) local exited = { done = false } - local d = daemon.spawn({ + local d = spawn({ bin = bin, db = db, socket = sock, @@ -67,7 +105,7 @@ function M.start() exited.done = true end, }) - local ok, reason = daemon.wait_ready(sock, 5000) + local ok, reason = wait_ready(sock, 5000) assert(ok, "daemon not ready: " .. tostring(reason)) rpc.setup(sock) -- the plugin's default session, used by buffers/commands @@ -81,6 +119,12 @@ function M.start() } end +--- Start a fresh daemon on a new temp dir and bind the plugin's rpc to it. +function M.start() + local dir = unique_dir() + return M.start_on(dir, dir .. "/s", dir .. "/db") +end + --- Tear down: close sessions, delete heph:// buffers, reap the daemon, rm temp. function M.stop(ctx) if not ctx then @@ -92,7 +136,6 @@ 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 index e170529..4e2c6a5 100644 --- a/heph.nvim/tests/e2e/managed_daemon_spec.lua +++ b/heph.nvim/tests/e2e/managed_daemon_spec.lua @@ -1,108 +1,47 @@ --- 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. +-- The plugin is connect-only (tech-spec §8, [[design]] §4): it never spawns a +-- daemon — it connects to one run as an OS service (`heph daemon start`). These +-- specs cover connecting to a running daemon, a clean failure when none is +-- running, and reconnecting after the daemon is restarted. 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 +describe("connect-only daemon", function() + it("connects to a running daemon and works", function() + local ctx = h.start() -- harness starts a real daemon; binds the plugin to it + require("heph").setup({ socket = ctx.sock, keymaps = false }) + assert.is_truthy(require("heph.rpc").call("health", {})) + h.stop(ctx) end) - after_each(function() - pcall(function() - require("heph.daemon").stop_spawned() - end) + + it("fails cleanly when no daemon is running (never spawns one)", function() + local t = h.tmp() -- temp socket path with nothing serving it + require("heph.rpc").setup(t.sock) + -- A call must fail loudly (connection error), not hang or spawn a daemon. + local ok = pcall(require("heph.rpc").call, "health", {}) + assert.is_false(ok, "expected a connection failure with no daemon running") 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, - }) + it("reconnects after the daemon is restarted under it", function() + local ctx = h.start() + require("heph").setup({ socket = ctx.sock, 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") + -- Kill the daemon, then start a fresh one on the SAME socket (as + -- `heph daemon restart` would). The next call should reconnect. + ctx.daemon.handle:kill("sigterm") vim.wait(2000, function() - return m.exited.done + return ctx.exited.done end, 20) + pcall(function() + vim.uv.fs_unlink(ctx.sock) + end) - -- The next call transparently respawns the daemon and succeeds. + local ctx2 = h.start_on(ctx.dir, ctx.sock, ctx.db) assert.is_truthy(require("heph.rpc").call("health", {})) - assert.is_true(require("heph.daemon").is_managed()) - end) - - it("does not deadlock on a stale socket left by a crash (regression)", function() - -- Bring up a managed daemon, then HARD-kill it so no cleanup runs — leaving - -- a stale socket file with no listener (the second-launch crash scenario: - -- wait_ready ran the rpc probe inside a vim.wait predicate, nesting vim.wait - -- and freezing Neovim). - require("heph").setup({ - socket = t.sock, - db = t.db, - bin = h.hephd_bin(), - autostart = true, - keymaps = false, - }) - require("heph.rpc").call("health", {}) - local m = require("heph.daemon")._managed - m.handle:kill("sigkill") - vim.wait(2000, function() - return m.exited.done - end, 20) - assert.is_truthy(vim.uv.fs_stat(t.sock), "precondition: a stale socket is present") - - -- Probing the stale socket must RETURN promptly (not deadlock). The fix - -- returns in ~200ms; the bug froze here indefinitely. - local start = vim.uv.hrtime() - local ready = require("heph.daemon").wait_ready(t.sock, 200) - local elapsed_ms = (vim.uv.hrtime() - start) / 1e6 - assert.is_false(ready) - assert.is_true(elapsed_ms < 2000, "wait_ready took " .. math.floor(elapsed_ms) .. "ms — possible deadlock") - - -- A fresh autostart recovers despite the stale socket still being there. - require("heph").setup({ - socket = t.sock, - db = t.db, - bin = h.hephd_bin(), - autostart = true, - keymaps = false, - }) - 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") + h.stop(ctx2) end) end) -- 2.50.1 (Apple Git-155) From eb4a8277003ce105543a5782f7171b89960ad3e0 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 2 Jun 2026 22:02:08 -0700 Subject: [PATCH 44/91] docs: plan the filter-views slice + TUI coherence; note chores rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tech-spec §8.2 (new): filter-views slice — extend `list` to a data-expressed predicate (attention set, project-subtree/multi scope, exclude-projects, actionable toggle), five built-in views (`heph view <name>`) seeded from the owner's verbatim Todoist queries; the TUI's filter pane reuses them - tech-spec §8.1: TUI filter pane = the §8.2 views; depends on that slice - tech-spec §14: filter-views is the next slice (before heph-tui); CLI + daemon-service marked done - design §6.2.1: record the verbatim filter queries + the reference-context → wiki reclassification, and note future direction: chores as a first-class feature (own do-date/recurrence; retire Chores/Camano-Chores projects) and dropping the Schedule filter (time-of-day not modeled) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/explanation/design.md | 20 ++++++++++++++++ docs/reference/tech-spec.md | 47 ++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/explanation/design.md b/docs/explanation/design.md index 5fb93ef..54cb8a9 100644 --- a/docs/explanation/design.md +++ b/docs/explanation/design.md @@ -274,6 +274,26 @@ The owner's old rule — "avoid ncurses and interactive UIs; write atomic code a - **Natural-language recurrence** — 95/107 dated tasks recur, expressed as `every 3 days`, `every 6 months`, `every workday`, `every April 15`, `every other wed`. heph stores RFC-5545 RRULE; capture should accept the common NL forms and compile them (the easy subset; time-of-day like "at 08:00" deferred — heph's `do_date` is date-grained for ranking). - **Tags are noise** — 7 labels, **5 task-uses across 387**. Confidently **defer** a tag surface; it is not load-bearing. +**The saved filters, verbatim (the basis for heph's filter views, [[tech-spec]] §8.2):** + +| Filter | Todoist query | +|---|---| +| Top of Mind | `(p1\|p2) & (no date \| today \| overdue)` | +| Schedule | `(today \| overdue) & !no time` | +| Tasks | `!p3 & (no date \| today \| overdue) & (p1 \| (p2 & !#Work) \| !(#Daily Routine \| #Work Routine \| #Chores \| #Camano Chores \| #Work \| ##Culture \| #Camano Info)) & !subtask` | +| Work Tasks | `#Work & !p3 & (no date \| today \| overdue) & !subtask` | +| Chores | `(today \| overdue \| no date) & (#Chores \| #Camano Chores)` | +| On Deck | `p3 & (no date \| overdue \| today)` | + +These define both the **agenda slices** heph must offer ([[tech-spec]] §8.2) and, by what "Tasks" *excludes* (`##Culture` = Movies/Books/Theater; `#Camano Info`), which contexts are **not tasks at all**. + +**Reference contexts → wiki, not tasks (decided 2026-06).** The `##Culture` subtree (Movies/Books/Theater) and `#Camano Info` are reference lists (films to watch, books to read, contractor phone numbers), deliberately excluded from every task filter. In heph they belong as **`doc` pages**, not committed tasks — otherwise they flood "what is next?". On the initial Todoist import these 5 projects (~59 items) were reclassified into one wiki list-doc each and the task/project nodes tombstoned. (A useful general principle: a "task" that never appears in any of your filters is probably a note.) + +**Future direction (noted 2026-06, not scheduled):** + +- **Chores as a first-class feature.** Chores want **different do-date / recurrence semantics** from regular tasks (the every-N-days "do it again sometime after" rhythm, tuned-down urgency). Rather than model them as a `#Chores` *project* you scope to, make "chore" a **first-class task property** (kind/flag) with its own scheduling rules — which retires the `#Chores` / `#Camano Chores` projects (and the Camano split) entirely. The interim `Chores` filter view ([[tech-spec]] §8.2) is project-scoped until then. +- **Drop the `Schedule` filter.** Schedule was Todoist's timed-routine view (`!no time`); heph's `do_date` is date-grained, so it is **omitted** (not approximated). It's entangled with the chores rework (timed routines), so reconsider both together if/when time-of-day lands on tasks. + ### 6.3 Two kinds of task: commitments vs. context items > 🔒 **DECIDED (shape).** A **commitment axis** orthogonal to the §6.2 attention-states. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 21c9aa2..855cc7a 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -253,10 +253,45 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** — `ratatui` + `crossterm`, a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. - **Layout** — three panes: **projects/contexts** (the §6.2.1 hierarchy) · **task list** (`next`/`list` rows with attention + human do/late) · **preview** (canonical-context doc body / `log.tail`). -- **Gestures** — `j/k` move · `a` add · `x` done · `space` skip · `A` cycle attention · `e` reschedule (do/late) · `b` push-to-blue · saved filters for the **daily rituals** (Top of Mind, On-Deck review, per-project) — the [[design]] §6.2 "filters = saved views" made interactive. +- **Gestures** — `j/k` move · `a` add · `x` done · `space` skip · `A` cycle attention · `e` reschedule (do/late) · `b` push-to-blue · the left pane lists the **§8.2 named filter views** (Top of Mind, Tasks, Work Tasks, Chores, On Deck) — the [[design]] §6.2 "filters = saved views" made interactive. - **TUI ↔ nvim handoff** — `o`/`<CR>` launches `$EDITOR` (nvim) on the task's canonical-context doc (`nvim` with a `+lua` call opening `heph://node/<ctx-id>`, or a temp `.md` round-tripped through `node.update`); a nvim command (e.g. `:Heph agenda`) shells back to the TUI. - **Testing** — TDD against a real daemon; headless smoke via `ratatui`'s `TestBackend`. -- **Prereqs** (surface-agnostic, landing first): the CLI-complete task surface (human dates, `list`/state/`edit`, recurrence) and `task.set_schedule` (reschedule) — both in the current slice. +- **Prereqs** (land first): **§8.2 filter views** (the TUI's saved-filter pane is just those views); the CLI-complete task surface and `task.set_schedule` (done). + +## 8.2 Filter views (saved agenda slices) — planned, the next slice + +> **Status: planned, the next slice.** [[design]] §6.2 / §6.2.1 establish that the owner navigates work through a fixed set of **saved filters**, not one flat list — so `next` alone is too coarse. This slice makes those filters first-class, shared by the CLI now and the TUI (§8.1) next. (The reference-context noise those filters excluded — `##Culture`, `#Camano Info` — has already been reclassified out of tasks into wiki docs, [[design]] §6.2.1.) + +**The five built-in views** (the owner's sixth Todoist filter, **Schedule**, is intentionally dropped — see below), each derived from the verbatim Todoist query ([[design]] §6.2.1) and realized in heph terms (attention: p1→red, p2→orange, p4→white, p3→blue): + +| View | Todoist query (origin) | heph realization | +|---|---|---| +| **Top of Mind** | `(p1\|p2) & (no date\|today\|overdue)` | `attention ∈ {red,orange}` ∧ actionable | +| **On Deck** | `p3 & (no date\|overdue\|today)` | `attention = blue` ∧ actionable | +| **Chores** | `(today\|overdue\|no date) & (#Chores\|#Camano Chores)` | scope ∈ {Chores, Camano Chores} ∧ actionable | +| **Work Tasks** | `#Work & !p3 & (…) & !subtask` | scope = Work subtree ∧ `attention ≠ blue` ∧ actionable | +| **Tasks** | `!p3 & (…) & !(#Daily Routine\|#Work Routine\|#Chores\|#Camano Chores\|#Work\|##Culture\|#Camano Info) & !subtask` | `attention ≠ blue` ∧ actionable ∧ **not in** the routine/work/chore projects | + +**Engine work — extend `list` (§6) so a view is a *predicate expressed as data*** (mirroring §7's "order as data"): + +- `attention`: a **set** of states (was a single value) — e.g. `{red,orange}`. +- `scope`: a project **including its descendant projects** (subtree, for `##Culture` / Work-tree), and/or **multiple** projects (Chores + Camano Chores). +- `exclude_projects`: a list subtracted from the result (the "Tasks" leftover view). +- `actionable`: a bool toggle applying the §7 do-date candidacy gate inside `list` (today the gate is `next`-only). +- (`recurring`: optional filter for the `Schedule` approximation.) + +Project-subtree resolution needs the **parent-project links** ([[design]] §6.2.1) — a small `links`/query addition. `!subtask` is largely implicit (heph `list`/`next` return committed tasks, not context items). + +**Surface:** +- **CLI:** `heph view <name>` (`tom|tasks|work|chores|ondeck`) prints the slice using the same row format as `next`/`list`. A `heph view` with no name lists the available views. +- **TUI (§8.1):** the same views power the left filter pane. +- **nvim:** may later expose them (`:Heph view <name>`) — navigation, not required for this slice. + +**Defaults vs. custom:** the five ship as **built-in named views** seeded from the queries above. **User-defined filters** (a small config-driven view list, eventually a query DSL) are a later extension — deliberately deferred to avoid building a Todoist-query-language parser now. + +**Schedule view dropped (decided 2026-06).** `Schedule`'s `!no time` selects items with a *time-of-day*, which heph's **date-grained** `do_date` can't represent; rather than ship a misleading approximation it is **omitted for now**. It is entangled with the chores rework below (timed routines), so revisit both together if/when time-of-day lands on tasks. + +> **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1. ## 9. Testing strategy (TDD, layered) @@ -374,12 +409,12 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi **Not yet done (resume order)** -> The Rust backend is feature-complete; `heph.nvim` slices 11a–11c + a UX iteration are done. **Surface strategy revised 2026-06 to a three-surface model** ([[design]] §4 / §6.2.1, grounded in a study of the owner's live Todoist): **CLI = capture/scripting + complete API**, **TUI = primary task agenda/triage** (to build), **nvim = context/KB**. Remaining work is reordered accordingly. +> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), and the live store has been seeded from Todoist with reference contexts reclassified to wiki docs ([[design]] §6.2.1). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (to build), **nvim = context/KB**. Remaining work, in order: -1. ⏳ **CLI-complete task surface (§1, §6, §7) — IN PROGRESS (this slice):** make `heph` implement the **entire daemon API** with ergonomic task management — human date parse+display (`tomorrow`/`+3d`/`fri`/ISO), recurrence presets + easy NL subset, `list`, `done`/`drop`/`skip`, `attention`, **`edit`** (new backend **`task.set_schedule`** — the missing *reschedule* capability), `promote`, `health`, `log`, project-by-name, links/backlinks, sync, conflicts. (Replaces the old "task-scheduling UX in nvim" item — structured-field entry belongs on the CLI/TUI, not nvim buffers; [[design]] §4.) -2. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; daily-ritual filters (orange reconfirm, blue review); launches into nvim for context and back. Planned in §8.1. +1. ⏳ **Filter views (§8.2) — the next slice:** make the owner's saved filters (Top of Mind / Tasks / Work Tasks / Chores / On Deck — **Schedule dropped**, §8.2) first-class so the agenda isn't one flat list. Extend `list` (§6) to a data-expressed predicate — **attention set**, **project-subtree / multi scope**, **exclude-projects**, **actionable toggle** (+ parent-project link resolution) — and surface five built-in views via `heph view <name>` (the TUI reuses them). Seeded from the verbatim Todoist queries ([[design]] §6.2.1). *(Future, noted: chores become a first-class kind with their own do-date/recurrence semantics, retiring the Chores/Camano-Chores projects — §8.2.)* +2. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; the §8.2 filter views as the saved-filter pane; launches into nvim for context and back. **Depends on the filter-views slice.** 3. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). -4. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (hierarchy-aware `scope`, `project list` needing a list-by-kind RPC) is a refinement. +4. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduces) is a refinement. 5. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 6. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). 7. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -- 2.50.1 (Apple Git-155) From a5fc57852577752f00906b933080bf1f0303a4cf Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 06:39:07 -0700 Subject: [PATCH 45/91] =?UTF-8?q?feat(views):=20filter=20views=20(=C2=A78.?= =?UTF-8?q?2)=20=E2=80=94=20saved=20agenda=20slices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the owner's saved filters first-class so the agenda isn't one flat list. `list` now takes a ListFilter predicate-as-data (heph-core::filter): attention include/exclude sets, project-id scope, exclude_projects, and an actionable do-date gate. New Store::view(name) resolves a built-in ViewSpec — looking project names up to ids and subtree-expanding them through parent links — then lists. Five built-ins seeded from the Todoist queries (design §6.2.1): tom, ondeck, chores, work, tasks (Schedule dropped — time-of-day isn't modeled on date-grained do-dates). Surfaced as `heph view <name>` (no name lists them), the `view` RPC + RemoteStore forward, and `:Heph view <name>` in nvim. The list RPC/RemoteStore/CLI/heph.nvim migrate to the filter wire; legacy --scope/--attention/--no-blue map onto it (nvim view.lua updated). Tests: filter unit predicate, a views integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket list/view dispatch test, two nvim e2e specs. 154 Rust tests + 18 nvim e2e green; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/error.rs | 4 + crates/heph-core/src/filter.rs | 278 +++++++++++++++++++++++ crates/heph-core/src/lib.rs | 2 + crates/heph-core/src/sqlite/links.rs | 45 ++++ crates/heph-core/src/sqlite/mod.rs | 16 +- crates/heph-core/src/sqlite/tasks.rs | 77 +++++-- crates/heph-core/src/store.rs | 23 +- crates/heph-core/tests/query_surface.rs | 28 ++- crates/heph-core/tests/views.rs | 161 +++++++++++++ crates/heph/src/main.rs | 35 ++- crates/hephd/src/remote.rs | 20 +- crates/hephd/src/rpc.rs | 29 ++- crates/hephd/tests/client_mode.rs | 2 +- crates/hephd/tests/rpc_socket.rs | 38 ++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 1 + docs/reference/tech-spec.md | 31 +-- heph.nvim/lua/heph/command.lua | 8 + heph.nvim/lua/heph/view.lua | 28 ++- heph.nvim/tests/e2e/view_spec.lua | 45 ++++ 20 files changed, 773 insertions(+), 99 deletions(-) create mode 100644 crates/heph-core/src/filter.rs create mode 100644 crates/heph-core/tests/views.rs create mode 100644 heph.nvim/tests/e2e/view_spec.lua diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs index d0c3eb5..8397e4b 100644 --- a/crates/heph-core/src/error.rs +++ b/crates/heph-core/src/error.rs @@ -23,6 +23,10 @@ pub enum Error { #[error("data integrity: {0}")] Integrity(String), + /// A caller passed an invalid argument (e.g. an unknown filter-view name). + #[error("invalid argument: {0}")] + InvalidArg(String), + /// A remote backend (a `RemoteStore` in `client` mode) failed or returned an /// error response (tech-spec §3.1). #[error("remote: {0}")] diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs new file mode 100644 index 0000000..bd463ad --- /dev/null +++ b/crates/heph-core/src/filter.rs @@ -0,0 +1,278 @@ +//! Filter views — saved agenda slices (tech-spec §8.2). +//! +//! A view is a **predicate expressed as data** (mirroring §7's "order as +//! data"): the engine [`Store::list`](crate::store::Store::list) takes a +//! [`ListFilter`] and returns the matching outstanding tasks as +//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / On Deck +//! / Chores / Work Tasks / Tasks) are derived from the owner's Todoist filter +//! queries (see `docs/explanation/design.md` §6.2.1) and realized in heph terms +//! (attention: p1→red, p2→orange, p4→white, p3→blue). + +use serde::{Deserialize, Serialize}; + +use crate::model::Attention; +use crate::ranking::RankedTask; + +/// A list predicate, expressed as data. Every field defaults to "no +/// constraint"; the implicit constraints (outstanding, non-tombstoned) are +/// applied by the store query itself, not here. +/// +/// `scope`/`exclude_projects` are project **node ids** that the caller has +/// already subtree-expanded ([`crate::store::Store::view`] does this from +/// project names); [`ListFilter::matches`] is then pure set-membership. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ListFilter { + /// Keep only these attention states. Empty = any attention (including + /// attention-less tasks). A whitelist *excludes* attention-less tasks. + pub attention_in: Vec<Attention>, + /// Drop these attention states. `[Blue]` expresses "≠ blue" while still + /// keeping attention-less tasks (unlike an `attention_in` whitelist). + pub attention_not: Vec<Attention>, + /// Keep only tasks whose project is one of these ids (subtree-expanded). + /// Empty = any project, including project-less tasks. + pub scope: Vec<String>, + /// Drop tasks whose project is one of these ids (subtree-expanded). + pub exclude_projects: Vec<String>, + /// Apply the §7 do-date candidacy gate: `do_date` is `None` or `<= now`. + pub actionable: bool, +} + +impl ListFilter { + /// Does `task` satisfy this predicate at `now`? Pure — `scope`/`exclude` + /// must already be project-id sets (subtree expansion happens upstream). + pub fn matches(&self, task: &RankedTask, now: i64) -> bool { + if !self.attention_in.is_empty() + && !task + .attention + .is_some_and(|a| self.attention_in.contains(&a)) + { + return false; + } + if let Some(a) = task.attention { + if self.attention_not.contains(&a) { + return false; + } + } + if !self.scope.is_empty() + && !task + .project_id + .as_ref() + .is_some_and(|p| self.scope.contains(p)) + { + return false; + } + if let Some(p) = &task.project_id { + if self.exclude_projects.contains(p) { + return false; + } + } + if self.actionable && task.do_date.is_some_and(|d| d > now) { + return false; + } + true + } +} + +/// A built-in named view: a [`ListFilter`] template whose `scope`/`exclude` are +/// project **names** (resolved to ids + subtree-expanded by the store). The +/// owner's sixth Todoist filter, `Schedule`, is intentionally dropped — its +/// time-of-day selection has no representation in heph's date-grained +/// `do_date` (tech-spec §8.2). +pub struct ViewSpec { + /// The short CLI name (`heph view <name>`). + pub name: &'static str, + /// A human title for the filter pane / `heph view` listing. + pub title: &'static str, + /// Attention whitelist (see [`ListFilter::attention_in`]). + pub attention_in: &'static [Attention], + /// Attention blacklist (see [`ListFilter::attention_not`]). + pub attention_not: &'static [Attention], + /// Project names to scope to (subtree-expanded by the store). + pub scope_names: &'static [&'static str], + /// Project names to exclude (subtree-expanded by the store). + pub exclude_names: &'static [&'static str], + /// Whether the §7 do-date candidacy gate applies. + pub actionable: bool, +} + +/// The five built-in views (tech-spec §8.2), each realized from the verbatim +/// Todoist query in design §6.2.1. +pub const BUILTIN_VIEWS: &[ViewSpec] = &[ + // (p1|p2) & (no date|today|overdue) + ViewSpec { + name: "tom", + title: "Top of Mind", + attention_in: &[Attention::Red, Attention::Orange], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: true, + }, + // p3 & (no date|overdue|today) + ViewSpec { + name: "ondeck", + title: "On Deck", + attention_in: &[Attention::Blue], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: true, + }, + // (today|overdue|no date) & (#Chores|#Camano Chores) + ViewSpec { + name: "chores", + title: "Chores", + attention_in: &[], + attention_not: &[], + scope_names: &["Chores", "Camano Chores"], + exclude_names: &[], + actionable: true, + }, + // #Work & !p3 & (…) & !subtask + ViewSpec { + name: "work", + title: "Work Tasks", + attention_in: &[], + attention_not: &[Attention::Blue], + scope_names: &["Work"], + exclude_names: &[], + actionable: true, + }, + // !p3 & (…) & !(#Daily Routine|#Work Routine|#Chores|#Camano Chores|#Work|…) & !subtask + ViewSpec { + name: "tasks", + title: "Tasks", + attention_in: &[], + attention_not: &[Attention::Blue], + scope_names: &[], + exclude_names: &[ + "Chores", + "Camano Chores", + "Work", + "Work Routine", + "Daily Routine", + ], + actionable: true, + }, +]; + +/// Look up a built-in view by its short name (`tom|ondeck|chores|work|tasks`). +pub fn builtin(name: &str) -> Option<&'static ViewSpec> { + BUILTIN_VIEWS.iter().find(|v| v.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::TaskState; + + const NOW: i64 = 1_000_000; + + fn task(id: &str) -> RankedTask { + RankedTask { + node_id: id.to_string(), + title: id.to_string(), + attention: Some(Attention::White), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + tombstoned: false, + project_id: None, + canonical_context_id: None, + created_at: 0, + } + } + + #[test] + fn empty_filter_matches_anything() { + let f = ListFilter::default(); + assert!(f.matches(&task("a"), NOW)); + } + + #[test] + fn attention_in_is_a_whitelist_excluding_attentionless() { + let f = ListFilter { + attention_in: vec![Attention::Red, Attention::Orange], + ..Default::default() + }; + let mut red = task("red"); + red.attention = Some(Attention::Red); + let mut white = task("white"); + white.attention = Some(Attention::White); + let mut none = task("none"); + none.attention = None; + assert!(f.matches(&red, NOW)); + assert!(!f.matches(&white, NOW)); + assert!(!f.matches(&none, NOW)); + } + + #[test] + fn attention_not_keeps_attentionless() { + // "≠ blue" must keep attention-less tasks (unlike a whitelist). + let f = ListFilter { + attention_not: vec![Attention::Blue], + ..Default::default() + }; + let mut blue = task("blue"); + blue.attention = Some(Attention::Blue); + let mut none = task("none"); + none.attention = None; + assert!(!f.matches(&blue, NOW)); + assert!(f.matches(&none, NOW)); + } + + #[test] + fn scope_restricts_to_project_ids_and_drops_projectless() { + let f = ListFilter { + scope: vec!["p1".into(), "p2".into()], + ..Default::default() + }; + let mut in1 = task("in1"); + in1.project_id = Some("p1".into()); + let mut other = task("other"); + other.project_id = Some("nope".into()); + let none = task("none"); + assert!(f.matches(&in1, NOW)); + assert!(!f.matches(&other, NOW)); + assert!(!f.matches(&none, NOW)); + } + + #[test] + fn exclude_drops_listed_projects_but_keeps_projectless() { + let f = ListFilter { + exclude_projects: vec!["chores".into()], + ..Default::default() + }; + let mut chore = task("chore"); + chore.project_id = Some("chores".into()); + let none = task("none"); + assert!(!f.matches(&chore, NOW)); + assert!(f.matches(&none, NOW)); + } + + #[test] + fn actionable_gate_drops_future_do_dates_only_when_set() { + let mut future = task("future"); + future.do_date = Some(NOW + 1); + let on = ListFilter { + actionable: true, + ..Default::default() + }; + let off = ListFilter::default(); + assert!(!on.matches(&future, NOW)); + assert!(off.matches(&future, NOW)); + // a do_date of exactly now is still actionable + let mut today = task("today"); + today.do_date = Some(NOW); + assert!(on.matches(&today, NOW)); + } + + #[test] + fn builtin_lookup() { + assert_eq!(builtin("tom").unwrap().title, "Top of Mind"); + assert_eq!(builtin("ondeck").unwrap().attention_in, &[Attention::Blue]); + assert!(builtin("nope").is_none()); + assert_eq!(BUILTIN_VIEWS.len(), 5); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index acf7fe7..73978e0 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -13,6 +13,7 @@ mod crdt; pub mod error; pub mod export; pub mod extract; +pub mod filter; pub mod hlc; pub mod model; pub mod oplog; @@ -25,6 +26,7 @@ pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; +pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS}; pub use hlc::{Hlc, HlcClock}; pub use model::{ deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index c5fa04d..26c78d9 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -191,3 +191,48 @@ pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result .optional()?; Ok(by_title) } + +/// Resolve a project **name** to its node id, restricted to `project`-kind +/// nodes (so a like-named task/doc never wins). `None` if no such project. +/// Used by filter-view scope/exclude resolution (tech-spec §8.2). +pub(super) fn resolve_project_id( + conn: &Connection, + owner: &str, + name: &str, +) -> Result<Option<String>> { + Ok(conn + .query_row( + "SELECT id FROM nodes + WHERE title = ?1 AND owner_id = ?2 AND kind = 'project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + (name, owner), + |r| r.get(0), + ) + .optional()?) +} + +/// Every project node id in the subtree rooted at `root` (inclusive): `root` +/// plus every project that reaches it through `parent` links (a child holds a +/// `parent` link to its parent, src=child → dst=parent). Powers the +/// project-subtree scope of filter views (tech-spec §8.2). Cycle-safe via the +/// visited set. +pub(super) fn project_subtree(conn: &Connection, root: &str) -> Result<Vec<String>> { + let mut out = vec![root.to_string()]; + let mut frontier = vec![root.to_string()]; + let mut stmt = conn.prepare( + "SELECT src_id FROM links + WHERE dst_id = ?1 AND type = 'parent' AND tombstoned = 0", + )?; + while let Some(parent) = frontier.pop() { + let children: Vec<String> = stmt + .query_map([&parent], |r| r.get(0))? + .collect::<rusqlite::Result<Vec<_>>>()?; + for child in children { + if !out.contains(&child) { + out.push(child.clone()); + frontier.push(child); + } + } + } + Ok(out) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 45c8c9e..04f7fa6 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -28,6 +28,7 @@ use ulid::Ulid; use crate::clock::Clock; use crate::error::{Error, Result}; +use crate::filter::ListFilter; use crate::hlc::Hlc; use crate::model::{ Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, @@ -260,13 +261,14 @@ impl Store for LocalStore { tasks::next(&self.conn, &self.owner_id, now, scope, limit) } - fn list( - &self, - scope: Option<&str>, - attention: Option<Attention>, - include_blue: bool, - ) -> Result<Vec<RankedTask>> { - tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue) + fn list(&self, filter: &ListFilter) -> Result<Vec<RankedTask>> { + let now = self.clock.now_ms(); + tasks::list(&self.conn, &self.owner_id, now, filter) + } + + fn view(&self, name: &str) -> Result<Vec<RankedTask>> { + let now = self.clock.now_ms(); + tasks::view(&self.conn, &self.owner_id, now, name) } fn health(&self) -> Result<Health> { diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index e108c94..9c18060 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -10,6 +10,7 @@ use serde_json::json; use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; use crate::extract; +use crate::filter::ListFilter; use crate::model::{ Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState, }; @@ -354,17 +355,16 @@ pub(super) fn next( Ok(ranking::rank(candidates, now, scope, limit)) } -/// Enumerate outstanding committed tasks for the Organizational view (the whole -/// set incl. backlog, tech-spec §6) as **titled rows** ([`RankedTask`] shape — -/// the same the plugin renders for `next`, so the survey view needs no N+1 -/// `node.get`). Optional `scope` (project) and `attention` filters; -/// `include_blue` keeps on-deck items (default true for `list`). +/// Enumerate outstanding committed tasks matching `filter` (tech-spec §8.2), +/// as **titled rows** ([`RankedTask`] shape — the same the plugin renders for +/// `next`, so the survey view needs no N+1 `node.get`). An empty +/// [`ListFilter`] yields the whole outstanding set (the Organizational survey, +/// tech-spec §6). `now` feeds the `actionable` do-date gate. pub(super) fn list( conn: &Connection, owner: &str, - scope: Option<&str>, - attention: Option<Attention>, - include_blue: bool, + now: i64, + filter: &ListFilter, ) -> Result<Vec<RankedTask>> { let sql = " SELECT n.id, n.title, n.created_at, n.tombstoned, @@ -384,24 +384,59 @@ pub(super) fn list( let mut out = Vec::new(); for row in rows { let task = row?; - if let Some(s) = scope { - if task.project_id.as_deref() != Some(s) { - continue; - } + if filter.matches(&task, now) { + out.push(task); } - if let Some(a) = attention { - if task.attention != Some(a) { - continue; - } - } - if !include_blue && task.attention == Some(Attention::Blue) { - continue; - } - out.push(task); } Ok(out) } +/// Run a built-in filter view by name (tech-spec §8.2): resolve its project +/// names to ids (each subtree-expanded), build the [`ListFilter`], and list. +/// A view whose `scope_names` all fail to resolve yields no rows (the projects +/// don't exist), rather than silently widening to "any project". +pub(super) fn view( + conn: &Connection, + owner: &str, + now: i64, + name: &str, +) -> Result<Vec<RankedTask>> { + let spec = crate::filter::builtin(name) + .ok_or_else(|| Error::InvalidArg(format!("unknown view {name:?}")))?; + + let scope = resolve_project_names(conn, owner, spec.scope_names)?; + if !spec.scope_names.is_empty() && scope.is_empty() { + return Ok(Vec::new()); + } + let exclude_projects = resolve_project_names(conn, owner, spec.exclude_names)?; + + let filter = ListFilter { + attention_in: spec.attention_in.to_vec(), + attention_not: spec.attention_not.to_vec(), + scope, + exclude_projects, + actionable: spec.actionable, + }; + list(conn, owner, now, &filter) +} + +/// Resolve project `names` to a deduped set of node ids, each expanded to its +/// project subtree. Names that don't resolve are skipped (a view tolerates a +/// store missing some of its projects). +fn resolve_project_names(conn: &Connection, owner: &str, names: &[&str]) -> Result<Vec<String>> { + let mut ids = Vec::new(); + for name in names { + if let Some(id) = links::resolve_project_id(conn, owner, name)? { + for sub in links::project_subtree(conn, &id)? { + if !ids.contains(&sub) { + ids.push(sub); + } + } + } + } + Ok(ids) +} + /// Working-set health counts (tech-spec §7) — surfaced honestly. pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> { let mut stmt = conn.prepare( diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 3a1d20c..fbd6aeb 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,6 +5,7 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; +use crate::filter::ListFilter; use crate::model::{ Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, SyncCursors, Task, TaskState, @@ -99,16 +100,18 @@ pub trait Store { /// node id; `red` items always appear regardless of `limit`. fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>>; - /// Enumerate outstanding committed tasks for the Organizational view — the - /// whole set incl. backlog (tech-spec §6), as **titled** [`RankedTask`] rows - /// (the same shape `next` returns, so the survey view needs no N+1 - /// `get_node`). `include_blue` keeps on-deck. - fn list( - &self, - scope: Option<&str>, - attention: Option<Attention>, - include_blue: bool, - ) -> Result<Vec<RankedTask>>; + /// Enumerate outstanding committed tasks matching a [`ListFilter`] — the + /// data-expressed predicate behind filter views (tech-spec §8.2). Returns + /// **titled** [`RankedTask`] rows (the same shape `next` returns, so the + /// survey view needs no N+1 `get_node`). An empty filter is the whole + /// outstanding set (the Organizational survey, tech-spec §6). + fn list(&self, filter: &ListFilter) -> Result<Vec<RankedTask>>; + + /// Run a built-in named filter view (`tom|ondeck|chores|work|tasks`, + /// tech-spec §8.2): resolve its project names to ids (subtree-expanded), + /// build the [`ListFilter`], and return the matching rows. Errors on an + /// unknown view name. + fn view(&self, name: &str) -> Result<Vec<RankedTask>>; /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result<Health>; diff --git a/crates/heph-core/tests/query_surface.rs b/crates/heph-core/tests/query_surface.rs index a09493a..f540252 100644 --- a/crates/heph-core/tests/query_surface.rs +++ b/crates/heph-core/tests/query_surface.rs @@ -1,6 +1,6 @@ //! list / health / journal — the Organizational + working-set surface (§6, §7). -use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState}; +use heph_core::{Attention, FixedClock, ListFilter, LocalStore, NewTask, Store, TaskState}; fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() @@ -25,7 +25,7 @@ fn list_enumerates_outstanding_including_blue_by_default() { s.set_task_state(&done, TaskState::Done).unwrap(); // Default list: outstanding only, blue included; done excluded. - let all = s.list(None, None, true).unwrap(); + let all = s.list(&ListFilter::default()).unwrap(); let titles: Vec<_> = all.iter().map(|t| t.attention.unwrap()).collect::<Vec<_>>(); assert_eq!(all.len(), 2); assert!(titles.contains(&Attention::White)); @@ -40,11 +40,16 @@ fn list_can_exclude_blue_and_filter_by_attention() { task(&mut s, "orange1", Attention::Orange); task(&mut s, "orange2", Attention::Orange); - assert_eq!(s.list(None, None, false).unwrap().len(), 3); // blue excluded - assert_eq!( - s.list(None, Some(Attention::Orange), true).unwrap().len(), - 2 - ); + let no_blue = ListFilter { + attention_not: vec![Attention::Blue], + ..Default::default() + }; + assert_eq!(s.list(&no_blue).unwrap().len(), 3); // blue excluded + let only_orange = ListFilter { + attention_in: vec![Attention::Orange], + ..Default::default() + }; + assert_eq!(s.list(&only_orange).unwrap().len(), 2); } #[test] @@ -54,7 +59,7 @@ fn list_rows_carry_title_and_canonical_context() { // The Organizational view needs titles + the one-keystroke context jump // without an N+1 node.get (tech-spec §6, §8). - let rows = s.list(None, None, true).unwrap(); + let rows = s.list(&ListFilter::default()).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].node_id, id); assert_eq!(rows[0].title, "Buy milk"); @@ -80,8 +85,11 @@ fn list_scopes_to_a_project() { .unwrap(); task(&mut s, "life task", Attention::White); - let scoped = s.list(Some(&project.id), None, true).unwrap(); - assert_eq!(scoped.len(), 1); + let scoped = ListFilter { + scope: vec![project.id.clone()], + ..Default::default() + }; + assert_eq!(s.list(&scoped).unwrap().len(), 1); } #[test] diff --git a/crates/heph-core/tests/views.rs b/crates/heph-core/tests/views.rs new file mode 100644 index 0000000..cdbd12b --- /dev/null +++ b/crates/heph-core/tests/views.rs @@ -0,0 +1,161 @@ +//! Filter views — the five built-in saved agenda slices (tech-spec §8.2). +//! +//! Builds a store mirroring the owner's project shape (Work + a Work subproject, +//! Chores / Camano Chores, the routine projects) with tasks across the attention +//! bands, then asserts each `view` returns exactly the right slice — including +//! project-subtree scope/exclude and the `actionable` do-date gate. + +use heph_core::{Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store}; + +const NOW: i64 = 1_700_000_000_000; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(NOW))).unwrap() +} + +fn project(s: &mut LocalStore, title: &str) -> String { + s.create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id +} + +/// Capture a task with an attention, optional project, and optional do_date. +fn task( + s: &mut LocalStore, + title: &str, + attention: Attention, + project_id: Option<&str>, + do_date: Option<i64>, +) -> String { + s.create_task(NewTask { + title: title.into(), + attention: Some(attention), + project_id: project_id.map(str::to_string), + do_date, + ..Default::default() + }) + .unwrap() + .node_id +} + +fn titles(rows: &[heph_core::RankedTask]) -> Vec<String> { + let mut t: Vec<String> = rows.iter().map(|r| r.title.clone()).collect(); + t.sort(); + t +} + +/// A store with the full project shape and one task per interesting case. +fn seeded() -> LocalStore { + let mut s = store(); + let work = project(&mut s, "Work"); + let work_sub = project(&mut s, "Work Sub"); + s.add_link(&work_sub, &work, LinkType::Parent).unwrap(); // child → parent + let chores = project(&mut s, "Chores"); + let camano = project(&mut s, "Camano Chores"); + let work_routine = project(&mut s, "Work Routine"); + project(&mut s, "Daily Routine"); + + task(&mut s, "red", Attention::Red, None, None); + task(&mut s, "orange", Attention::Orange, None, None); + task(&mut s, "white", Attention::White, None, None); + task(&mut s, "blue", Attention::Blue, None, None); + task(&mut s, "work task", Attention::White, Some(&work), None); + task( + &mut s, + "work sub task", + Attention::White, + Some(&work_sub), + None, + ); + task(&mut s, "chore", Attention::White, Some(&chores), None); + task( + &mut s, + "camano chore", + Attention::Orange, + Some(&camano), + None, + ); + task( + &mut s, + "routine", + Attention::White, + Some(&work_routine), + None, + ); + // A red task whose do_date is in the future — excluded by the actionable gate. + task( + &mut s, + "future red", + Attention::Red, + None, + Some(NOW + 86_400_000), + ); + s +} + +#[test] +fn top_of_mind_is_red_and_orange_and_actionable() { + let s = seeded(); + // Every red/orange task across all projects (the orange Camano chore counts + // too — ToM has no project scope), but NOT the future-dated red (actionable + // gate). + assert_eq!( + titles(&s.view("tom").unwrap()), + vec!["camano chore", "orange", "red"] + ); +} + +#[test] +fn on_deck_is_blue_only() { + let s = seeded(); + assert_eq!(titles(&s.view("ondeck").unwrap()), vec!["blue"]); +} + +#[test] +fn chores_scopes_to_the_two_chore_projects() { + let s = seeded(); + assert_eq!( + titles(&s.view("chores").unwrap()), + vec!["camano chore", "chore"] + ); +} + +#[test] +fn work_scopes_to_the_work_subtree() { + let s = seeded(); + // Both the direct Work task and the Work-Sub task (subtree expansion). + assert_eq!( + titles(&s.view("work").unwrap()), + vec!["work sub task", "work task"] + ); +} + +#[test] +fn tasks_excludes_routine_chore_and_work_subtree_projects() { + let s = seeded(); + // Non-blue, actionable, and not in any excluded project (incl. the Work + // subtree) → just the three project-less, present-dated tasks. + assert_eq!( + titles(&s.view("tasks").unwrap()), + vec!["orange", "red", "white"] + ); +} + +#[test] +fn unknown_view_is_an_error() { + let s = store(); + assert!(s.view("nope").is_err()); +} + +#[test] +fn scoped_view_is_empty_when_its_projects_are_absent() { + // A store with no Chores/Camano projects: the chores view scopes to nothing, + // and must return empty rather than widening to "any project". + let mut s = store(); + task(&mut s, "loose", Attention::White, None, None); + assert!(s.view("chores").unwrap().is_empty()); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 257f637..02dbb6d 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -76,6 +76,11 @@ enum Command { #[arg(long)] no_blue: bool, }, + /// Run a built-in filter view (tech-spec §8.2); omit the name to list views. + View { + /// View name: tom|ondeck|chores|work|tasks. Omit to list all views. + name: Option<String>, + }, /// Mark a task done (recurring tasks roll forward). Done { /// Task node id. @@ -397,12 +402,34 @@ fn main() -> Result<()> { attention, no_blue, } => { - let result = client.call( - "list", - json!({ "scope": scope, "attention": attention, "include_blue": !no_blue }), - )?; + // `list` takes a ListFilter (tech-spec §8.2). Map the legacy flags: + // a single `--scope` id, a single `--attention` whitelist, and + // `--no-blue` as an attention exclusion. + let mut filter = json!({}); + if let Some(s) = scope { + filter["scope"] = json!([s]); + } + if let Some(a) = attention { + filter["attention_in"] = json!([a]); + } + if no_blue { + filter["attention_not"] = json!(["blue"]); + } + let result = client.call("list", filter)?; print_rows(result)?; } + Command::View { name } => match name { + Some(name) => { + let result = client.call("view", json!({ "name": name }))?; + print_rows(result)?; + } + None => { + println!("Available views (heph view <name>):"); + for v in heph_core::BUILTIN_VIEWS { + println!(" {:<8} {}", v.name, v.title); + } + } + }, Command::Done { id } => { set_state(&mut client, &id, "done")?; } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 550faba..959e6a3 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -17,8 +17,8 @@ use serde::de::DeserializeOwned; use serde_json::{json, Value}; use heph_core::{ - Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Result, - SchedulePatch, Store, SyncCursors, Task, TaskState, + Attention, Conflict, Error, Health, Link, LinkType, ListFilter, NewNode, NewTask, Node, + NodeKind, Result, SchedulePatch, Store, SyncCursors, Task, TaskState, }; use crate::oauth::{self, TokenStore}; @@ -189,16 +189,12 @@ impl Store for RemoteStore { self.call_as("next", json!({ "scope": scope, "limit": limit })) } - fn list( - &self, - scope: Option<&str>, - attention: Option<Attention>, - include_blue: bool, - ) -> Result<Vec<heph_core::RankedTask>> { - self.call_as( - "list", - json!({ "scope": scope, "attention": attention, "include_blue": include_blue }), - ) + fn list(&self, filter: &ListFilter) -> Result<Vec<heph_core::RankedTask>> { + self.call_as("list", json!(filter)) + } + + fn view(&self, name: &str) -> Result<Vec<heph_core::RankedTask>> { + self.call_as("view", json!({ "name": name })) } fn health(&self) -> Result<Health> { diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index cbd8320..fa7d160 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,9 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, LinkType, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState}; +use heph_core::{ + Attention, LinkType, ListFilter, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState, +}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -159,19 +161,12 @@ struct NextParams { limit: Option<usize>, } -#[derive(Deserialize)] -struct ListParams { - #[serde(default)] - scope: Option<String>, - #[serde(default)] - attention: Option<Attention>, - /// Keep on-deck (blue) items; defaults to true for the survey view. - #[serde(default = "default_true")] - include_blue: bool, -} +/// `list` takes a [`ListFilter`] directly as its params (tech-spec §8.2); an +/// empty object is the whole outstanding set. -fn default_true() -> bool { - true +#[derive(Deserialize)] +struct ViewParams { + name: String, } #[derive(Deserialize)] @@ -291,8 +286,12 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?) } "list" => { - let p: ListParams = parse(params)?; - json!(store.list(p.scope.as_deref(), p.attention, p.include_blue)?) + let filter: ListFilter = parse(params)?; + json!(store.list(&filter)?) + } + "view" => { + let p: ViewParams = parse(params)?; + json!(store.view(&p.name)?) } "health" => json!(store.health()?), "search" => { diff --git a/crates/hephd/tests/client_mode.rs b/crates/hephd/tests/client_mode.rs index 15707a2..f8fd3d7 100644 --- a/crates/hephd/tests/client_mode.rs +++ b/crates/hephd/tests/client_mode.rs @@ -77,7 +77,7 @@ fn remote_store_proxies_the_store_api() { .unwrap() .expect("task on server"); assert_eq!(fetched.node_id, task.node_id); - let listed = remote.list(None, None, true).unwrap(); + let listed = remote.list(&heph_core::ListFilter::default()).unwrap(); assert!( listed.iter().any(|t| t.node_id == task.node_id), "task missing from list" diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 62cbb27..1c5d122 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -309,3 +309,41 @@ fn multiple_clients_concurrently_create_tasks() { let ranked = c.call("next", json!({ "limit": 100 })).unwrap(); assert_eq!(ranked.as_array().unwrap().len(), N); } + +#[test] +fn list_takes_a_filter_and_view_runs_a_builtin_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + c.call( + "task.create", + json!({ "title": "red task", "attention": "red" }), + ) + .unwrap(); + c.call( + "task.create", + json!({ "title": "blue task", "attention": "blue" }), + ) + .unwrap(); + + // An empty filter is the whole outstanding set (both tasks). + let all = c.call("list", json!({})).unwrap(); + assert_eq!(all.as_array().unwrap().len(), 2); + + // A filter excluding blue drops the on-deck task. + let no_blue = c + .call("list", json!({ "attention_not": ["blue"] })) + .unwrap(); + let arr = no_blue.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["title"], "red task"); + + // The Top of Mind view (red|orange) returns just the red task. + let tom = c.call("view", json!({ "name": "tom" })).unwrap(); + let arr = tom.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["title"], "red task"); + + // An unknown view name is a reported RPC error. + assert!(c.call("view", json!({ "name": "bogus" })).is_err()); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index aca928b..9ccf385 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -22,3 +22,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). +- Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index db4b19b..e28ddaf 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -72,6 +72,7 @@ edits, else adding the `wiki` link directly). | `:Heph search <query>` | Full-text search; pick a result to open | | `:Heph next [scope]` | Tactical "what is next?" view (`<CR>` opens a task's context) | | `:Heph list [attention]` | Organizational survey of the outstanding set | +| `:Heph view <name>` | Run a built-in filter view (`tom\|ondeck\|chores\|work\|tasks`, tech-spec §8.2) | | `:Heph capture <title>` | Capture a committed task (pick attention) | | `:Heph attention [color]` | Set the current task's attention | | `:Heph done` / `:Heph drop` / `:Heph skip` | State change on the current task | diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 855cc7a..1c3d652 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -195,7 +195,8 @@ Methods (request → response; errors are JSON-RPC errors). Signatures are indic - `task.set_attention({id, attention}) → Task` - `task.promote({container_id, item_ref, attention?, project?}) → Task` (mints a committed task from a context-item line and rewrites that line into a link to it, §4.3) - `next({scope?, limit?}) → [RankedTask]` (the Tactical blank-slate "what is next?" ranking, §7) -- `list({scope?, attention?, include_blue?, include_future?, group_by?}) → [Task]` (enumeration for the Organizational view — the whole set incl. backlog) +- `list(ListFilter) → [RankedTask]` (the §8.2 predicate-as-data: `{attention_in?, attention_not?, scope?, exclude_projects?, actionable?}`; an empty filter is the whole outstanding set — the Organizational survey) +- `view({name}) → [RankedTask]` (run a built-in filter view `tom|ondeck|chores|work|tasks`, §8.2 — resolves project names→ids+subtree and lists) - `search({query, filters?}) → [Node]` (FTS) - `links.outgoing(id) → [Link]` / `links.backlinks(id) → [Link]` - `journal.open_or_create(date) → Node` @@ -258,9 +259,11 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Testing** — TDD against a real daemon; headless smoke via `ratatui`'s `TestBackend`. - **Prereqs** (land first): **§8.2 filter views** (the TUI's saved-filter pane is just those views); the CLI-complete task surface and `task.set_schedule` (done). -## 8.2 Filter views (saved agenda slices) — planned, the next slice +## 8.2 Filter views (saved agenda slices) — built -> **Status: planned, the next slice.** [[design]] §6.2 / §6.2.1 establish that the owner navigates work through a fixed set of **saved filters**, not one flat list — so `next` alone is too coarse. This slice makes those filters first-class, shared by the CLI now and the TUI (§8.1) next. (The reference-context noise those filters excluded — `##Culture`, `#Camano Info` — has already been reclassified out of tasks into wiki docs, [[design]] §6.2.1.) +> **Status: built.** [[design]] §6.2 / §6.2.1 establish that the owner navigates work through a fixed set of **saved filters**, not one flat list — so `next` alone is too coarse. This slice made those filters first-class, shared by the CLI now and the TUI (§8.1) next. (The reference-context noise those filters excluded — `##Culture`, `#Camano Info` — has already been reclassified out of tasks into wiki docs, [[design]] §6.2.1.) +> +> Implemented as a `ListFilter` **predicate-as-data** (`heph-core::filter`): `list` takes a `ListFilter` (attention include/exclude sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate); `Store::view(name)` resolves a built-in [`ViewSpec`] — looking project **names** up to ids and **subtree-expanding** them through `parent` links — then runs `list`. Surfaced as `heph view <name>` (no name lists the five), the `view` RPC, and `:Heph view <name>` in nvim. **The five built-in views** (the owner's sixth Todoist filter, **Schedule**, is intentionally dropped — see below), each derived from the verbatim Todoist query ([[design]] §6.2.1) and realized in heph terms (attention: p1→red, p2→orange, p4→white, p3→blue): @@ -274,7 +277,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba **Engine work — extend `list` (§6) so a view is a *predicate expressed as data*** (mirroring §7's "order as data"): -- `attention`: a **set** of states (was a single value) — e.g. `{red,orange}`. +- `attention`: an **include set** (`attention_in`, e.g. `{red,orange}`) *and* an **exclude set** (`attention_not`, e.g. `{blue}` for "≠ blue"). The split matters: a whitelist drops attention-less tasks, but "≠ blue" must keep them — so Work/Tasks use `attention_not`, ToM/On Deck use `attention_in`. - `scope`: a project **including its descendant projects** (subtree, for `##Culture` / Work-tree), and/or **multiple** projects (Chores + Camano Chores). - `exclude_projects`: a list subtracted from the result (the "Tasks" leftover view). - `actionable`: a bool toggle applying the §7 do-date candidacy gate inside `list` (today the gate is `next`-only). @@ -377,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-02 — **117 Rust tests** (`cargo test --all`) + **17 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **154 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** @@ -406,19 +409,19 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - **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. - **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI. +- ✅ **Filter views (§8.2) — saved agenda slices:** the owner's saved filters are first-class so the agenda isn't one flat list. New **`heph-core::filter`**: a `ListFilter` **predicate-as-data** (`attention_in`/`attention_not` sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate) + the five built-in [`ViewSpec`]s (Top of Mind / On Deck / Chores / Work Tasks / Tasks — **Schedule dropped**, §8.2). `Store::list` now takes a `ListFilter`; new `Store::view(name)` resolves a spec's project **names** → ids and **subtree-expands** them through `parent` links (`links::project_subtree`/`resolve_project_id`), tolerating absent projects (a scoped view whose projects are all missing returns empty, never widens). Surfaces: `heph view <name>` (no name lists the five), the **`view` RPC** + `RemoteStore` forward, and `:Heph view <name>` in nvim (`heph://view/<name>` buffers). The `list` RPC/`RemoteStore`/CLI/`heph.nvim` migrated to the filter wire (legacy `--scope`/`--attention`/`--no-blue` map onto it). Tested: `filter` unit predicate, a `views` integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket `list`/`view` dispatch test, and two nvim e2e specs. **Not yet done (resume order)** -> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), and the live store has been seeded from Todoist with reference contexts reclassified to wiki docs ([[design]] §6.2.1). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (to build), **nvim = context/KB**. Remaining work, in order: +> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist with reference contexts reclassified to wiki docs ([[design]] §6.2.1), and **filter views (§8.2) are built** (`heph view`). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (next big build), **nvim = context/KB**. Remaining work, in order: -1. ⏳ **Filter views (§8.2) — the next slice:** make the owner's saved filters (Top of Mind / Tasks / Work Tasks / Chores / On Deck — **Schedule dropped**, §8.2) first-class so the agenda isn't one flat list. Extend `list` (§6) to a data-expressed predicate — **attention set**, **project-subtree / multi scope**, **exclude-projects**, **actionable toggle** (+ parent-project link resolution) — and surface five built-in views via `heph view <name>` (the TUI reuses them). Seeded from the verbatim Todoist queries ([[design]] §6.2.1). *(Future, noted: chores become a first-class kind with their own do-date/recurrence semantics, retiring the Chores/Camano-Chores projects — §8.2.)* -2. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; the §8.2 filter views as the saved-filter pane; launches into nvim for context and back. **Depends on the filter-views slice.** -3. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). -4. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduces) is a refinement. -5. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -6. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). -7. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -8. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; the §8.2 filter views as the saved-filter pane (the `view` RPC is ready); launches into nvim for context and back. **Filter-views prereq is now done.** +2. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). +3. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduced) is a refinement. +4. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +5. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). +6. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +7. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index af63122..774c58e 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -65,6 +65,14 @@ M.subs = { list = function(args) require("heph.view").list({ attention = args[1] }) end, + view = function(args) + local name = args[1] + if not name then + require("heph.util").notify("usage: :Heph view <tom|ondeck|chores|work|tasks>", vim.log.levels.WARN) + return + end + require("heph.view").view(name) + end, capture = function(args) local title = table.concat(args, " ") if #title == 0 then diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua index 5c1063f..fbc10fa 100644 --- a/heph.nvim/lua/heph/view.lua +++ b/heph.nvim/lua/heph/view.lua @@ -144,17 +144,35 @@ function M.next(opts) end --- Organizational survey — render the outstanding set, return the rows. +--- `list` takes a ListFilter (tech-spec §8.2); an empty table is the whole +--- outstanding set. Legacy opts map onto the filter fields. function M.list(opts) opts = opts or {} - local tasks = rpc.call("list", { - scope = opts.scope, - attention = opts.attention, - include_blue = opts.include_blue ~= false, - }) + local filter = {} + if opts.scope then + filter.scope = { opts.scope } + end + if opts.attention then + filter.attention_in = { opts.attention } + end + if opts.include_blue == false then + filter.attention_not = { "blue" } + end + local tasks = rpc.call("list", filter) render("heph://list", tasks, function() M.list(opts) end) return tasks end +--- A built-in filter view (tech-spec §8.2) — render its rows like `list`. +function M.view(name, opts) + opts = opts or {} + local tasks = rpc.call("view", { name = name }) + render("heph://view/" .. name, tasks, function() + M.view(name, opts) + end) + return tasks +end + return M diff --git a/heph.nvim/tests/e2e/view_spec.lua b/heph.nvim/tests/e2e/view_spec.lua new file mode 100644 index 0000000..fc88358 --- /dev/null +++ b/heph.nvim/tests/e2e/view_spec.lua @@ -0,0 +1,45 @@ +-- Filter views (tech-spec §8.2): `:Heph view <name>` renders a built-in slice. + +local h = require("e2e.helpers") + +describe("filter views", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("renders the Top of Mind view (red|orange, not blue)", function() + ctx.q:call("task.create", { title = "urgent thing", attention = "red" }) + ctx.q:call("task.create", { title = "warm thing", attention = "orange" }) + ctx.q:call("task.create", { title = "cool thing", attention = "blue" }) + + -- The backend view returns just the red + orange tasks. + local rows = ctx.q:call("view", { name = "tom" }) + assert.are.equal(2, #rows) + + -- The plugin renders them into a dedicated view buffer. + require("heph.view").view("tom") + local buf = vim.api.nvim_get_current_buf() + assert.is_truthy( + vim.api.nvim_buf_get_name(buf):find("heph://view/tom", 1, true), + "view buffer not named heph://view/tom" + ) + local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + assert.is_truthy(text:find("urgent thing", 1, true), "red task missing from ToM") + assert.is_truthy(text:find("warm thing", 1, true), "orange task missing from ToM") + assert.is_falsy(text:find("cool thing", 1, true), "blue task should not be in ToM") + end) + + it("scopes the chores view to chore projects via the daemon", function() + local chores = ctx.q:call("node.create", { kind = "project", title = "Chores" }) + ctx.q:call("task.create", { title = "take out trash", attention = "white", project_id = chores.id }) + ctx.q:call("task.create", { title = "unrelated", attention = "white" }) + + local rows = ctx.q:call("view", { name = "chores" }) + assert.are.equal(1, #rows) + assert.are.equal("take out trash", rows[1].title) + end) +end) -- 2.50.1 (Apple Git-155) From a21f9e575b99d7f76ec000f1af0ae058870d1478 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:06:48 -0700 Subject: [PATCH 46/91] =?UTF-8?q?feat(tui):=20heph-tui=20T1=20=E2=80=94=20?= =?UTF-8?q?read-only=203-pane=20agenda=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate crates/heph-tui: a ratatui terminal agenda, thin client of the hephd unix socket (never touches SQLite, same as heph.nvim). The next big surface — the interactive triage UI the §6.2.1 Todoist study calls for. - 3-pane layout: sidebar (the five §8.2 filter views + projects), task list (attention-colored rows with compact human do/late dates), and a preview pane (the highlighted task's canonical-context doc body + log tail). - App state is generic over a `Backend` seam, so navigation/selection logic is unit-testable without a terminal or daemon; `ClientBackend` forwards to the socket. Rendering is a pure `ui::render(frame, &app)`. - Navigation: j/k within the focused pane, Tab / h / l to move focus, selecting a sidebar source reloads the list, moving the task cursor refreshes the preview. r refresh, q quit. - Socket resolution: --socket flag, then $HEPH_SOCKET, then the standard runtime path (the TUI honors the env var the CLI doesn't). Tests: a headless TestBackend render against a real spawned daemon (asserts views/projects/tasks/preview paint, and Top of Mind excludes blue), plus in-memory navigation unit tests. 8 heph-tui tests; clippy/fmt clean. Mutations (add/done/attention/reschedule/blue) + nvim handoff land in T2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- Cargo.lock | 296 ++++++++++++++++++++++++++++ Cargo.toml | 3 +- crates/heph-tui/Cargo.toml | 31 +++ crates/heph-tui/src/app.rs | 227 +++++++++++++++++++++ crates/heph-tui/src/backend.rs | 159 +++++++++++++++ crates/heph-tui/src/fmt.rs | 52 +++++ crates/heph-tui/src/lib.rs | 15 ++ crates/heph-tui/src/main.rs | 95 +++++++++ crates/heph-tui/src/ui.rs | 216 ++++++++++++++++++++ crates/heph-tui/tests/agenda.rs | 134 +++++++++++++ crates/heph-tui/tests/navigation.rs | 149 ++++++++++++++ 11 files changed, 1376 insertions(+), 1 deletion(-) create mode 100644 crates/heph-tui/Cargo.toml create mode 100644 crates/heph-tui/src/app.rs create mode 100644 crates/heph-tui/src/backend.rs create mode 100644 crates/heph-tui/src/fmt.rs create mode 100644 crates/heph-tui/src/lib.rs create mode 100644 crates/heph-tui/src/main.rs create mode 100644 crates/heph-tui/src/ui.rs create mode 100644 crates/heph-tui/tests/agenda.rs create mode 100644 crates/heph-tui/tests/navigation.rs diff --git a/Cargo.lock b/Cargo.lock index 0f455f8..c34e345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -377,6 +383,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -499,6 +520,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -593,6 +628,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -642,6 +702,40 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -776,6 +870,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1106,6 +1206,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1161,6 +1263,22 @@ dependencies = [ "yrs", ] +[[package]] +name = "heph-tui" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "heph-core", + "hephd", + "ratatui", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "hephd" version = "0.0.0" @@ -1417,6 +1535,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1450,6 +1574,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1460,6 +1593,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1472,6 +1618,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1616,6 +1771,15 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1669,6 +1833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1890,6 +2055,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1912,6 +2087,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -2219,6 +2400,27 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2650,6 +2852,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2759,6 +2982,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3114,6 +3359,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3366,6 +3640,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 54f59a4..be3bb8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/heph-core", "crates/hephd", "crates/heph"] +members = ["crates/heph-core", "crates/hephd", "crates/heph", "crates/heph-tui"] [workspace.package] edition = "2021" @@ -32,6 +32,7 @@ tokio = { version = "1", features = [ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4", features = ["derive"] } +ratatui = "0.29" fs4 = "0.12" axum = "0.8" jsonwebtoken = { version = "10", features = ["rust_crypto"] } diff --git a/crates/heph-tui/Cargo.toml b/crates/heph-tui/Cargo.toml new file mode 100644 index 0000000..0224c96 --- /dev/null +++ b/crates/heph-tui/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "heph-tui" +description = "Hephaestus terminal UI: the task agenda/triage surface (a thin client of the local hephd daemon)." +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "heph-tui" +path = "src/main.rs" + +[lib] +name = "heph_tui" +path = "src/lib.rs" + +[dependencies] +heph-core = { path = "../heph-core" } +hephd = { path = "../hephd" } +ratatui.workspace = true +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +clap.workspace = true + +[dev-dependencies] +tempfile = "3" +tokio.workspace = true diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs new file mode 100644 index 0000000..6ef3d12 --- /dev/null +++ b/crates/heph-tui/src/app.rs @@ -0,0 +1,227 @@ +//! The agenda app state + interaction logic (tech-spec §8.1), generic over a +//! [`Backend`] so it is testable without a terminal or a daemon. Rendering +//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. + +use anyhow::Result; +use heph_core::{RankedTask, BUILTIN_VIEWS}; + +use crate::backend::{Backend, Project}; + +/// Which pane has the keyboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + Tasks, +} + +/// One row of the left sidebar. `Header` rows are labels and are skipped by the +/// cursor; the others are selectable sources for the task list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebarEntry { + Header(String), + View { name: String, title: String }, + Project { id: String, title: String }, +} + +impl SidebarEntry { + fn selectable(&self) -> bool { + !matches!(self, SidebarEntry::Header(_)) + } +} + +/// The selected sidebar source, as owned values (so reloads don't hold a borrow +/// on `self.sidebar` while calling the backend). +enum Target { + View(String), + Project(String), +} + +/// The right-pane preview of the highlighted task: its canonical-context doc +/// body plus the tail of its log. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Preview { + pub title: String, + pub body: Vec<String>, + pub log: Vec<String>, +} + +/// The whole TUI state. +pub struct App<B: Backend> { + backend: B, + pub sidebar: Vec<SidebarEntry>, + pub sidebar_cursor: usize, + pub tasks: Vec<RankedTask>, + pub task_cursor: usize, + pub preview: Preview, + pub focus: Focus, + pub status: String, + pub should_quit: bool, +} + +const PREVIEW_LOG_LINES: usize = 5; + +impl<B: Backend> App<B> { + /// Build the sidebar (built-in views + projects), select the first view, and + /// load its tasks + the first task's preview. + pub fn new(mut backend: B) -> Result<Self> { + let mut sidebar = vec![SidebarEntry::Header("Views".into())]; + for v in BUILTIN_VIEWS { + sidebar.push(SidebarEntry::View { + name: v.name.into(), + title: v.title.into(), + }); + } + sidebar.push(SidebarEntry::Header("Projects".into())); + for Project { id, title } in backend.projects()? { + sidebar.push(SidebarEntry::Project { id, title }); + } + + let sidebar_cursor = sidebar + .iter() + .position(SidebarEntry::selectable) + .unwrap_or(0); + + let mut app = App { + backend, + sidebar, + sidebar_cursor, + tasks: Vec::new(), + task_cursor: 0, + preview: Preview::default(), + focus: Focus::Sidebar, + status: String::new(), + should_quit: false, + }; + app.reload(); + Ok(app) + } + + /// The title shown above the task list (the selected source). + pub fn task_pane_title(&self) -> String { + match self.sidebar.get(self.sidebar_cursor) { + Some(SidebarEntry::View { title, .. }) => title.clone(), + Some(SidebarEntry::Project { title, .. }) => title.clone(), + _ => "Tasks".into(), + } + } + + /// The highlighted task, if any. + pub fn selected_task(&self) -> Option<&RankedTask> { + self.tasks.get(self.task_cursor) + } + + fn current_target(&self) -> Option<Target> { + match self.sidebar.get(self.sidebar_cursor)? { + SidebarEntry::View { name, .. } => Some(Target::View(name.clone())), + SidebarEntry::Project { id, .. } => Some(Target::Project(id.clone())), + SidebarEntry::Header(_) => None, + } + } + + /// Reload the task list for the current sidebar selection, then the preview. + /// Errors surface in the status line rather than crashing the UI. + pub fn reload(&mut self) { + match self.load_tasks() { + Ok(tasks) => { + self.tasks = tasks; + if self.task_cursor >= self.tasks.len() { + self.task_cursor = self.tasks.len().saturating_sub(1); + } + } + Err(e) => self.status = format!("error: {e}"), + } + self.reload_preview(); + } + + fn load_tasks(&mut self) -> Result<Vec<RankedTask>> { + match self.current_target() { + Some(Target::View(name)) => self.backend.view(&name), + Some(Target::Project(id)) => { + let filter = heph_core::ListFilter { + scope: vec![id], + ..Default::default() + }; + self.backend.list(&filter) + } + None => Ok(Vec::new()), + } + } + + fn reload_preview(&mut self) { + let Some(task) = self.selected_task().cloned() else { + self.preview = Preview::default(); + return; + }; + let mut preview = Preview { + title: task.title.clone(), + ..Default::default() + }; + if let Some(ctx) = &task.canonical_context_id { + match self.backend.node_body(ctx) { + Ok(body) => preview.body = body.lines().map(str::to_string).collect(), + Err(e) => preview.body = vec![format!("(preview error: {e})")], + } + } + if let Ok(log) = self.backend.log_tail(&task.node_id, PREVIEW_LOG_LINES) { + preview.log = log; + } + self.preview = preview; + } + + // --- navigation --- + + /// Move the sidebar cursor by `delta` rows, skipping headers, reloading the + /// task list for the new selection. + pub fn move_sidebar(&mut self, delta: isize) { + let n = self.sidebar.len(); + let mut i = self.sidebar_cursor as isize; + loop { + i += delta; + if i < 0 || i >= n as isize { + return; // off the end — leave selection unchanged + } + if self.sidebar[i as usize].selectable() { + break; + } + } + self.sidebar_cursor = i as usize; + self.task_cursor = 0; + self.reload(); + } + + /// Move the task cursor by `delta`, clamped, refreshing the preview. + pub fn move_task(&mut self, delta: isize) { + if self.tasks.is_empty() { + return; + } + let max = self.tasks.len() as isize - 1; + let i = (self.task_cursor as isize + delta).clamp(0, max); + self.task_cursor = i as usize; + self.reload_preview(); + } + + pub fn focus_sidebar(&mut self) { + self.focus = Focus::Sidebar; + } + + pub fn focus_tasks(&mut self) { + if !self.tasks.is_empty() { + self.focus = Focus::Tasks; + } + } + + pub fn toggle_focus(&mut self) { + self.focus = match self.focus { + Focus::Sidebar => Focus::Tasks, + Focus::Tasks => Focus::Sidebar, + }; + } + + /// Run `f` against the backend; any error lands in the status line. Used by + /// mutation gestures (T2). + pub fn try_mutate(&mut self, f: impl FnOnce(&mut B) -> Result<()>) { + if let Err(e) = f(&mut self.backend) { + self.status = format!("error: {e}"); + } + } +} diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs new file mode 100644 index 0000000..223e9b2 --- /dev/null +++ b/crates/heph-tui/src/backend.rs @@ -0,0 +1,159 @@ +//! The data seam between the TUI and the daemon (tech-spec §8.1). +//! +//! [`Backend`] is the small set of reads/writes the agenda needs; the App is +//! generic over it, so navigation/triage logic is unit-testable against a fake +//! and the real surface ([`ClientBackend`]) just forwards to the `hephd` unix +//! socket — the TUI never touches SQLite, same as `heph.nvim`. + +use anyhow::{Context, Result}; +use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch}; +use hephd::Client; +use serde_json::{json, Value}; + +/// A project node, as the sidebar lists it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + pub id: String, + pub title: String, +} + +/// Everything the agenda surface asks of the daemon. +pub trait Backend { + /// All project nodes (for the sidebar), title-sorted. + fn projects(&mut self) -> Result<Vec<Project>>; + /// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2). + fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>; + /// Run a raw [`ListFilter`] (used for per-project scope). + fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>>; + /// A node's markdown body (the canonical-context doc preview). Empty if the + /// node is bodiless or missing. + fn node_body(&mut self, id: &str) -> Result<String>; + /// The last `n` log lines for a task (the resumption breadcrumb). + fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>; + + // --- triage mutations (T2) --- + + /// Set a task's lifecycle state (`done` rolls a recurring task forward). + fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>; + /// Skip a recurring task to its next occurrence (no completion logged). + fn skip(&mut self, task_id: &str) -> Result<()>; + /// Set a task's attention band. + fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>; + /// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option. + fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>; + /// Capture a committed task; returns its node id. + fn create_task( + &mut self, + title: &str, + attention: Option<Attention>, + do_date: Option<i64>, + project_id: Option<&str>, + ) -> Result<String>; +} + +/// The real backend: a thin client of the `hephd` unix socket. +pub struct ClientBackend { + client: Client, +} + +impl ClientBackend { + pub fn new(client: Client) -> Self { + Self { client } + } + + fn call(&mut self, method: &str, params: Value) -> Result<Value> { + self.client + .call(method, params) + .with_context(|| format!("rpc {method}")) + } +} + +impl Backend for ClientBackend { + fn projects(&mut self) -> Result<Vec<Project>> { + let v = self.call("node.list", json!({ "kind": "project" }))?; + let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?; + let mut projects: Vec<Project> = nodes + .into_iter() + .map(|n| Project { + id: n.id, + title: n.title, + }) + .collect(); + projects.sort_by(|a, b| a.title.cmp(&b.title)); + Ok(projects) + } + + fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> { + let v = self.call("view", json!({ "name": name }))?; + Ok(serde_json::from_value(v)?) + } + + fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> { + let v = self.call("list", json!(filter))?; + Ok(serde_json::from_value(v)?) + } + + fn node_body(&mut self, id: &str) -> Result<String> { + let v = self.call("node.get", json!({ "id": id }))?; + if v.is_null() { + return Ok(String::new()); + } + let node: heph_core::Node = serde_json::from_value(v)?; + Ok(node.body.unwrap_or_default()) + } + + fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>> { + let v = self.call("log.tail", json!({ "task_id": task_id, "n": n }))?; + Ok(serde_json::from_value(v)?) + } + + fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { + self.call("task.set_state", json!({ "id": task_id, "state": state }))?; + Ok(()) + } + + fn skip(&mut self, task_id: &str) -> Result<()> { + self.call("task.skip", json!({ "id": task_id }))?; + Ok(()) + } + + fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> { + self.call( + "task.set_attention", + json!({ "id": task_id, "attention": attention }), + )?; + Ok(()) + } + + fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()> { + let mut params = json!({ "id": task_id }); + let p = serde_json::to_value(&patch)?; + if let Value::Object(map) = p { + for (k, val) in map { + params[k] = val; + } + } + self.call("task.set_schedule", params)?; + Ok(()) + } + + fn create_task( + &mut self, + title: &str, + attention: Option<Attention>, + do_date: Option<i64>, + project_id: Option<&str>, + ) -> Result<String> { + let v = self.call( + "task.create", + json!({ + "title": title, + "attention": attention, + "do_date": do_date, + "project_id": project_id, + }), + )?; + let task: heph_core::Task = serde_json::from_value(v)?; + Ok(task.node_id) + } +} diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs new file mode 100644 index 0000000..439c979 --- /dev/null +++ b/crates/heph-tui/src/fmt.rs @@ -0,0 +1,52 @@ +//! Small display helpers (compact human dates for task rows). The UI layer may +//! read the wall clock (unlike `heph-core`, which is clock-injected). + +use chrono::{DateTime, Datelike, Local, NaiveDate}; + +/// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`, +/// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`. +pub fn fmt_date(ms: i64, today: NaiveDate) -> String { + let Some(dt) = DateTime::from_timestamp_millis(ms) else { + return "?".into(); + }; + let date = dt.with_timezone(&Local).date_naive(); + match (date - today).num_days() { + 0 => "today".into(), + 1 => "tomorrow".into(), + -1 => "yesterday".into(), + _ if date.year() == today.year() => format!("{:02}-{:02}", date.month(), date.day()), + _ => date.format("%Y-%m-%d").to_string(), + } +} + +/// Today in the local timezone (the reference for [`fmt_date`]). +pub fn today_local() -> NaiveDate { + Local::now().date_naive() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + fn ms(date: NaiveDate) -> i64 { + date.and_hms_opt(12, 0, 0) + .unwrap() + .and_local_timezone(Local) + .unwrap() + .timestamp_millis() + } + + #[test] + fn relative_and_absolute_dates() { + let today = day(2026, 6, 3); + assert_eq!(fmt_date(ms(today), today), "today"); + assert_eq!(fmt_date(ms(day(2026, 6, 4)), today), "tomorrow"); + assert_eq!(fmt_date(ms(day(2026, 6, 2)), today), "yesterday"); + assert_eq!(fmt_date(ms(day(2026, 12, 25)), today), "12-25"); + assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); + } +} diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs new file mode 100644 index 0000000..17bb71d --- /dev/null +++ b/crates/heph-tui/src/lib.rs @@ -0,0 +1,15 @@ +//! `heph-tui` — the Hephaestus terminal agenda/triage surface (tech-spec §8.1). +//! +//! A thin client of the local `hephd` unix socket (it never touches SQLite, +//! same as `heph.nvim`). The [`app::App`] holds all state and is generic over a +//! [`backend::Backend`] so its navigation/triage logic is unit-testable without +//! a terminal; [`ui::render`] draws the 3-pane layout; `main.rs` owns the +//! terminal and the event loop. + +pub mod app; +pub mod backend; +pub mod fmt; +pub mod ui; + +pub use app::{App, Focus}; +pub use backend::{Backend, ClientBackend, Project}; diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs new file mode 100644 index 0000000..1cc1827 --- /dev/null +++ b/crates/heph-tui/src/main.rs @@ -0,0 +1,95 @@ +//! `heph-tui` binary: terminal lifecycle + event loop. All state/logic lives in +//! the library ([`heph_tui::App`] + [`heph_tui::ui`]); this file is the I/O shell. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use heph_tui::{app::App, backend::ClientBackend, ui, Focus}; +use hephd::Client; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +#[derive(Parser)] +#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")] +struct Cli { + /// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the + /// standard runtime path. + #[arg(long)] + socket: Option<PathBuf>, +} + +fn resolve_socket(flag: Option<PathBuf>) -> PathBuf { + flag.or_else(|| std::env::var_os("HEPH_SOCKET").map(PathBuf::from)) + .unwrap_or_else(hephd::default_socket_path) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = resolve_socket(cli.socket); + + let client = Client::connect(&socket).with_context(|| { + format!( + "could not connect to hephd at {} — is it running? (try: heph daemon start)", + socket.display() + ) + })?; + let app = App::new(ClientBackend::new(client)).context("loading the agenda")?; + + let mut terminal = ratatui::init(); + let result = run(&mut terminal, app); + ratatui::restore(); + result +} + +fn run<B: heph_tui::Backend>( + terminal: &mut ratatui::DefaultTerminal, + mut app: App<B>, +) -> Result<()> { + loop { + terminal.draw(|f| ui::render(f, &app))?; + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key(&mut app, key); + } + } + if app.should_quit { + return Ok(()); + } + } +} + +/// Translate a key press into an [`App`] action (T1: navigation only). +fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) { + // Any keypress clears a stale status message. + app.status.clear(); + + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.should_quit = true; + return; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, + KeyCode::Char('r') => app.reload(), + KeyCode::Tab => app.toggle_focus(), + KeyCode::Char('j') | KeyCode::Down => move_down(app), + KeyCode::Char('k') | KeyCode::Up => move_up(app), + KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), + KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + _ => {} + } +} + +fn move_down<B: heph_tui::Backend>(app: &mut App<B>) { + match app.focus { + Focus::Sidebar => app.move_sidebar(1), + Focus::Tasks => app.move_task(1), + } +} + +fn move_up<B: heph_tui::Backend>(app: &mut App<B>) { + match app.focus { + Focus::Sidebar => app.move_sidebar(-1), + Focus::Tasks => app.move_task(-1), + } +} diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs new file mode 100644 index 0000000..6a8e799 --- /dev/null +++ b/crates/heph-tui/src/ui.rs @@ -0,0 +1,216 @@ +//! Rendering — the 3-pane agenda (sidebar · task list · preview) + status line +//! (tech-spec §8.1). Pure: it reads [`App`] and draws; no I/O, no mutation. + +use heph_core::Attention; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use crate::app::{App, Focus, SidebarEntry}; +use crate::backend::Backend; +use crate::fmt::{fmt_date, today_local}; + +const HINTS: &str = " j/k move Tab/h/l pane Enter open r refresh q quit"; + +/// Draw the whole UI for the current frame. +pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .split(frame.area()); + + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(22), + Constraint::Min(28), + Constraint::Length(38), + ]) + .split(outer[0]); + + render_sidebar(frame, app, panes[0]); + render_tasks(frame, app, panes[1]); + render_preview(frame, app, panes[2]); + render_status(frame, app, outer[1]); +} + +fn pane_border(focused: bool) -> Style { + if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } +} + +fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + let focused = app.focus == Focus::Sidebar; + let items: Vec<ListItem> = app + .sidebar + .iter() + .enumerate() + .map(|(i, entry)| { + let selected = i == app.sidebar_cursor; + match entry { + SidebarEntry::Header(h) => ListItem::new(Line::from(Span::styled( + h.clone(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ))), + SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => { + let mut style = Style::default(); + if selected { + style = if focused { + style.fg(Color::Black).bg(Color::Cyan) + } else { + style.add_modifier(Modifier::REVERSED) + }; + } + ListItem::new(Line::from(Span::styled(format!(" {title}"), style))) + } + } + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(pane_border(focused)) + .title(" Views "), + ); + frame.render_widget(list, area); +} + +fn attention_style(a: Option<Attention>) -> (char, Style) { + match a { + Some(Attention::Red) => ('●', Style::default().fg(Color::Red)), + Some(Attention::Orange) => ('●', Style::default().fg(Color::Yellow)), + Some(Attention::White) => ('○', Style::default().fg(Color::White)), + Some(Attention::Blue) => ('·', Style::default().fg(Color::Blue)), + None => ('·', Style::default().fg(Color::DarkGray)), + } +} + +fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + let focused = app.focus == Focus::Tasks; + let today = today_local(); + let width = area.width.saturating_sub(2) as usize; // inside borders + + let items: Vec<ListItem> = if app.tasks.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " (nothing here)", + Style::default().fg(Color::DarkGray), + )))] + } else { + app.tasks + .iter() + .enumerate() + .map(|(i, t)| { + let (glyph, gstyle) = attention_style(t.attention); + // Right-aligned date chip (late > do). + let (chip, chip_style) = if let Some(late) = t + .late_on + .filter(|l| chrono::Local::now().timestamp_millis() > *l) + { + ( + format!("late:{}", fmt_date(late, today)), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } else if let Some(do_date) = t.do_date { + ( + format!("do:{}", fmt_date(do_date, today)), + Style::default().fg(Color::DarkGray), + ) + } else { + (String::new(), Style::default()) + }; + + let selected = i == app.task_cursor; + let title_style = if selected && focused { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let cursor = if selected { "▌" } else { " " }; + + // Pad the title so the chip sits at the right edge. + let chip_w = chip.len(); + let fixed = 1 + 2; // cursor + glyph + space + let avail = width.saturating_sub(fixed + chip_w + 1); + let mut title: String = t.title.chars().take(avail).collect(); + let pad = avail.saturating_sub(title.chars().count()); + title.push_str(&" ".repeat(pad)); + + ListItem::new(Line::from(vec![ + Span::styled(cursor, Style::default().fg(Color::Cyan)), + Span::styled(format!("{glyph} "), gstyle), + Span::styled(title, title_style), + Span::raw(" "), + Span::styled(chip, chip_style), + ])) + }) + .collect() + }; + + let title = format!(" {} ({}) ", app.task_pane_title(), app.tasks.len()); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(pane_border(focused)) + .title(title), + ); + frame.render_widget(list, area); +} + +fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + let mut lines: Vec<Line> = Vec::new(); + if !app.preview.title.is_empty() { + lines.push(Line::from(Span::styled( + app.preview.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + } + for l in &app.preview.body { + lines.push(Line::from(l.clone())); + } + if !app.preview.log.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "─ log ─", + Style::default().fg(Color::DarkGray), + ))); + for l in &app.preview.log { + lines.push(Line::from(Span::styled( + l.clone(), + Style::default().fg(Color::DarkGray), + ))); + } + } + + let para = Paragraph::new(lines).wrap(Wrap { trim: false }).block( + Block::default() + .borders(Borders::ALL) + .border_style(pane_border(false)) + .title(" Preview "), + ); + frame.render_widget(para, area); +} + +fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + let text = if app.status.is_empty() { + HINTS.to_string() + } else { + format!(" {}", app.status) + }; + let style = if app.status.starts_with("error") { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); +} diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs new file mode 100644 index 0000000..c0197bf --- /dev/null +++ b/crates/heph-tui/tests/agenda.rs @@ -0,0 +1,134 @@ +//! Headless render test (tech-spec §8.1/§9): a real `hephd` over a real unix +//! socket against a temp DB, driven by the TUI's `ClientBackend`, rendered to +//! ratatui's `TestBackend` — asserting the agenda actually paints seeded data. + +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use heph_core::{FixedClock, LocalStore}; +use heph_tui::{app::App, backend::ClientBackend}; +use hephd::{Client, Daemon}; +use ratatui::{backend::TestBackend, Terminal}; +use serde_json::json; +use tokio::net::UnixListener; + +const NOW: i64 = 1_717_400_000_000; // ~2024-06-03 + +fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + let socket = dir.path().join("d.sock"); + + let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); + let socket_for_thread = socket.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = UnixListener::bind(&socket_for_thread).unwrap(); + let _ = Daemon::new(store).serve(listener).await; + }); + }); + for _ in 0..200 { + if socket.exists() { + break; + } + thread::sleep(Duration::from_millis(5)); + } + (socket, dir) +} + +fn client(socket: &Path) -> Client { + Client::connect(socket).unwrap() +} + +/// Render the app to a fixed-size TestBackend and return the screen as text. +fn screen<B: heph_tui::Backend>(app: &App<B>) -> String { + let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); + terminal.draw(|f| heph_tui::ui::render(f, app)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out +} + +#[test] +fn agenda_renders_views_projects_and_tasks() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + // Seed: a red task (Top of Mind) and a blue one (On Deck), plus a project. + c.call( + "task.create", + json!({ "title": "Pay the water bill", "attention": "red" }), + ) + .unwrap(); + c.call( + "task.create", + json!({ "title": "Someday backlog item", "attention": "blue" }), + ) + .unwrap(); + c.call( + "node.create", + json!({ "kind": "project", "title": "Camano" }), + ) + .unwrap(); + + let app = App::new(ClientBackend::new(client(&socket))).unwrap(); + let s = screen(&app); + + // Sidebar shows the built-in views and the project. + assert!(s.contains("Top of Mind"), "missing view in sidebar:\n{s}"); + assert!(s.contains("On Deck"), "missing On Deck view:\n{s}"); + assert!(s.contains("Camano"), "missing project in sidebar:\n{s}"); + // The default selection (Top of Mind) lists the red task, not the blue one. + assert!(s.contains("Pay the water bill"), "red task missing:\n{s}"); + assert!( + !s.contains("Someday backlog item"), + "blue task should not be in Top of Mind:\n{s}" + ); + assert!(s.contains("Preview"), "preview pane missing:\n{s}"); +} + +#[test] +fn preview_shows_the_selected_tasks_context_body() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let task = c + .call( + "task.create", + json!({ "title": "Renew passport", "attention": "orange" }), + ) + .unwrap(); + // Find the canonical-context doc and give it a body. + let links = c + .call("links.outgoing", json!({ "id": task["node_id"] })) + .unwrap(); + let ctx = links + .as_array() + .unwrap() + .iter() + .find(|l| l["link_type"] == "canonical-context") + .unwrap()["dst_id"] + .as_str() + .unwrap() + .to_string(); + c.call( + "node.update", + json!({ "id": ctx, "body": "- [ ] fill form\n- [ ] passport photo" }), + ) + .unwrap(); + + let app = App::new(ClientBackend::new(client(&socket))).unwrap(); + let s = screen(&app); + assert!(s.contains("fill form"), "context body not previewed:\n{s}"); +} diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs new file mode 100644 index 0000000..10c2f90 --- /dev/null +++ b/crates/heph-tui/tests/navigation.rs @@ -0,0 +1,149 @@ +//! Navigation/selection logic against an in-memory fake backend — no terminal, +//! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1). + +use std::collections::HashMap; + +use anyhow::Result; +use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState}; +use heph_tui::{ + app::{App, Focus}, + backend::{Backend, Project}, +}; + +fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { + RankedTask { + node_id: id.into(), + title: title.into(), + attention: Some(attention), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + tombstoned: false, + project_id: None, + canonical_context_id: ctx.map(str::to_string), + created_at: 0, + } +} + +#[derive(Default)] +struct Fake { + views: HashMap<String, Vec<RankedTask>>, + projects: Vec<Project>, + by_project: HashMap<String, Vec<RankedTask>>, + bodies: HashMap<String, String>, +} + +impl Backend for Fake { + fn projects(&mut self) -> Result<Vec<Project>> { + Ok(self.projects.clone()) + } + fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> { + Ok(self.views.get(name).cloned().unwrap_or_default()) + } + fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> { + let id = filter.scope.first().cloned().unwrap_or_default(); + Ok(self.by_project.get(&id).cloned().unwrap_or_default()) + } + fn node_body(&mut self, id: &str) -> Result<String> { + Ok(self.bodies.get(id).cloned().unwrap_or_default()) + } + fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> { + Ok(Vec::new()) + } + fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> { + Ok(()) + } + fn skip(&mut self, _t: &str) -> Result<()> { + Ok(()) + } + fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> { + Ok(()) + } + fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> { + Ok(()) + } + fn create_task( + &mut self, + _title: &str, + _a: Option<Attention>, + _d: Option<i64>, + _p: Option<&str>, + ) -> Result<String> { + Ok("new".into()) + } +} + +fn fixture() -> Fake { + let mut f = Fake::default(); + f.views.insert( + "tom".into(), + vec![ + task("t1", "red one", Attention::Red, Some("c1")), + task("t2", "orange two", Attention::Orange, Some("c2")), + ], + ); + f.views.insert( + "ondeck".into(), + vec![task("b1", "blue one", Attention::Blue, None)], + ); + f.projects.push(Project { + id: "p1".into(), + title: "Camano".into(), + }); + f.by_project.insert( + "p1".into(), + vec![task("pt", "project task", Attention::White, None)], + ); + f.bodies.insert("c1".into(), "body of red one".into()); + f.bodies.insert("c2".into(), "body of orange two".into()); + f +} + +#[test] +fn starts_on_the_first_view_with_its_tasks() { + let app = App::new(fixture()).unwrap(); + assert_eq!(app.task_pane_title(), "Top of Mind"); + assert_eq!(app.tasks.len(), 2); + assert_eq!(app.selected_task().unwrap().title, "red one"); + assert_eq!(app.preview.body, vec!["body of red one"]); + assert_eq!(app.focus, Focus::Sidebar); +} + +#[test] +fn moving_the_sidebar_switches_the_task_list() { + let mut app = App::new(fixture()).unwrap(); + app.move_sidebar(1); // Top of Mind -> On Deck + assert_eq!(app.task_pane_title(), "On Deck"); + assert_eq!(app.tasks.len(), 1); + assert_eq!(app.selected_task().unwrap().title, "blue one"); +} + +#[test] +fn sidebar_skips_headers_into_the_projects_section() { + let mut app = App::new(fixture()).unwrap(); + // 5 built-in views: step down 5 times crosses the "Projects" header to p1. + for _ in 0..5 { + app.move_sidebar(1); + } + assert_eq!(app.task_pane_title(), "Camano"); + assert_eq!(app.selected_task().unwrap().title, "project task"); +} + +#[test] +fn moving_the_task_cursor_updates_the_preview() { + let mut app = App::new(fixture()).unwrap(); + app.focus_tasks(); + assert_eq!(app.focus, Focus::Tasks); + app.move_task(1); + assert_eq!(app.selected_task().unwrap().title, "orange two"); + assert_eq!(app.preview.body, vec!["body of orange two"]); +} + +#[test] +fn move_task_clamps_at_the_ends() { + let mut app = App::new(fixture()).unwrap(); + app.move_task(-5); // already at top + assert_eq!(app.task_cursor, 0); + app.move_task(50); // past the end + assert_eq!(app.task_cursor, 1); +} -- 2.50.1 (Apple Git-155) From 10cf0fc3955b089fc0ccfa6608d26f92929b248d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:08:58 -0700 Subject: [PATCH 47/91] =?UTF-8?q?feat(tui):=20heph-tui=20T2a=20=E2=80=94?= =?UTF-8?q?=20instant=20triage=20gestures=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-keypress mutations on the highlighted task, each → RPC → reload with a status confirmation: x done (recurring roll-forward), d drop, s skip, A cycle attention (white→orange→red→blue, §6.2), b push-to-blue (On Deck). The bulk of daily triage — the daily orange reconfirm and blue keep/drop review made fast. Tests: next_attention cycle unit test; integration tests against a real daemon that completing/pushing-to-blue removes a task from Top of Mind (and it then shows under On Deck). 11 heph-tui tests; clippy/fmt clean. Input-requiring actions (a add, e reschedule) + nvim context handoff are T2b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 79 ++++++++++++++++++++++++++--- crates/heph-tui/src/main.rs | 6 +++ crates/heph-tui/src/ui.rs | 3 +- crates/heph-tui/tests/agenda.rs | 41 +++++++++++++++ crates/heph-tui/tests/navigation.rs | 10 ++++ 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 6ef3d12..b399dcf 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -3,10 +3,22 @@ //! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. use anyhow::Result; -use heph_core::{RankedTask, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, BUILTIN_VIEWS}; use crate::backend::{Backend, Project}; +/// The attention cycle for the `A` gesture: default → top-of-mind → consequence +/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. +pub fn next_attention(current: Option<Attention>) -> Attention { + match current { + Some(Attention::White) => Attention::Orange, + Some(Attention::Orange) => Attention::Red, + Some(Attention::Red) => Attention::Blue, + Some(Attention::Blue) => Attention::White, + None => Attention::White, + } +} + /// Which pane has the keyboard. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { @@ -217,11 +229,66 @@ impl<B: Backend> App<B> { }; } - /// Run `f` against the backend; any error lands in the status line. Used by - /// mutation gestures (T2). - pub fn try_mutate(&mut self, f: impl FnOnce(&mut B) -> Result<()>) { - if let Err(e) = f(&mut self.backend) { - self.status = format!("error: {e}"); + // --- triage mutations (T2a: single-keypress, no input) --- + + /// Run `f` against the backend; on success set `ok` as the status and reload, + /// on error surface it in the status line. The shared shape for the gestures. + fn mutate(&mut self, ok: String, f: impl FnOnce(&mut B) -> Result<()>) { + match f(&mut self.backend) { + Ok(()) => { + self.status = ok; + self.reload(); + } + Err(e) => self.status = format!("error: {e}"), } } + + /// Mark the highlighted task done (recurring tasks roll forward). + pub fn complete_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("done: {}", t.title), |b| { + b.set_state(&t.node_id, "done") + }); + } + + /// Drop the highlighted task (let it go — the blue keep/drop review). + pub fn drop_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("dropped: {}", t.title), |b| { + b.set_state(&t.node_id, "dropped") + }); + } + + /// Skip the highlighted recurring task to its next occurrence. + pub fn skip_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id)); + } + + /// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue). + pub fn cycle_attention_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + let next = next_attention(t.attention); + self.mutate(format!("{}: {}", next.as_str(), t.title), |b| { + b.set_attention(&t.node_id, next) + }); + } + + /// Push the highlighted task to On Deck (blue) — the pressure-relief valve. + pub fn push_to_blue_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("→ on deck: {}", t.title), |b| { + b.set_attention(&t.node_id, Attention::Blue) + }); + } } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 1cc1827..b1e596a 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -76,6 +76,12 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) { KeyCode::Char('k') | KeyCode::Up => move_up(app), KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + // triage mutations (act on the highlighted task) + KeyCode::Char('x') => app.complete_selected(), + KeyCode::Char('d') => app.drop_selected(), + KeyCode::Char('s') => app.skip_selected(), + KeyCode::Char('A') => app.cycle_attention_selected(), + KeyCode::Char('b') => app.push_to_blue_selected(), _ => {} } } diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6a8e799..69c5b7d 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -14,7 +14,8 @@ use crate::app::{App, Focus, SidebarEntry}; use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; -const HINTS: &str = " j/k move Tab/h/l pane Enter open r refresh q quit"; +const HINTS: &str = + " j/k move Tab pane x done d drop s skip A attn b→blue r refresh q quit"; /// Draw the whole UI for the current frame. pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index c0197bf..cf82816 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -132,3 +132,44 @@ fn preview_shows_the_selected_tasks_context_body() { let s = screen(&app); assert!(s.contains("fill form"), "context body not previewed:\n{s}"); } + +#[test] +fn completing_a_task_removes_it_from_top_of_mind() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + c.call( + "task.create", + json!({ "title": "Pay the bill", "attention": "red" }), + ) + .unwrap(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert_eq!(app.tasks.len(), 1); + app.complete_selected(); + + assert!(app.status.contains("done"), "status: {}", app.status); + assert!(app.tasks.is_empty(), "completed task still listed"); + // ...and the screen reflects the empty list. + assert!(screen(&app).contains("nothing here")); +} + +#[test] +fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + c.call( + "task.create", + json!({ "title": "Cool it down", "attention": "orange" }), + ) + .unwrap(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert_eq!(app.tasks.len(), 1); + app.push_to_blue_selected(); + assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); + + // It now appears under On Deck (sidebar row 2). + app.move_sidebar(1); + assert_eq!(app.task_pane_title(), "On Deck"); + assert_eq!(app.selected_task().unwrap().title, "Cool it down"); +} diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 10c2f90..d5fb939 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -147,3 +147,13 @@ fn move_task_clamps_at_the_ends() { app.move_task(50); // past the end assert_eq!(app.task_cursor, 1); } + +#[test] +fn attention_cycles_white_orange_red_blue() { + use heph_tui::app::next_attention; + assert_eq!(next_attention(Some(Attention::White)), Attention::Orange); + assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red); + assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue); + assert_eq!(next_attention(Some(Attention::Blue)), Attention::White); + assert_eq!(next_attention(None), Attention::White); +} -- 2.50.1 (Apple Git-155) From ae2eff401cb9c881ae869c00881c3490ad642a29 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:11:47 -0700 Subject: [PATCH 48/91] =?UTF-8?q?feat(tui):=20heph-tui=20T2b=20=E2=80=94?= =?UTF-8?q?=20nvim=20context=20handoff=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `o` on a task suspends the TUI, opens its canonical-context doc in the owner's nvim via heph.nvim's live buffer surface (+lua require('heph.node').open), then restores the alternate screen and reloads to pick up edits. The child nvim is pointed at the same daemon via $HEPH_SOCKET, so it works under a custom --socket too. This is the KB↔task fusion — edit the description/checklist in the real editor and return straight to triage. handle_key now returns an Action the event loop performs (the suspend/spawn is terminal-owning, kept out of App). nvim arg builder unit-tested; the actual suspend/spawn is interactive so it's exercised manually. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 11 ++++++++ crates/heph-tui/src/editor.rs | 53 +++++++++++++++++++++++++++++++++++ crates/heph-tui/src/lib.rs | 1 + crates/heph-tui/src/main.rs | 44 +++++++++++++++++++++++++---- crates/heph-tui/src/ui.rs | 2 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/heph-tui/src/editor.rs diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index b399dcf..89e331c 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -122,6 +122,17 @@ impl<B: Backend> App<B> { self.tasks.get(self.task_cursor) } + /// The node to open in the editor for the highlighted task: its + /// canonical-context doc (where the description/checklist live), falling + /// back to the task node itself. + pub fn selected_context_id(&self) -> Option<String> { + self.selected_task().map(|t| { + t.canonical_context_id + .clone() + .unwrap_or_else(|| t.node_id.clone()) + }) + } + fn current_target(&self) -> Option<Target> { match self.sidebar.get(self.sidebar_cursor)? { SidebarEntry::View { name, .. } => Some(Target::View(name.clone())), diff --git a/crates/heph-tui/src/editor.rs b/crates/heph-tui/src/editor.rs new file mode 100644 index 0000000..edb1023 --- /dev/null +++ b/crates/heph-tui/src/editor.rs @@ -0,0 +1,53 @@ +//! The nvim context handoff (tech-spec §8.1): suspend the TUI, open the task's +//! canonical-context doc in the owner's nvim (live, via heph.nvim's buffer +//! surface), then resume. The KB↔task fusion — edit the description/checklist +//! in the real editor and come straight back to triage. + +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; +use ratatui::crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::DefaultTerminal; + +/// The nvim arguments that open a heph node buffer through heph.nvim. Node ids +/// are ULIDs (no quote/escaping hazard), so a plain `'<id>'` literal is safe. +pub fn nvim_args(node_id: &str) -> Vec<String> { + vec![format!("+lua require('heph.node').open('{node_id}')")] +} + +/// Drop out of the alternate screen, run `nvim` on `node_id` (pointing its +/// heph.nvim at the same daemon via `$HEPH_SOCKET`), then restore the TUI. +pub fn edit_in_nvim(terminal: &mut DefaultTerminal, node_id: &str, socket: &Path) -> Result<()> { + disable_raw_mode()?; + execute!(std::io::stdout(), LeaveAlternateScreen)?; + + let status = Command::new("nvim") + .args(nvim_args(node_id)) + .env("HEPH_SOCKET", socket) + .status(); + + // Restore the TUI regardless of how nvim exited, then surface any error. + enable_raw_mode()?; + execute!(std::io::stdout(), EnterAlternateScreen)?; + terminal.clear()?; + + status.context("launching nvim (is it on PATH?)")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn args_open_the_node_via_heph_nvim() { + let args = nvim_args("01ABCXYZ"); + assert_eq!(args.len(), 1); + assert!(args[0].starts_with("+lua")); + assert!(args[0].contains("require('heph.node').open('01ABCXYZ')")); + } +} diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs index 17bb71d..83efc5c 100644 --- a/crates/heph-tui/src/lib.rs +++ b/crates/heph-tui/src/lib.rs @@ -8,6 +8,7 @@ pub mod app; pub mod backend; +pub mod editor; pub mod fmt; pub mod ui; diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b1e596a..ee28461 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -5,10 +5,17 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::Parser; -use heph_tui::{app::App, backend::ClientBackend, ui, Focus}; +use heph_tui::{app::App, backend::ClientBackend, editor, ui, Focus}; use hephd::Client; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +/// A key action that the event loop (which owns the terminal) must perform — +/// kept out of `App` so app logic stays terminal-free. +enum Action { + /// Suspend and open this node in nvim, then reload. + EditContext(String), +} + #[derive(Parser)] #[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")] struct Cli { @@ -36,7 +43,7 @@ fn main() -> Result<()> { let app = App::new(ClientBackend::new(client)).context("loading the agenda")?; let mut terminal = ratatui::init(); - let result = run(&mut terminal, app); + let result = run(&mut terminal, app, &socket); ratatui::restore(); result } @@ -44,12 +51,15 @@ fn main() -> Result<()> { fn run<B: heph_tui::Backend>( terminal: &mut ratatui::DefaultTerminal, mut app: App<B>, + socket: &std::path::Path, ) -> Result<()> { loop { terminal.draw(|f| ui::render(f, &app))?; if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { - handle_key(&mut app, key); + if let Some(action) = handle_key(&mut app, key) { + perform(terminal, &mut app, socket, action)?; + } } } if app.should_quit { @@ -58,14 +68,33 @@ fn run<B: heph_tui::Backend>( } } -/// Translate a key press into an [`App`] action (T1: navigation only). -fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) { +/// Perform a terminal-affecting action (currently: the nvim handoff). +fn perform<B: heph_tui::Backend>( + terminal: &mut ratatui::DefaultTerminal, + app: &mut App<B>, + socket: &std::path::Path, + action: Action, +) -> Result<()> { + match action { + Action::EditContext(id) => { + if let Err(e) = editor::edit_in_nvim(terminal, &id, socket) { + app.status = format!("error: {e}"); + } + app.reload(); // pick up edits made in nvim + } + } + Ok(()) +} + +/// Translate a key press into an [`App`] mutation and/or an [`Action`] for the +/// event loop to perform. +fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<Action> { // Any keypress clears a stale status message. app.status.clear(); if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { app.should_quit = true; - return; + return None; } match key.code { @@ -82,8 +111,11 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) { KeyCode::Char('s') => app.skip_selected(), KeyCode::Char('A') => app.cycle_attention_selected(), KeyCode::Char('b') => app.push_to_blue_selected(), + // open the task's context doc in nvim (handled by the event loop) + KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext), _ => {} } + None } fn move_down<B: heph_tui::Backend>(app: &mut App<B>) { diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 69c5b7d..a26c2e2 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -15,7 +15,7 @@ use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; const HINTS: &str = - " j/k move Tab pane x done d drop s skip A attn b→blue r refresh q quit"; + " j/k move Tab pane x done d drop s skip A attn b→blue o edit r refresh q quit"; /// Draw the whole UI for the current frame. pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { -- 2.50.1 (Apple Git-155) From 8f10287a7f5c96fd15feff2b0168e4e13a400d13 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:21:12 -0700 Subject: [PATCH 49/91] =?UTF-8?q?feat(tui):=20heph-tui=20T2c=20=E2=80=94?= =?UTF-8?q?=20guided=20add=20+=20reschedule=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-line input modal (centered popup, Esc cancels, Enter submits) drives the two input-requiring gestures: - a: guided capture — title → attention (o/r/b, blank=white) → do-date (today/tomorrow/+3d, blank=none). If a project is the current sidebar selection, the task is filed there (Todoist's add-within-project). - e: reschedule the highlighted task's do-date (blank clears it). Parse errors keep the input step (typed text isn't lost) and show in the status line. Shared client-side date/recurrence parsing (datespec) moved from the heph CLI into hephd's lib so both the CLI and TUI use one parser; heph-core stays clock-pure. The CLI now uses hephd::datespec (no behavior change; its tests moved with it). Tests: add-flow + reschedule unit tests against a recording fake (asserting the collected title/attention/project and the clear-on-blank double-option), plus real-daemon integration tests that guided-add surfaces a task and reschedule sets the do_date. 171 workspace tests; clippy/fmt clean. With done/drop/skip/attention/blue (T2a) + nvim handoff (T2b), all four day-one daily-driver gestures now land. NL single-line quick-add + search are T3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/{heph => hephd}/src/datespec.rs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/{heph => hephd}/src/datespec.rs (100%) diff --git a/crates/heph/src/datespec.rs b/crates/hephd/src/datespec.rs similarity index 100% rename from crates/heph/src/datespec.rs rename to crates/hephd/src/datespec.rs -- 2.50.1 (Apple Git-155) From 2e0e37f76dbafcc84f8bba9832f2e33478c3044b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:23:18 -0700 Subject: [PATCH 50/91] =?UTF-8?q?docs(tui):=20mark=20heph-tui=20daily-driv?= =?UTF-8?q?er=20core=20built=20(=C2=A78.1,=20=C2=A714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8.1 status → "core built" with the implemented panes/gestures/handoff; §14 adds the heph-tui Done bullet, retargets the resume list to T3 (NL quick-add + search), and bumps the count to 171 Rust tests. Changelog fragment for the TUI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 9ccf385..6771681 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -23,3 +23,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). +- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). Single-line natural-language quick-add and search are still to come. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 1c3d652..ae596c0 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -248,16 +248,16 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba **Known-hard:** reconciling an incoming CRDT body delta into a *dirty* buffer (unsaved local edits, cursor position) — the §9 "update arrives while a buffer is open" case — is genuinely fiddly under Fork A; expect to iterate. -## 8.1 heph-tui surface — task agenda / triage (planned) +## 8.1 heph-tui surface — task agenda / triage (core built) -> **Status: planned, not yet built.** The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it. +> **Status: daily-driver core built** (slices T1–T2c; NL quick-add + search are the remaining T3 polish). The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it. -- **Crate `crates/heph-tui`** — `ratatui` + `crossterm`, a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. -- **Layout** — three panes: **projects/contexts** (the §6.2.1 hierarchy) · **task list** (`next`/`list` rows with attention + human do/late) · **preview** (canonical-context doc body / `log.tail`). -- **Gestures** — `j/k` move · `a` add · `x` done · `space` skip · `A` cycle attention · `e` reschedule (do/late) · `b` push-to-blue · the left pane lists the **§8.2 named filter views** (Top of Mind, Tasks, Work Tasks, Chores, On Deck) — the [[design]] §6.2 "filters = saved views" made interactive. -- **TUI ↔ nvim handoff** — `o`/`<CR>` launches `$EDITOR` (nvim) on the task's canonical-context doc (`nvim` with a `+lua` call opening `heph://node/<ctx-id>`, or a temp `.md` round-tripped through `node.update`); a nvim command (e.g. `:Heph agenda`) shells back to the TUI. -- **Testing** — TDD against a real daemon; headless smoke via `ratatui`'s `TestBackend`. -- **Prereqs** (land first): **§8.2 filter views** (the TUI's saved-filter pane is just those views); the CLI-complete task surface and `task.set_schedule` (done). +- **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. +- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` add (guided: title→attention→do-date, filed under the selected project) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. *(T3: a single-line NL quick-add à la Todoist, and `/` search.)* +- **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* +- **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. +- **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. ## 8.2 Filter views (saved agenda slices) — built @@ -380,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **154 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **171 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** @@ -410,12 +410,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - **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. - **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI. - ✅ **Filter views (§8.2) — saved agenda slices:** the owner's saved filters are first-class so the agenda isn't one flat list. New **`heph-core::filter`**: a `ListFilter` **predicate-as-data** (`attention_in`/`attention_not` sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate) + the five built-in [`ViewSpec`]s (Top of Mind / On Deck / Chores / Work Tasks / Tasks — **Schedule dropped**, §8.2). `Store::list` now takes a `ListFilter`; new `Store::view(name)` resolves a spec's project **names** → ids and **subtree-expands** them through `parent` links (`links::project_subtree`/`resolve_project_id`), tolerating absent projects (a scoped view whose projects are all missing returns empty, never widens). Surfaces: `heph view <name>` (no name lists the five), the **`view` RPC** + `RemoteStore` forward, and `:Heph view <name>` in nvim (`heph://view/<name>` buffers). The `list` RPC/`RemoteStore`/CLI/`heph.nvim` migrated to the filter wire (legacy `--scope`/`--attention`/`--no-blue` map onto it). Tested: `filter` unit predicate, a `views` integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket `list`/`view` dispatch test, and two nvim e2e specs. +- ✅ **`heph-tui` (§8.1) — the task agenda/triage surface, daily-driver core (slices T1–T2c):** a `ratatui` terminal UI, thin client of the daemon socket (never touches SQLite). **3-pane layout** — sidebar (the five §8.2 views + projects) · attention-colored task list with compact human do/late · preview (canonical-context body + `log.tail`). `App` is generic over a `Backend` seam (unit-testable without a terminal/daemon); `ui::render` is pure. **Gestures:** `j/k`/`Tab`/`h`/`l` navigate; `a` guided add (title→attention→do-date, filed under the selected project); `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push-to-blue, `e` reschedule do-date; `o` suspends and opens the task's context doc in nvim (`+lua require('heph.node').open`, `$HEPH_SOCKET` shared) then reloads. Shared client date/recurrence parsing (`datespec`) moved from the CLI into `hephd`'s lib so both surfaces use one parser (heph-core stays clock-pure). Tested: `TestBackend` render assertions against a real spawned daemon + in-memory navigation/input-flow units. *(Remaining T3: a single-line NL quick-add like Todoist, `/` search, move-to-project.)* **Not yet done (resume order)** -> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist with reference contexts reclassified to wiki docs ([[design]] §6.2.1), and **filter views (§8.2) are built** (`heph view`). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (next big build), **nvim = context/KB**. Remaining work, in order: +> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist ([[design]] §6.2.1), **filter views (§8.2) are built** (`heph view`), and the **`heph-tui` daily-driver core is built** (§8.1, T1–T2c). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (core done, T3 polish remains), **nvim = context/KB**. Remaining work, in order: -1. ⏳ **`heph-tui` — the task agenda/triage surface (§8.1) — the next big build:** ratatui terminal UI over the daemon socket; projects/list/preview panes; the §8.2 filter views as the saved-filter pane (the `view` RPC is ready); launches into nvim for context and back. **Filter-views prereq is now done.** +1. ⏳ **`heph-tui` T3 — quick-add + search polish (§8.1):** a single-line **NL quick-add** (`Buy milk tomorrow p2 #Work every week`, reusing `datespec`), `/` **search**, and move-to-project. The daily-driver gestures (T1–T2c) are done; this closes Todoist-parity capture. 2. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). 3. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduced) is a refinement. 4. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -- 2.50.1 (Apple Git-155) From b4624af02114ed153d2c65824df4793ff4fb92c8 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:32:45 -0700 Subject: [PATCH 51/91] =?UTF-8?q?feat(tui):=20heph-tui=20T3=20=E2=80=94=20?= =?UTF-8?q?single-line=20NL=20quick-add=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a` is now Todoist-style one-line capture: parse a line like `Water plants tomorrow p2 #Camano Chores every 3 days` into title + attention (p1 red / p2 orange / p3 blue / p4 white) + do-date (today/tomorrow/+3d/fri/ISO) + recurrence (`every …`, longest suffix that parses) + project (`#Name`, greedy multi-word match against existing projects). An unresolved `#tag` stays in the title verbatim (no surprise project creation); with no `#project`, the task is filed under the selected sidebar project. The parser (`quickadd::parse`) is pure — `today` and the project list are passed in — reusing hephd::datespec for dates/recurrence, so it's exhaustively unit-tested (priority, relative/weekday dates, single + multi-word projects, recurrence extraction, unresolved tags, the all-at-once case, and the "every"-not-a-recurrence fallback). `Backend::create_task` gained a recurrence arg. The multi-step guided add it replaces is gone. 181 workspace tests; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 186 ++++++++++++++++++++++- crates/heph-tui/src/backend.rs | 3 + crates/heph-tui/src/lib.rs | 1 + crates/heph-tui/src/quickadd.rs | 223 ++++++++++++++++++++++++++++ crates/heph-tui/tests/agenda.rs | 45 ++++++ crates/heph-tui/tests/navigation.rs | 118 ++++++++++++++- 6 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 crates/heph-tui/src/quickadd.rs diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 89e331c..19442bc 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -3,10 +3,36 @@ //! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. use anyhow::Result; -use heph_core::{Attention, RankedTask, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; use crate::backend::{Backend, Project}; +/// The interaction mode: normal navigation, or collecting a line of text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mode { + Normal, + Input(InputState), +} + +/// A single-line text prompt overlay (guided add / reschedule). `prompt` labels +/// it; `buffer` is what the user has typed; `kind` says what submit does (and, +/// for the multi-step add, carries the fields collected so far). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputState { + pub prompt: String, + pub buffer: String, + kind: InputKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InputKind { + /// Single-line natural-language capture (parsed by [`crate::quickadd`]). + QuickAdd, + Reschedule { + task_id: String, + }, +} + /// The attention cycle for the `A` gesture: default → top-of-mind → consequence /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. pub fn next_attention(current: Option<Attention>) -> Attention { @@ -66,6 +92,7 @@ pub struct App<B: Backend> { pub task_cursor: usize, pub preview: Preview, pub focus: Focus, + pub mode: Mode, pub status: String, pub should_quit: bool, } @@ -101,6 +128,7 @@ impl<B: Backend> App<B> { task_cursor: 0, preview: Preview::default(), focus: Focus::Sidebar, + mode: Mode::Normal, status: String::new(), should_quit: false, }; @@ -302,4 +330,160 @@ impl<B: Backend> App<B> { b.set_attention(&t.node_id, Attention::Blue) }); } + + // --- input modal (T2c: guided add + reschedule) --- + + fn current_project_id(&self) -> Option<String> { + match self.sidebar.get(self.sidebar_cursor) { + Some(SidebarEntry::Project { id, .. }) => Some(id.clone()), + _ => None, + } + } + + /// The projects known to the sidebar (for quick-add `#project` resolution). + fn project_list(&self) -> Vec<Project> { + self.sidebar + .iter() + .filter_map(|e| match e { + SidebarEntry::Project { id, title } => Some(Project { + id: id.clone(), + title: title.clone(), + }), + _ => None, + }) + .collect() + } + + /// Start single-line natural-language capture. If no `#project` is given and + /// a project is the current sidebar selection, the task is filed there. + pub fn begin_add(&mut self) { + self.mode = Mode::Input(InputState { + prompt: "Add (e.g. Buy milk tomorrow p2 #Work every week)".into(), + buffer: String::new(), + kind: InputKind::QuickAdd, + }); + } + + /// Start rescheduling the highlighted task's do-date (blank = clear it). + pub fn begin_reschedule(&mut self) { + let Some(t) = self.selected_task() else { + return; + }; + self.mode = Mode::Input(InputState { + prompt: format!("Do-date for \"{}\" (blank clears)", t.title), + buffer: String::new(), + kind: InputKind::Reschedule { + task_id: t.node_id.clone(), + }, + }); + } + + /// Append a typed character to the active input. + pub fn input_push(&mut self, c: char) { + if let Mode::Input(state) = &mut self.mode { + state.buffer.push(c); + } + } + + /// Delete the last character of the active input. + pub fn input_backspace(&mut self) { + if let Mode::Input(state) = &mut self.mode { + state.buffer.pop(); + } + } + + /// Abandon the active input. + pub fn input_cancel(&mut self) { + self.mode = Mode::Normal; + self.status = "cancelled".into(); + } + + /// Re-enter an input step (used to keep state after a parse error). + fn reenter(&mut self, prompt: String, buffer: String, kind: InputKind) { + self.mode = Mode::Input(InputState { + prompt, + buffer, + kind, + }); + } + + /// Commit the active input — advancing the add flow, capturing the task, or + /// applying the reschedule. Parse errors keep the step (so typed text isn't + /// lost) and show the error in the status line. + pub fn input_submit(&mut self) { + let Mode::Input(state) = std::mem::replace(&mut self.mode, Mode::Normal) else { + return; + }; + let buf = state.buffer.trim().to_string(); + match state.kind { + InputKind::QuickAdd => { + if buf.is_empty() { + self.status = "add cancelled".into(); + return; + } + let projects = self.project_list(); + let parsed = crate::quickadd::parse(&buf, crate::fmt::today_local(), &projects); + if parsed.title.is_empty() { + self.status = "add cancelled (no title)".into(); + return; + } + // An explicit #project wins; otherwise file under the selected one. + let project = parsed.project_id.or_else(|| self.current_project_id()); + match self.backend.create_task( + &parsed.title, + parsed.attention, + parsed.do_date, + parsed.recurrence.as_deref(), + project.as_deref(), + ) { + Ok(_) => { + self.status = format!("added: {}", parsed.title); + self.reload(); + } + Err(e) => self.status = format!("error: {e}"), + } + } + InputKind::Reschedule { task_id } => { + let patch = if buf.is_empty() { + SchedulePatch { + do_date: Some(None), // clear + ..Default::default() + } + } else { + match parse_optional_date(&buf) { + Ok(ms) => SchedulePatch { + do_date: Some(ms), + ..Default::default() + }, + Err(e) => { + self.status = format!("error: {e}"); + self.reenter( + "Do-date (blank clears)".into(), + buf, + InputKind::Reschedule { task_id }, + ); + return; + } + } + }; + match self.backend.set_schedule(&task_id, patch) { + Ok(()) => { + self.status = "rescheduled".into(); + self.reload(); + } + Err(e) => self.status = format!("error: {e}"), + } + } + } + } +} + +/// Parse a do-date input to `Some(epoch_ms)`, or `None` when blank. +fn parse_optional_date(s: &str) -> Result<Option<i64>> { + let s = s.trim(); + if s.is_empty() { + Ok(None) + } else { + Ok(Some(hephd::datespec::parse_date_ms(s)?)) + } } diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 223e9b2..9b783fc 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -47,6 +47,7 @@ pub trait Backend { title: &str, attention: Option<Attention>, do_date: Option<i64>, + recurrence: Option<&str>, project_id: Option<&str>, ) -> Result<String>; } @@ -142,6 +143,7 @@ impl Backend for ClientBackend { title: &str, attention: Option<Attention>, do_date: Option<i64>, + recurrence: Option<&str>, project_id: Option<&str>, ) -> Result<String> { let v = self.call( @@ -150,6 +152,7 @@ impl Backend for ClientBackend { "title": title, "attention": attention, "do_date": do_date, + "recurrence": recurrence, "project_id": project_id, }), )?; diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs index 83efc5c..c49e995 100644 --- a/crates/heph-tui/src/lib.rs +++ b/crates/heph-tui/src/lib.rs @@ -10,6 +10,7 @@ pub mod app; pub mod backend; pub mod editor; pub mod fmt; +pub mod quickadd; pub mod ui; pub use app::{App, Focus}; diff --git a/crates/heph-tui/src/quickadd.rs b/crates/heph-tui/src/quickadd.rs new file mode 100644 index 0000000..5153746 --- /dev/null +++ b/crates/heph-tui/src/quickadd.rs @@ -0,0 +1,223 @@ +//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style +//! capture: `Water plants tomorrow p2 #Chores every 3 days`. +//! +//! Pure and deterministic: `today` and the known projects are passed in, so the +//! whole parser is unit-testable. Recognized inline tokens are extracted and the +//! remainder is the title (order preserved). The recognized forms mirror the +//! owner's Todoist usage ([[design]] §6.2.1): +//! +//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white). +//! - **Project** `#Name` — resolved against existing projects, greedily matching +//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the +//! title verbatim (no surprise project creation). +//! - **Do-date** a `datespec` token: `today`/`tomorrow`/`+3d`/`fri`/ISO. +//! - **Recurrence** an `every …` phrase (the longest suffix that parses), e.g. +//! `every 3 days`, `every workday`, `every other wed`. + +use chrono::NaiveDate; +use heph_core::Attention; + +use crate::backend::Project; + +/// The structured result of parsing a quick-add line. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Parsed { + pub title: String, + pub attention: Option<Attention>, + pub do_date: Option<i64>, + /// An RFC-5545 RRULE, if a recurrence phrase was recognized. + pub recurrence: Option<String>, + pub project_id: Option<String>, +} + +fn priority_attention(token: &str) -> Option<Attention> { + match token.to_ascii_lowercase().as_str() { + "p1" => Some(Attention::Red), + "p2" => Some(Attention::Orange), + "p3" => Some(Attention::Blue), + "p4" => Some(Attention::White), + _ => None, + } +} + +/// Parse a quick-add line against `today` and the `projects` known to the store. +pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed { + let mut tokens: Vec<String> = input.split_whitespace().map(str::to_string).collect(); + let mut out = Parsed::default(); + + extract_recurrence(&mut tokens, &mut out); + + let mut title: Vec<String> = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let tok = &tokens[i]; + + if let Some(a) = priority_attention(tok) { + out.attention = Some(a); + i += 1; + continue; + } + + if let Some(stripped) = tok.strip_prefix('#') { + if let Some((id, consumed)) = match_project(stripped, &tokens[i + 1..], projects) { + out.project_id = Some(id); + i += 1 + consumed; + continue; + } + // Unresolved #tag: keep the word (with the #) in the title. + } + + if out.do_date.is_none() { + if let Ok(date) = hephd::datespec::parse_date(tok, today) { + out.do_date = Some(hephd::datespec::to_epoch_ms(date)); + i += 1; + continue; + } + } + + title.push(tok.clone()); + i += 1; + } + + out.title = title.join(" "); + out +} + +/// Find the first `every` token and consume the longest suffix starting there +/// that `datespec::parse_recurrence` accepts, recording its RRULE. +fn extract_recurrence(tokens: &mut Vec<String>, out: &mut Parsed) { + let Some(start) = tokens.iter().position(|t| t.eq_ignore_ascii_case("every")) else { + return; + }; + for end in (start + 1..=tokens.len()).rev() { + let phrase = tokens[start..end].join(" "); + if let Ok(rrule) = hephd::datespec::parse_recurrence(&phrase) { + out.recurrence = Some(rrule); + tokens.drain(start..end); + return; + } + } +} + +/// Greedily match `first` (+ following words) against a known project title, +/// case-insensitively, longest-first. Returns `(project_id, extra_words_taken)`. +fn match_project(first: &str, rest: &[String], projects: &[Project]) -> Option<(String, usize)> { + // Try the longest candidate (up to 4 trailing words) down to just `first`. + let max_extra = rest.len().min(4); + for extra in (0..=max_extra).rev() { + let mut candidate = first.to_string(); + for w in &rest[..extra] { + candidate.push(' '); + candidate.push_str(w); + } + if let Some(p) = projects + .iter() + .find(|p| p.title.eq_ignore_ascii_case(&candidate)) + { + return Some((p.id.clone(), extra)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 6, 3).unwrap() + } + + fn projects() -> Vec<Project> { + vec![ + Project { + id: "work".into(), + title: "Work".into(), + }, + Project { + id: "camano".into(), + title: "Camano Chores".into(), + }, + ] + } + + fn p(input: &str) -> Parsed { + parse(input, today(), &projects()) + } + + fn ms(y: i32, m: u32, d: u32) -> i64 { + hephd::datespec::to_epoch_ms(NaiveDate::from_ymd_opt(y, m, d).unwrap()) + } + + #[test] + fn plain_title() { + let r = p("Buy milk"); + assert_eq!(r.title, "Buy milk"); + assert_eq!(r.attention, None); + assert_eq!(r.do_date, None); + assert_eq!(r.recurrence, None); + assert_eq!(r.project_id, None); + } + + #[test] + fn priority_maps_to_attention() { + assert_eq!(p("Email boss p1").attention, Some(Attention::Red)); + assert_eq!(p("Email boss p2").attention, Some(Attention::Orange)); + assert_eq!(p("Email boss p3").attention, Some(Attention::Blue)); + assert_eq!(p("Email boss p4").attention, Some(Attention::White)); + assert_eq!(p("Email boss p1").title, "Email boss"); + } + + #[test] + fn relative_date_is_extracted() { + let r = p("Call dentist tomorrow"); + assert_eq!(r.title, "Call dentist"); + assert_eq!(r.do_date, Some(ms(2026, 6, 4))); + } + + #[test] + fn single_word_project_resolves() { + let r = p("Standup #Work"); + assert_eq!(r.title, "Standup"); + assert_eq!(r.project_id.as_deref(), Some("work")); + } + + #[test] + fn multi_word_project_resolves_greedily() { + let r = p("Sweep deck #Camano Chores"); + assert_eq!(r.title, "Sweep deck"); + assert_eq!(r.project_id.as_deref(), Some("camano")); + } + + #[test] + fn unresolved_tag_stays_in_title() { + let r = p("Buy #groceries milk"); + assert_eq!(r.title, "Buy #groceries milk"); + assert_eq!(r.project_id, None); + } + + #[test] + fn recurrence_phrase_is_extracted() { + let r = p("Water plants every 3 days"); + assert_eq!(r.title, "Water plants"); + assert_eq!(r.recurrence.as_deref(), Some("FREQ=DAILY;INTERVAL=3")); + } + + #[test] + fn everything_at_once() { + let r = p("Plan trip p2 friday #Work every week"); + assert_eq!(r.title, "Plan trip"); + assert_eq!(r.attention, Some(Attention::Orange)); + assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday + assert_eq!(r.project_id.as_deref(), Some("work")); + assert_eq!(r.recurrence.as_deref(), Some("FREQ=WEEKLY")); + } + + #[test] + fn non_recurrence_every_stays_in_title() { + // "every report" isn't a recurrence; leave it alone. + let r = p("Review every report"); + assert_eq!(r.title, "Review every report"); + assert_eq!(r.recurrence, None); + } +} diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index cf82816..518162e 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -153,6 +153,51 @@ fn completing_a_task_removes_it_from_top_of_mind() { assert!(screen(&app).contains("nothing here")); } +fn type_and_submit<B: heph_tui::Backend>(app: &mut App<B>, s: &str) { + for ch in s.chars() { + app.input_push(ch); + } + app.input_submit(); +} + +#[test] +fn quick_add_captures_a_task_that_appears_in_the_view() { + let (socket, _dir) = spawn_daemon(); + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert!(app.tasks.is_empty()); + + app.begin_add(); + // Single-line NL: p1 → red, so it lands in Top of Mind (the default view). + type_and_submit(&mut app, "Call the plumber p1"); + + assert!(app.status.contains("added"), "status: {}", app.status); + assert!( + app.tasks.iter().any(|t| t.title == "Call the plumber"), + "added task missing from Top of Mind" + ); +} + +#[test] +fn reschedule_sets_a_do_date_on_the_task() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + let task = c + .call( + "task.create", + json!({ "title": "Mail the form", "attention": "red" }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + app.begin_reschedule(); + type_and_submit(&mut app, "today"); + + // Verify directly via the daemon (the view may drop it depending on clocks). + let got = c.call("task.get", json!({ "id": id })).unwrap(); + assert!(!got["do_date"].is_null(), "do_date was not set: {got}"); +} + #[test] fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index d5fb939..4d1e8cb 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -1,7 +1,9 @@ //! Navigation/selection logic against an in-memory fake backend — no terminal, //! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1). +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use anyhow::Result; use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState}; @@ -10,6 +12,23 @@ use heph_tui::{ backend::{Backend, Project}, }; +/// A recorded `create_task`: (title, attention, do_date, recurrence, project_id). +type CreatedTask = ( + String, + Option<Attention>, + Option<i64>, + Option<String>, + Option<String>, +); + +/// Records mutations the App makes, so tests can assert on them after the App +/// has consumed the backend. +#[derive(Default)] +struct Recorder { + created: Vec<CreatedTask>, + scheduled: Vec<(String, SchedulePatch)>, +} + fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { RankedTask { node_id: id.into(), @@ -31,6 +50,7 @@ struct Fake { projects: Vec<Project>, by_project: HashMap<String, Vec<RankedTask>>, bodies: HashMap<String, String>, + rec: Rc<RefCell<Recorder>>, } impl Backend for Fake { @@ -59,16 +79,25 @@ impl Backend for Fake { fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> { Ok(()) } - fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> { + fn set_schedule(&mut self, t: &str, p: SchedulePatch) -> Result<()> { + self.rec.borrow_mut().scheduled.push((t.into(), p)); Ok(()) } fn create_task( &mut self, - _title: &str, - _a: Option<Attention>, - _d: Option<i64>, - _p: Option<&str>, + title: &str, + a: Option<Attention>, + d: Option<i64>, + recur: Option<&str>, + p: Option<&str>, ) -> Result<String> { + self.rec.borrow_mut().created.push(( + title.into(), + a, + d, + recur.map(str::to_string), + p.map(str::to_string), + )); Ok("new".into()) } } @@ -157,3 +186,82 @@ fn attention_cycles_white_orange_red_blue() { assert_eq!(next_attention(Some(Attention::Blue)), Attention::White); assert_eq!(next_attention(None), Attention::White); } + +fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) { + for c in s.chars() { + app.input_push(c); + } + app.input_submit(); +} + +#[test] +fn quick_add_files_under_the_current_project_when_no_tag_given() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + // Select the project so the new task is filed there. + for _ in 0..5 { + app.move_sidebar(1); + } + assert_eq!(app.task_pane_title(), "Camano"); + + app.begin_add(); + type_and_submit(&mut app, "Fix the dock p2"); + + let created = &rec.borrow().created; + assert_eq!(created.len(), 1); + assert_eq!(created[0].0, "Fix the dock"); + assert_eq!(created[0].1, Some(Attention::Orange)); // p2 + assert_eq!(created[0].2, None); // no do-date + assert_eq!(created[0].3, None); // no recurrence + assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) +} + +#[test] +fn quick_add_passes_inline_recurrence_and_project_through() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_add(); + // #Camano resolves to the fixture project id "p1"; "every week" → weekly. + type_and_submit(&mut app, "Water the ferns #Camano every week"); + + let created = &rec.borrow().created; + assert_eq!(created.len(), 1); + assert_eq!(created[0].0, "Water the ferns"); + assert_eq!(created[0].3.as_deref(), Some("FREQ=WEEKLY")); + assert_eq!(created[0].4.as_deref(), Some("p1")); +} + +#[test] +fn empty_title_cancels_the_add() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_add(); + type_and_submit(&mut app, ""); // empty title aborts + assert!(rec.borrow().created.is_empty()); +} + +#[test] +fn reschedule_with_blank_clears_the_do_date() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_reschedule(); // on the first ToM task (t1) + type_and_submit(&mut app, ""); // blank => clear + + let scheduled = &rec.borrow().scheduled; + assert_eq!(scheduled.len(), 1); + assert_eq!(scheduled[0].0, "t1"); + // do_date present-and-null = "clear" (the double-option). + assert_eq!(scheduled[0].1.do_date, Some(None)); +} -- 2.50.1 (Apple Git-155) From 3099034d43f3750338a25d98623a4b3f154cb3c1 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:33:37 -0700 Subject: [PATCH 52/91] docs(tui): NL quick-add done; T3 remainder is search + move-to-project Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 6771681..e1c8663 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -23,4 +23,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). -- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). Single-line natural-language quick-add and search are still to come. +- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). In-TUI search is still to come. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index ae596c0..0140bba 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,7 +254,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` add (guided: title→attention→do-date, filed under the selected project) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. *(T3: a single-line NL quick-add à la Todoist, and `/` search.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. *(Remaining T3 polish: `/` search and move-to-project.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. @@ -380,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **171 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **181 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** @@ -410,13 +410,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - **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. - **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI. - ✅ **Filter views (§8.2) — saved agenda slices:** the owner's saved filters are first-class so the agenda isn't one flat list. New **`heph-core::filter`**: a `ListFilter` **predicate-as-data** (`attention_in`/`attention_not` sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate) + the five built-in [`ViewSpec`]s (Top of Mind / On Deck / Chores / Work Tasks / Tasks — **Schedule dropped**, §8.2). `Store::list` now takes a `ListFilter`; new `Store::view(name)` resolves a spec's project **names** → ids and **subtree-expands** them through `parent` links (`links::project_subtree`/`resolve_project_id`), tolerating absent projects (a scoped view whose projects are all missing returns empty, never widens). Surfaces: `heph view <name>` (no name lists the five), the **`view` RPC** + `RemoteStore` forward, and `:Heph view <name>` in nvim (`heph://view/<name>` buffers). The `list` RPC/`RemoteStore`/CLI/`heph.nvim` migrated to the filter wire (legacy `--scope`/`--attention`/`--no-blue` map onto it). Tested: `filter` unit predicate, a `views` integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket `list`/`view` dispatch test, and two nvim e2e specs. -- ✅ **`heph-tui` (§8.1) — the task agenda/triage surface, daily-driver core (slices T1–T2c):** a `ratatui` terminal UI, thin client of the daemon socket (never touches SQLite). **3-pane layout** — sidebar (the five §8.2 views + projects) · attention-colored task list with compact human do/late · preview (canonical-context body + `log.tail`). `App` is generic over a `Backend` seam (unit-testable without a terminal/daemon); `ui::render` is pure. **Gestures:** `j/k`/`Tab`/`h`/`l` navigate; `a` guided add (title→attention→do-date, filed under the selected project); `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push-to-blue, `e` reschedule do-date; `o` suspends and opens the task's context doc in nvim (`+lua require('heph.node').open`, `$HEPH_SOCKET` shared) then reloads. Shared client date/recurrence parsing (`datespec`) moved from the CLI into `hephd`'s lib so both surfaces use one parser (heph-core stays clock-pure). Tested: `TestBackend` render assertions against a real spawned daemon + in-memory navigation/input-flow units. *(Remaining T3: a single-line NL quick-add like Todoist, `/` search, move-to-project.)* +- ✅ **`heph-tui` (§8.1) — the task agenda/triage surface, daily-driver core (slices T1–T2c):** a `ratatui` terminal UI, thin client of the daemon socket (never touches SQLite). **3-pane layout** — sidebar (the five §8.2 views + projects) · attention-colored task list with compact human do/late · preview (canonical-context body + `log.tail`). `App` is generic over a `Backend` seam (unit-testable without a terminal/daemon); `ui::render` is pure. **Gestures:** `j/k`/`Tab`/`h`/`l` navigate; `a` guided add (title→attention→do-date, filed under the selected project); `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push-to-blue, `e` reschedule do-date; `o` suspends and opens the task's context doc in nvim (`+lua require('heph.node').open`, `$HEPH_SOCKET` shared) then reloads. Shared client date/recurrence parsing (`datespec`) moved from the CLI into `hephd`'s lib so both surfaces use one parser (heph-core stays clock-pure). `a` is a **single-line NL quick-add** (`quickadd::parse`, pure + exhaustively unit-tested): `Buy milk tomorrow p2 #Camano Chores every 3 days` → title + attention + do-date + recurrence + (greedy multi-word) project, reusing `datespec`. Tested: `TestBackend` render assertions against a real spawned daemon + in-memory navigation/input-flow/quick-add units. *(Remaining T3 polish: `/` search, move-to-project.)* **Not yet done (resume order)** > The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist ([[design]] §6.2.1), **filter views (§8.2) are built** (`heph view`), and the **`heph-tui` daily-driver core is built** (§8.1, T1–T2c). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (core done, T3 polish remains), **nvim = context/KB**. Remaining work, in order: -1. ⏳ **`heph-tui` T3 — quick-add + search polish (§8.1):** a single-line **NL quick-add** (`Buy milk tomorrow p2 #Work every week`, reusing `datespec`), `/` **search**, and move-to-project. The daily-driver gestures (T1–T2c) are done; this closes Todoist-parity capture. +1. ⏳ **`heph-tui` T3 — remaining polish (§8.1):** **NL quick-add is done** (`a` parses `Buy milk tomorrow p2 #Work every week`); still to add: `/` **search** and **move-to-project**. Closes the last of Todoist-parity capture. 2. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). 3. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduced) is a refinement. 4. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -- 2.50.1 (Apple Git-155) From dc8e06ecaaf116d6b1ced28348f50560db4db8e1 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:40:21 -0700 Subject: [PATCH 53/91] =?UTF-8?q?feat(tui):=20heph-tui=20T3=20=E2=80=94=20?= =?UTF-8?q?full-text=20search=20overlay=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/` opens a search prompt; submitting runs the FTS `search` RPC and overlays the results on the center pane (title + [kind]). j/k move, Enter opens the hit (a task hit opens its canonical-context doc via context_of; docs/journals open themselves) in nvim, Esc exits search. Backend gained `search` + `context_of`. Tests: fake-backend flow (results populate; task hit resolves to its context, doc hit to itself; clear) + a real-daemon integration test (seed a doc, search, assert the hit + that the Search pane renders). 183 workspace tests; clippy/fmt clean. Move-to-project is the last Todoist-parity gap; it needs a new task.set_project RPC (no link-remove RPC yet) and is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 74 +++++++++++++++++++++++- crates/heph-tui/src/backend.rs | 35 +++++++++++ crates/heph-tui/src/main.rs | 42 ++++++++++++-- crates/heph-tui/src/ui.rs | 90 +++++++++++++++++++++++++++-- crates/heph-tui/tests/agenda.rs | 23 ++++++++ crates/heph-tui/tests/navigation.rs | 46 ++++++++++++++- 6 files changed, 299 insertions(+), 11 deletions(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 19442bc..6ff1528 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -5,7 +5,7 @@ use anyhow::Result; use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; -use crate::backend::{Backend, Project}; +use crate::backend::{Backend, Project, SearchHit}; /// The interaction mode: normal navigation, or collecting a line of text. #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,6 +31,17 @@ enum InputKind { Reschedule { task_id: String, }, + /// Full-text search query. + Search, +} + +/// An active full-text search: the query, its hits, and the highlighted row. +/// While `Some` on the App, the center pane shows results instead of tasks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchView { + pub query: String, + pub results: Vec<SearchHit>, + pub cursor: usize, } /// The attention cycle for the `A` gesture: default → top-of-mind → consequence @@ -93,6 +104,8 @@ pub struct App<B: Backend> { pub preview: Preview, pub focus: Focus, pub mode: Mode, + /// When `Some`, a full-text search overlays the task list. + pub search: Option<SearchView>, pub status: String, pub should_quit: bool, } @@ -129,6 +142,7 @@ impl<B: Backend> App<B> { preview: Preview::default(), focus: Focus::Sidebar, mode: Mode::Normal, + search: None, status: String::new(), should_quit: false, }; @@ -378,6 +392,48 @@ impl<B: Backend> App<B> { }); } + /// Open the full-text search prompt. + pub fn begin_search(&mut self) { + self.mode = Mode::Input(InputState { + prompt: "Search".into(), + buffer: String::new(), + kind: InputKind::Search, + }); + } + + /// Close the search overlay, returning to the selected view's task list. + pub fn clear_search(&mut self) { + self.search = None; + } + + /// Move the search-results cursor by `delta` (clamped). + pub fn search_move(&mut self, delta: isize) { + if let Some(s) = &mut self.search { + if s.results.is_empty() { + return; + } + let max = s.results.len() as isize - 1; + s.cursor = (s.cursor as isize + delta).clamp(0, max) as usize; + } + } + + /// The node id to open for the highlighted search hit — a task's + /// canonical-context doc, or the node itself. `None` if no hit is selected. + pub fn search_open_target(&mut self) -> Option<String> { + let hit = self + .search + .as_ref()? + .results + .get(self.search.as_ref()?.cursor)?; + let (id, is_task) = (hit.id.clone(), hit.kind == "task"); + if is_task { + if let Ok(Some(ctx)) = self.backend.context_of(&id) { + return Some(ctx); + } + } + Some(id) + } + /// Append a typed character to the active input. pub fn input_push(&mut self, c: char) { if let Mode::Input(state) = &mut self.mode { @@ -443,6 +499,22 @@ impl<B: Backend> App<B> { Err(e) => self.status = format!("error: {e}"), } } + InputKind::Search => { + if buf.is_empty() { + return; + } + match self.backend.search(&buf) { + Ok(results) => { + self.status = format!("{} result(s) for {buf:?}", results.len()); + self.search = Some(SearchView { + query: buf, + results, + cursor: 0, + }); + } + Err(e) => self.status = format!("error: {e}"), + } + } InputKind::Reschedule { task_id } => { let patch = if buf.is_empty() { SchedulePatch { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 9b783fc..2664948 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -17,6 +17,14 @@ pub struct Project { pub title: String, } +/// A full-text search result row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchHit { + pub id: String, + pub title: String, + pub kind: String, +} + /// Everything the agenda surface asks of the daemon. pub trait Backend { /// All project nodes (for the sidebar), title-sorted. @@ -30,6 +38,11 @@ pub trait Backend { fn node_body(&mut self, id: &str) -> Result<String>; /// The last `n` log lines for a task (the resumption breadcrumb). fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>; + /// Full-text search over titles + bodies (FTS5), best-match first. + fn search(&mut self, query: &str) -> Result<Vec<SearchHit>>; + /// A task's canonical-context doc id (where its description/checklist live), + /// for opening a task search-hit at the useful node. `None` if it has none. + fn context_of(&mut self, task_id: &str) -> Result<Option<String>>; // --- triage mutations (T2) --- @@ -108,6 +121,28 @@ impl Backend for ClientBackend { Ok(serde_json::from_value(v)?) } + fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> { + let v = self.call("search", json!({ "query": query }))?; + let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?; + Ok(nodes + .into_iter() + .map(|n| SearchHit { + id: n.id, + title: n.title, + kind: n.kind.as_str().to_string(), + }) + .collect()) + } + + fn context_of(&mut self, task_id: &str) -> Result<Option<String>> { + let v = self.call("links.outgoing", json!({ "id": task_id }))?; + let links: Vec<heph_core::Link> = serde_json::from_value(v)?; + Ok(links + .into_iter() + .find(|l| l.link_type == heph_core::LinkType::CanonicalContext) + .map(|l| l.dst_id)) + } + fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { self.call("task.set_state", json!({ "id": task_id, "state": state }))?; Ok(()) diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index ee28461..6a651a7 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -5,7 +5,11 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::Parser; -use heph_tui::{app::App, backend::ClientBackend, editor, ui, Focus}; +use heph_tui::{ + app::{App, Mode}, + backend::ClientBackend, + editor, ui, Focus, +}; use hephd::Client; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -89,14 +93,40 @@ fn perform<B: heph_tui::Backend>( /// Translate a key press into an [`App`] mutation and/or an [`Action`] for the /// event loop to perform. fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<Action> { - // Any keypress clears a stale status message. - app.status.clear(); - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { app.should_quit = true; return None; } + // While collecting input, all keys go to the prompt. + if matches!(app.mode, Mode::Input(_)) { + match key.code { + KeyCode::Esc => app.input_cancel(), + KeyCode::Enter => app.input_submit(), + KeyCode::Backspace => app.input_backspace(), + KeyCode::Char(c) => app.input_push(c), + _ => {} + } + return None; + } + + // While search results are shown, the center pane navigates them. + if app.search.is_some() { + app.status.clear(); + match key.code { + KeyCode::Esc => app.clear_search(), + KeyCode::Char('j') | KeyCode::Down => app.search_move(1), + KeyCode::Char('k') | KeyCode::Up => app.search_move(-1), + KeyCode::Enter => return app.search_open_target().map(Action::EditContext), + KeyCode::Char('q') => app.should_quit = true, + _ => {} + } + return None; + } + + // Any other keypress clears a stale status message. + app.status.clear(); + match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('r') => app.reload(), @@ -105,6 +135,10 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A KeyCode::Char('k') | KeyCode::Up => move_up(app), KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + // capture + reschedule + search (open an input prompt) + KeyCode::Char('a') => app.begin_add(), + KeyCode::Char('e') => app.begin_reschedule(), + KeyCode::Char('/') => app.begin_search(), // triage mutations (act on the highlighted task) KeyCode::Char('x') => app.complete_selected(), KeyCode::Char('d') => app.drop_selected(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index a26c2e2..fbe0090 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -6,16 +6,18 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, Frame, }; -use crate::app::{App, Focus, SidebarEntry}; +use crate::app::{App, Focus, InputState, Mode, SidebarEntry}; use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; const HINTS: &str = - " j/k move Tab pane x done d drop s skip A attn b→blue o edit r refresh q quit"; + " j/k move Tab pane a add x done e date A attn b→blue o edit / search q quit"; + +const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; /// Draw the whole UI for the current frame. pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { @@ -34,9 +36,42 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { .split(outer[0]); render_sidebar(frame, app, panes[0]); - render_tasks(frame, app, panes[1]); + if app.search.is_some() { + render_search(frame, app, panes[1]); + } else { + render_tasks(frame, app, panes[1]); + } render_preview(frame, app, panes[2]); render_status(frame, app, outer[1]); + + if let Mode::Input(state) = &app.mode { + render_input(frame, state); + } +} + +/// A centered single-line input popup (guided add / reschedule). +fn render_input(frame: &mut Frame, state: &InputState) { + let area = frame.area(); + let width = area.width.saturating_sub(8).clamp(20, 70); + let popup = Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + area.height / 3, + width, + height: 3, + }; + frame.render_widget(Clear, popup); + let line = Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::raw(&state.buffer), + Span::styled("▏", Style::default().fg(Color::Cyan)), // cursor + ]); + let para = Paragraph::new(line).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" {} ", state.prompt)), + ); + frame.render_widget(para, popup); } fn pane_border(focused: bool) -> Style { @@ -167,6 +202,46 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { frame.render_widget(list, area); } +fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + let Some(s) = &app.search else { return }; + let items: Vec<ListItem> = if s.results.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " (no matches — Esc to exit search)", + Style::default().fg(Color::DarkGray), + )))] + } else { + s.results + .iter() + .enumerate() + .map(|(i, hit)| { + let selected = i == s.cursor; + let title_style = if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let cursor = if selected { "▌" } else { " " }; + ListItem::new(Line::from(vec![ + Span::styled(cursor, Style::default().fg(Color::Cyan)), + Span::styled( + format!("[{}] ", hit.kind), + Style::default().fg(Color::DarkGray), + ), + Span::styled(hit.title.clone(), title_style), + ])) + }) + .collect() + }; + let title = format!(" Search: {} ({}) ", s.query, s.results.len()); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(title), + ); + frame.render_widget(list, area); +} + fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { let mut lines: Vec<Line> = Vec::new(); if !app.preview.title.is_empty() { @@ -203,8 +278,13 @@ fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { } fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + let hints = if app.search.is_some() { + SEARCH_HINTS + } else { + HINTS + }; let text = if app.status.is_empty() { - HINTS.to_string() + hints.to_string() } else { format!(" {}", app.status) }; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 518162e..25e9d0c 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -177,6 +177,29 @@ fn quick_add_captures_a_task_that_appears_in_the_view() { ); } +#[test] +fn search_finds_a_matching_node_and_overlays_results() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + c.call( + "node.create", + json!({ "kind": "doc", "title": "Plumbing notes", "body": "shutoff valve is in the garage" }), + ) + .unwrap(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + app.begin_search(); + type_and_submit(&mut app, "plumbing"); + + let s = app.search.as_ref().expect("search active"); + assert!( + s.results.iter().any(|h| h.title == "Plumbing notes"), + "search missed the doc: {:?}", + s.results + ); + assert!(screen(&app).contains("Search:"), "search pane not rendered"); +} + #[test] fn reschedule_sets_a_do_date_on_the_task() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 4d1e8cb..2ed1694 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -9,7 +9,7 @@ use anyhow::Result; use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState}; use heph_tui::{ app::{App, Focus}, - backend::{Backend, Project}, + backend::{Backend, Project, SearchHit}, }; /// A recorded `create_task`: (title, attention, do_date, recurrence, project_id). @@ -50,6 +50,8 @@ struct Fake { projects: Vec<Project>, by_project: HashMap<String, Vec<RankedTask>>, bodies: HashMap<String, String>, + search_hits: Vec<SearchHit>, + contexts: HashMap<String, String>, rec: Rc<RefCell<Recorder>>, } @@ -70,6 +72,17 @@ impl Backend for Fake { fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> { Ok(Vec::new()) } + fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> { + Ok(self + .search_hits + .iter() + .filter(|h| h.title.to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect()) + } + fn context_of(&mut self, task_id: &str) -> Result<Option<String>> { + Ok(self.contexts.get(task_id).cloned()) + } fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> { Ok(()) } @@ -219,6 +232,37 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) } +#[test] +fn search_populates_results_and_task_hits_open_their_context() { + let mut fake = fixture(); + fake.search_hits = vec![ + SearchHit { + id: "doc1".into(), + title: "Roof notes".into(), + kind: "doc".into(), + }, + SearchHit { + id: "task1".into(), + title: "Roof repair".into(), + kind: "task".into(), + }, + ]; + fake.contexts.insert("task1".into(), "ctx1".into()); + let mut app = App::new(fake).unwrap(); + + app.begin_search(); + type_and_submit(&mut app, "roof"); + assert_eq!(app.search.as_ref().unwrap().results.len(), 2); + + // A doc hit opens itself; a task hit opens its canonical-context doc. + assert_eq!(app.search_open_target().as_deref(), Some("doc1")); + app.search_move(1); + assert_eq!(app.search_open_target().as_deref(), Some("ctx1")); + + app.clear_search(); + assert!(app.search.is_none()); +} + #[test] fn quick_add_passes_inline_recurrence_and_project_through() { let rec = Rc::new(RefCell::new(Recorder::default())); -- 2.50.1 (Apple Git-155) From ffec575249c1ceb1842a70d76f08ace254d6096d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:41:12 -0700 Subject: [PATCH 54/91] docs(tui): FTS search done; move-to-project (needs task.set_project) is the last gap Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index e1c8663..bb97724 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -23,4 +23,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). -- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). In-TUI search is still to come. +- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. Re-filing a task to a different project is the one remaining capture gap. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 0140bba..514d524 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,7 +254,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. *(Remaining T3 polish: `/` search and move-to-project.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. @@ -380,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **181 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **183 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** @@ -410,13 +410,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - **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. - **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI. - ✅ **Filter views (§8.2) — saved agenda slices:** the owner's saved filters are first-class so the agenda isn't one flat list. New **`heph-core::filter`**: a `ListFilter` **predicate-as-data** (`attention_in`/`attention_not` sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate) + the five built-in [`ViewSpec`]s (Top of Mind / On Deck / Chores / Work Tasks / Tasks — **Schedule dropped**, §8.2). `Store::list` now takes a `ListFilter`; new `Store::view(name)` resolves a spec's project **names** → ids and **subtree-expands** them through `parent` links (`links::project_subtree`/`resolve_project_id`), tolerating absent projects (a scoped view whose projects are all missing returns empty, never widens). Surfaces: `heph view <name>` (no name lists the five), the **`view` RPC** + `RemoteStore` forward, and `:Heph view <name>` in nvim (`heph://view/<name>` buffers). The `list` RPC/`RemoteStore`/CLI/`heph.nvim` migrated to the filter wire (legacy `--scope`/`--attention`/`--no-blue` map onto it). Tested: `filter` unit predicate, a `views` integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket `list`/`view` dispatch test, and two nvim e2e specs. -- ✅ **`heph-tui` (§8.1) — the task agenda/triage surface, daily-driver core (slices T1–T2c):** a `ratatui` terminal UI, thin client of the daemon socket (never touches SQLite). **3-pane layout** — sidebar (the five §8.2 views + projects) · attention-colored task list with compact human do/late · preview (canonical-context body + `log.tail`). `App` is generic over a `Backend` seam (unit-testable without a terminal/daemon); `ui::render` is pure. **Gestures:** `j/k`/`Tab`/`h`/`l` navigate; `a` guided add (title→attention→do-date, filed under the selected project); `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push-to-blue, `e` reschedule do-date; `o` suspends and opens the task's context doc in nvim (`+lua require('heph.node').open`, `$HEPH_SOCKET` shared) then reloads. Shared client date/recurrence parsing (`datespec`) moved from the CLI into `hephd`'s lib so both surfaces use one parser (heph-core stays clock-pure). `a` is a **single-line NL quick-add** (`quickadd::parse`, pure + exhaustively unit-tested): `Buy milk tomorrow p2 #Camano Chores every 3 days` → title + attention + do-date + recurrence + (greedy multi-word) project, reusing `datespec`. Tested: `TestBackend` render assertions against a real spawned daemon + in-memory navigation/input-flow/quick-add units. *(Remaining T3 polish: `/` search, move-to-project.)* +- ✅ **`heph-tui` (§8.1) — the task agenda/triage surface, daily-driver core (slices T1–T2c):** a `ratatui` terminal UI, thin client of the daemon socket (never touches SQLite). **3-pane layout** — sidebar (the five §8.2 views + projects) · attention-colored task list with compact human do/late · preview (canonical-context body + `log.tail`). `App` is generic over a `Backend` seam (unit-testable without a terminal/daemon); `ui::render` is pure. **Gestures:** `j/k`/`Tab`/`h`/`l` navigate; `a` guided add (title→attention→do-date, filed under the selected project); `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push-to-blue, `e` reschedule do-date; `o` suspends and opens the task's context doc in nvim (`+lua require('heph.node').open`, `$HEPH_SOCKET` shared) then reloads. Shared client date/recurrence parsing (`datespec`) moved from the CLI into `hephd`'s lib so both surfaces use one parser (heph-core stays clock-pure). `a` is a **single-line NL quick-add** (`quickadd::parse`, pure + exhaustively unit-tested): `Buy milk tomorrow p2 #Camano Chores every 3 days` → title + attention + do-date + recurrence + (greedy multi-word) project, reusing `datespec`. Tested: `TestBackend` render assertions against a real spawned daemon + in-memory navigation/input-flow/quick-add/search units. **`/` runs FTS `search`** as an overlay (Enter opens a hit in nvim — task hits at their context doc). *(Remaining: move-to-project, which needs a `task.set_project` RPC.)* **Not yet done (resume order)** > The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist ([[design]] §6.2.1), **filter views (§8.2) are built** (`heph view`), and the **`heph-tui` daily-driver core is built** (§8.1, T1–T2c). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (core done, T3 polish remains), **nvim = context/KB**. Remaining work, in order: -1. ⏳ **`heph-tui` T3 — remaining polish (§8.1):** **NL quick-add is done** (`a` parses `Buy milk tomorrow p2 #Work every week`); still to add: `/` **search** and **move-to-project**. Closes the last of Todoist-parity capture. +1. ⏳ **`heph-tui` — move-to-project (§8.1) — last Todoist-parity gap:** NL quick-add and `/` FTS search are **done**; re-filing a task's project needs a new **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — there's no link-remove RPC today). Small backend slice + a TUI gesture (`m`). 2. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). 3. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduced) is a refinement. 4. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -- 2.50.1 (Apple Git-155) From 391277c939868676b81b9bbea21dccfeff2693c7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 07:45:27 -0700 Subject: [PATCH 55/91] fix(build): commit the datespec-move wiring (orphaned in T2c) The T2c commit moved datespec.rs into hephd but left its wiring uncommitted: hephd's lib never exported `pub mod datespec`, hephd lacked the chrono dep, and the CLI still declared `mod datespec` for a file that had moved. The working tree had these (so local builds passed) but the pushed tree didn't, breaking `cargo install` of heph + heph-tui. Commit them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- Cargo.lock | 1 + crates/heph/src/main.rs | 3 +-- crates/hephd/Cargo.toml | 1 + crates/hephd/src/datespec.rs | 7 ++++--- crates/hephd/src/lib.rs | 1 + 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c34e345..ee18d03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1286,6 +1286,7 @@ dependencies = [ "anyhow", "axum", "base64", + "chrono", "clap", "fs4", "heph-core", diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 02dbb6d..a383a0b 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -13,9 +13,8 @@ use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use heph_core::{Node, RankedTask, Task}; -use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; +use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; -mod datespec; mod service; #[derive(Parser, Debug)] diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index 807feb4..facf7d1 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] heph-core = { path = "../heph-core" } +chrono.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/hephd/src/datespec.rs b/crates/hephd/src/datespec.rs index d37ed21..1ffadfe 100644 --- a/crates/hephd/src/datespec.rs +++ b/crates/hephd/src/datespec.rs @@ -1,7 +1,8 @@ -//! Human-friendly date and recurrence parsing for the CLI (tech-spec §1, §8). +//! Human-friendly date and recurrence parsing for client surfaces — the CLI +//! and the TUI (tech-spec §1, §8, §8.1). //! -//! `heph-core` is clock-pure (no ambient wall-clock reads); the CLI is a client, -//! so it may read the local clock. Date parsing is split so the logic is +//! `heph-core` is clock-pure (no ambient wall-clock reads); clients may read the +//! local clock. Date parsing is split so the logic is //! deterministically testable: the pure functions take `today` / a year, and the //! thin wrappers supply `Local::now()`. //! diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index ef80eb1..d4521b1 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -10,6 +10,7 @@ pub mod auth; pub mod client; pub mod clock; +pub mod datespec; pub mod lock; pub mod oauth; pub mod remote; -- 2.50.1 (Apple Git-155) From 1833863594bd102dc7b4f37ed80ecf0e2a6328c6 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 08:08:16 -0700 Subject: [PATCH 56/91] =?UTF-8?q?feat(tui):=20recurring-task=20glyph=20+?= =?UTF-8?q?=20inline=20detail=20under=20the=20selected=20row=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recurring tasks now show a ↻ marker on their row, and the highlighted task expands inline beneath itself with a dimmed detail block: project name, recurrence rule, and do/late dates (only the fields that are set). Project name resolves client-side from the sidebar; dates were already on the row. Backend: RankedTask gains `recurrence: Option<String>` (populated in ranked_from_row from t.recurrence; both list/next select lists updated) — the only data the row was missing. Serializes over the socket automatically. Tested: a real-daemon render test asserts the ↻ glyph plus the selected detail block (recurs: FREQ=DAILY, project: Routines). 184 workspace tests; clippy/fmt clean. Note: the recurrence is shown as the raw RRULE for now (humanizing it is a later polish). Subtask/checklist folding was dropped — those reference items turned out to be blue backlog items, not sub-items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/filter.rs | 1 + crates/heph-core/src/ranking.rs | 3 ++ crates/heph-core/src/sqlite/tasks.rs | 5 ++- crates/heph-tui/src/app.rs | 8 ++++ crates/heph-tui/src/ui.rs | 55 +++++++++++++++++++++++++--- crates/heph-tui/tests/agenda.rs | 32 ++++++++++++++++ crates/heph-tui/tests/navigation.rs | 1 + 7 files changed, 97 insertions(+), 8 deletions(-) diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs index bd463ad..745be6b 100644 --- a/crates/heph-core/src/filter.rs +++ b/crates/heph-core/src/filter.rs @@ -177,6 +177,7 @@ mod tests { do_date: None, late_on: None, state: TaskState::Outstanding, + recurrence: None, tombstoned: false, project_id: None, canonical_context_id: None, diff --git a/crates/heph-core/src/ranking.rs b/crates/heph-core/src/ranking.rs index e586829..f077eba 100644 --- a/crates/heph-core/src/ranking.rs +++ b/crates/heph-core/src/ranking.rs @@ -34,6 +34,8 @@ pub struct RankedTask { pub late_on: Option<i64>, /// Lifecycle state. pub state: TaskState, + /// RRULE if this is a recurring task (surfaced for a `↻` marker + detail). + pub recurrence: Option<String>, /// Whether tombstoned. pub tombstoned: bool, /// The task's project node id, if any (for `scope`). @@ -155,6 +157,7 @@ mod tests { do_date: None, late_on: None, state: TaskState::Outstanding, + recurrence: None, tombstoned: false, project_id: None, canonical_context_id: None, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 9c18060..f751539 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -368,7 +368,7 @@ pub(super) fn list( ) -> Result<Vec<RankedTask>> { let sql = " SELECT n.id, n.title, n.created_at, n.tombstoned, - t.attention, t.do_date, t.late_on, t.state, + t.attention, t.do_date, t.late_on, t.state, t.recurrence, (SELECT dst_id FROM links WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 ORDER BY created_at, id LIMIT 1) AS project_id, @@ -475,7 +475,7 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> { fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> { let sql = " SELECT n.id, n.title, n.created_at, n.tombstoned, - t.attention, t.do_date, t.late_on, t.state, + t.attention, t.do_date, t.late_on, t.state, t.recurrence, (SELECT dst_id FROM links WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 ORDER BY created_at, id LIMIT 1) AS project_id, @@ -508,6 +508,7 @@ fn ranked_from_row(row: &Row) -> rusqlite::Result<RankedTask> { late_on: row.get("late_on")?, state: TaskState::parse(&row.get::<_, String>("state")?) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + recurrence: row.get("recurrence")?, tombstoned: row.get::<_, i64>("tombstoned")? != 0, project_id: row.get("project_id")?, canonical_context_id: row.get("ctx_id")?, diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 6ff1528..e5cbc1e 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -164,6 +164,14 @@ impl<B: Backend> App<B> { self.tasks.get(self.task_cursor) } + /// The title of a project node id, resolved from the sidebar. + pub fn project_name(&self, id: &str) -> Option<String> { + self.sidebar.iter().find_map(|e| match e { + SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()), + _ => None, + }) + } + /// The node to open in the editor for the highlighted task: its /// canonical-context doc (where the description/checklist live), falling /// back to the task node itself. diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index fbe0090..808c2ea 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -131,6 +131,38 @@ fn attention_style(a: Option<Attention>) -> (char, Style) { } } +/// The dimmed, indented detail block shown under the selected task: project, +/// recurrence rule, and do/late dates (only the fields that are set). +fn task_detail_lines<B: Backend>( + app: &App<B>, + t: &heph_core::RankedTask, + today: chrono::NaiveDate, +) -> Vec<Line<'static>> { + let dim = Style::default().fg(Color::DarkGray); + let mut lines = Vec::new(); + let mut field = |label: &str, value: String| { + lines.push(Line::from(Span::styled( + format!(" {label:8}{value}"), + dim, + ))); + }; + if let Some(pid) = &t.project_id { + if let Some(name) = app.project_name(pid) { + field("project:", name); + } + } + if let Some(rrule) = &t.recurrence { + field("recurs:", rrule.clone()); + } + if let Some(d) = t.do_date { + field("do:", fmt_date(d, today)); + } + if let Some(l) = t.late_on { + field("late:", fmt_date(l, today)); + } + lines +} + fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { let focused = app.focus == Focus::Tasks; let today = today_local(); @@ -172,22 +204,33 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { Style::default() }; let cursor = if selected { "▌" } else { " " }; + let recur = t.recurrence.is_some(); - // Pad the title so the chip sits at the right edge. + // Pad the title so the right side (↻ + date chip) aligns right. let chip_w = chip.len(); - let fixed = 1 + 2; // cursor + glyph + space - let avail = width.saturating_sub(fixed + chip_w + 1); + let recur_w = if recur { 2 } else { 0 }; // "↻ " + let fixed = 1 + 2 + 1; // cursor + "glyph " + trailing space + let avail = width.saturating_sub(fixed + recur_w + chip_w); let mut title: String = t.title.chars().take(avail).collect(); let pad = avail.saturating_sub(title.chars().count()); title.push_str(&" ".repeat(pad)); - ListItem::new(Line::from(vec![ + let mut header = vec![ Span::styled(cursor, Style::default().fg(Color::Cyan)), Span::styled(format!("{glyph} "), gstyle), Span::styled(title, title_style), Span::raw(" "), - Span::styled(chip, chip_style), - ])) + ]; + if recur { + header.push(Span::styled("↻ ", Style::default().fg(Color::Magenta))); + } + header.push(Span::styled(chip, chip_style)); + + let mut lines = vec![Line::from(header)]; + if selected { + lines.extend(task_detail_lines(app, t, today)); + } + ListItem::new(lines) }) .collect() }; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 25e9d0c..60b4cf1 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -177,6 +177,38 @@ fn quick_add_captures_a_task_that_appears_in_the_view() { ); } +#[test] +fn recurring_task_shows_glyph_and_selected_detail_block() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + let proj = c + .call( + "node.create", + json!({ "kind": "project", "title": "Routines" }), + ) + .unwrap(); + c.call( + "task.create", + json!({ + "title": "Daily standup", + "attention": "red", + "recurrence": "FREQ=DAILY", + "project_id": proj["id"], + }), + ) + .unwrap(); + + let app = App::new(ClientBackend::new(client(&socket))).unwrap(); + let s = screen(&app); + // Recurrence glyph on the row... + assert!(s.contains('↻'), "recurrence glyph missing:\n{s}"); + // ...and the selected task's inline detail block (cursor starts on row 0). + assert!(s.contains("recurs:"), "no recurrence detail:\n{s}"); + assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}"); + assert!(s.contains("project:"), "no project detail:\n{s}"); + assert!(s.contains("Routines"), "project name missing:\n{s}"); +} + #[test] fn search_finds_a_matching_node_and_overlays_results() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 2ed1694..704e383 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -37,6 +37,7 @@ fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> Ranke do_date: None, late_on: None, state: TaskState::Outstanding, + recurrence: None, tombstoned: false, project_id: None, canonical_context_id: ctx.map(str::to_string), -- 2.50.1 (Apple Git-155) From bed2d6a8aa8ccbe4b0ed49894a79cddba82765a7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 08:08:42 -0700 Subject: [PATCH 57/91] =?UTF-8?q?docs(tui):=20note=20recurring=20=E2=86=BB?= =?UTF-8?q?=20marker=20+=20inline=20selected-row=20detail=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/reference/tech-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 514d524..6c2031c 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,7 +254,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet; humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. @@ -380,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **183 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **184 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** -- 2.50.1 (Apple Git-155) From 2c8d8b101faa0d360c6938b7f7fd4a30906c5b4a Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 08:48:32 -0700 Subject: [PATCH 58/91] =?UTF-8?q?feat(tui):=20delete/tombstone=20a=20task?= =?UTF-8?q?=20with=20D=20(y/N=20confirm)=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `D` arms a delete on the highlighted task; the status line shows "Delete \"title\"? (y / N)" and the next key confirms (y) or cancels (anything else). Confirming calls node.tombstone — a true soft-delete that removes the task from every view, recurring tasks included (unlike `x` done, which rolls a recurring task forward, or `d` dropped, which keeps it in the store). Backend gains `tombstone`. Tests: confirm-flow unit test against a recording fake (arm → cancel keeps it; arm → confirm tombstones), plus a real-daemon integration test that deleting a recurring task drops it from the view and sets the node's tombstoned flag. 186 workspace tests; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 35 +++++++++++++++++++++++++++++ crates/heph-tui/src/backend.rs | 8 +++++++ crates/heph-tui/src/main.rs | 10 +++++++++ crates/heph-tui/src/ui.rs | 13 ++++++++++- crates/heph-tui/tests/agenda.rs | 26 +++++++++++++++++++++ crates/heph-tui/tests/navigation.rs | 29 ++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index e5cbc1e..c6446ff 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -44,6 +44,13 @@ pub struct SearchView { pub cursor: usize, } +/// A pending delete awaiting y/N confirmation (the most destructive gesture). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingDelete { + pub task_id: String, + pub title: String, +} + /// The attention cycle for the `A` gesture: default → top-of-mind → consequence /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. pub fn next_attention(current: Option<Attention>) -> Attention { @@ -106,6 +113,8 @@ pub struct App<B: Backend> { pub mode: Mode, /// When `Some`, a full-text search overlays the task list. pub search: Option<SearchView>, + /// When `Some`, a delete is awaiting y/N confirmation. + pub pending_delete: Option<PendingDelete>, pub status: String, pub should_quit: bool, } @@ -143,6 +152,7 @@ impl<B: Backend> App<B> { focus: Focus::Sidebar, mode: Mode::Normal, search: None, + pending_delete: None, status: String::new(), should_quit: false, }; @@ -353,6 +363,31 @@ impl<B: Backend> App<B> { }); } + /// Arm a delete on the highlighted task (awaits y/N confirmation). + pub fn begin_delete(&mut self) { + if let Some(t) = self.selected_task() { + self.pending_delete = Some(PendingDelete { + task_id: t.node_id.clone(), + title: t.title.clone(), + }); + } + } + + /// Confirm the armed delete: tombstone the task and reload. + pub fn confirm_delete(&mut self) { + if let Some(pd) = self.pending_delete.take() { + self.mutate(format!("deleted: {}", pd.title), |b| { + b.tombstone(&pd.task_id) + }); + } + } + + /// Cancel the armed delete. + pub fn cancel_delete(&mut self) { + self.pending_delete = None; + self.status = "delete cancelled".into(); + } + // --- input modal (T2c: guided add + reschedule) --- fn current_project_id(&self) -> Option<String> { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 2664948..60d9e80 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -50,6 +50,9 @@ pub trait Backend { fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>; /// Skip a recurring task to its next occurrence (no completion logged). fn skip(&mut self, task_id: &str) -> Result<()>; + /// Tombstone (soft-delete) a task node — removes it from every view, + /// including recurring roll-forward. Distinct from `done`/`dropped`. + fn tombstone(&mut self, node_id: &str) -> Result<()>; /// Set a task's attention band. fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>; /// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option. @@ -153,6 +156,11 @@ impl Backend for ClientBackend { Ok(()) } + fn tombstone(&mut self, node_id: &str) -> Result<()> { + self.call("node.tombstone", json!({ "id": node_id }))?; + Ok(()) + } + fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> { self.call( "task.set_attention", diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 6a651a7..7fa989a 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -98,6 +98,15 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A return None; } + // A pending delete confirmation captures the next key (y confirms; else cancel). + if app.pending_delete.is_some() { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => app.confirm_delete(), + _ => app.cancel_delete(), + } + return None; + } + // While collecting input, all keys go to the prompt. if matches!(app.mode, Mode::Input(_)) { match key.code { @@ -145,6 +154,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A KeyCode::Char('s') => app.skip_selected(), KeyCode::Char('A') => app.cycle_attention_selected(), KeyCode::Char('b') => app.push_to_blue_selected(), + KeyCode::Char('D') => app.begin_delete(), // open the task's context doc in nvim (handled by the event loop) KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext), _ => {} diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 808c2ea..8ede817 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -15,7 +15,7 @@ use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; const HINTS: &str = - " j/k move Tab pane a add x done e date A attn b→blue o edit / search q quit"; + " j/k move a add x done e date A attn b→blue D del o edit / search q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; @@ -321,6 +321,17 @@ fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { } fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { + // A pending delete confirmation takes over the status line. + if let Some(pd) = &app.pending_delete { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" Delete \"{}\"? (y / N)", pd.title), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))), + area, + ); + return; + } let hints = if app.search.is_some() { SEARCH_HINTS } else { diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 60b4cf1..56fa7ed 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -253,6 +253,32 @@ fn reschedule_sets_a_do_date_on_the_task() { assert!(!got["do_date"].is_null(), "do_date was not set: {got}"); } +#[test] +fn deleting_a_recurring_task_tombstones_it_and_drops_it_from_the_view() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + let task = c + .call( + "task.create", + json!({ "title": "Daily ritual", "attention": "red", "recurrence": "FREQ=DAILY" }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert_eq!(app.tasks.len(), 1); + + app.begin_delete(); + app.confirm_delete(); + + assert!(app.status.contains("deleted"), "status: {}", app.status); + assert!(app.tasks.is_empty(), "deleted task still listed"); + // The node is tombstoned, not merely rolled forward (node.get includes + // tombstoned rows, with the flag set). + let got = c.call("node.get", json!({ "id": id })).unwrap(); + assert_eq!(got["tombstoned"], true, "task node not tombstoned: {got}"); +} + #[test] fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 704e383..effcc8c 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -27,6 +27,7 @@ type CreatedTask = ( struct Recorder { created: Vec<CreatedTask>, scheduled: Vec<(String, SchedulePatch)>, + tombstoned: Vec<String>, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -90,6 +91,10 @@ impl Backend for Fake { fn skip(&mut self, _t: &str) -> Result<()> { Ok(()) } + fn tombstone(&mut self, node_id: &str) -> Result<()> { + self.rec.borrow_mut().tombstoned.push(node_id.into()); + Ok(()) + } fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> { Ok(()) } @@ -233,6 +238,30 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) } +#[test] +fn delete_requires_confirmation_then_tombstones() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); // starts on ToM, first task = t1 + + // Arming a delete doesn't tombstone yet. + app.begin_delete(); + assert!(app.pending_delete.is_some()); + assert!(rec.borrow().tombstoned.is_empty()); + + // Cancelling clears it without tombstoning. + app.cancel_delete(); + assert!(app.pending_delete.is_none()); + assert!(rec.borrow().tombstoned.is_empty()); + + // Arming then confirming tombstones the selected task. + app.begin_delete(); + app.confirm_delete(); + assert!(app.pending_delete.is_none()); + assert_eq!(rec.borrow().tombstoned, vec!["t1".to_string()]); +} + #[test] fn search_populates_results_and_task_hits_open_their_context() { let mut fake = fixture(); -- 2.50.1 (Apple Git-155) From 8bd16664c4f7bcd4791e5a439301b3058b18f3d4 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 08:48:49 -0700 Subject: [PATCH 59/91] =?UTF-8?q?docs(tui):=20note=20D=20delete/tombstone?= =?UTF-8?q?=20gesture=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/reference/tech-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 6c2031c..34f295b 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,7 +254,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet; humanizing the displayed RRULE is later polish.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet; humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. @@ -380,7 +380,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **184 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **186 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** -- 2.50.1 (Apple Git-155) From 1c94a08cdab451bff84a23795e21934bf678162d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 08:56:53 -0700 Subject: [PATCH 60/91] =?UTF-8?q?docs(explanation):=20add=20Task=20Lifecyc?= =?UTF-8?q?le=20=E2=80=94=20the=20two-axis=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new explanation doc making the task model explicit: lifecycle state (outstanding → done/dropped) is orthogonal to attention (white/orange/red/ blue), attention only matters while outstanding, and On Deck (blue) is a live "later" task — NOT the same as dropped ("let go", terminal). Covers delete/ tombstone (soft-delete that keeps the context doc) and a table of where each task shows up (agenda / search / export). Cross-links design §6.2/§6.3 and tech-spec §4.3/§7/§8.1/§12; wired into the explanation index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.doc.md | 1 + docs/explanation/explanation.md | 1 + docs/explanation/task-lifecycle.md | 112 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 docs/explanation/task-lifecycle.md diff --git a/docs/changelog.d/v1-prototype.doc.md b/docs/changelog.d/v1-prototype.doc.md index 8867085..6ae23cc 100644 --- a/docs/changelog.d/v1-prototype.doc.md +++ b/docs/changelog.d/v1-prototype.doc.md @@ -1 +1,2 @@ +- Added a [[task-lifecycle]] explanation doc: the **two-axis task model** (lifecycle state — outstanding/done/dropped — kept separate from attention white/orange/red/blue), why **On Deck (blue) is not the same as dropped**, what **delete/tombstone** does (and that it keeps the context doc), and where each task shows up across the agenda, full-text search, and export. - Surface strategy revised to a **three-surface model** ([[design]] §4, tech-spec §1/§8), grounded in a study of the owner's live Todoist usage (387 active tasks, 34 hierarchical projects — [[design]] §6.2.1): the **CLI** is task capture/scripting plus the complete daemon API, a planned **`heph-tui`** terminal UI (tech-spec §8.1) is the primary task agenda/triage surface, and **`heph.nvim`** is the primary context/knowledge-base surface. The study also confirms do-date-not-due-date (zero deadlines used), that task descriptions are already full of unresolved `[[wiki-links]]` (the exact task↔KB fusion heph provides), and surfaces new needs (project hierarchy, natural-language recurrence) while showing tags are negligible. diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md index b7f4d9d..c4763ab 100644 --- a/docs/explanation/explanation.md +++ b/docs/explanation/explanation.md @@ -11,3 +11,4 @@ tags: Background context and design decisions. - [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap +- [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up diff --git a/docs/explanation/task-lifecycle.md b/docs/explanation/task-lifecycle.md new file mode 100644 index 0000000..f299c02 --- /dev/null +++ b/docs/explanation/task-lifecycle.md @@ -0,0 +1,112 @@ +--- +title: Task Lifecycle +modified: 2026-06-03 +tags: + - explanation + - tasks +--- + +# Task Lifecycle + +How a committed task moves through heph — and the one idea that makes the whole +thing click: a task has **two independent axes**, not one status field. + +> Scope: this is about **committed tasks** (the things that appear in "what is +> next"). Ephemeral **context items** (the `- [ ]` checklist lines inside a +> task's context doc) are a separate concept — see [[design]] §6.3. + +## The two axes + +| Axis | Values | Question it answers | +|---|---|---| +| **Lifecycle state** | `outstanding` → `done` / `dropped` | Is this still a live thing? | +| **Attention** | `white` · `orange` · `red` · **`blue`** | How much should it be on my mind *right now*? | + +They are orthogonal. Attention only means anything **while a task is +outstanding** — a `done` task has no useful attention. This separation is what +keeps the system honest (see [[design]] §6.2): you decide *whether* you're still +doing something separately from *how loud* it should be. + +### Lifecycle state + +- **`outstanding`** — a live commitment. Every task starts here. Only + outstanding tasks appear in the agenda (the §8.2 views, project views, "what + is next"). +- **`done`** — you did it. Terminal. For a **recurring** task, completing rolls + it *forward* to its next occurrence with a fresh checklist (it reappears as a + new outstanding instance — see [[design]] §3.3 and [[tech-spec]] §4.4), rather + than ending. +- **`dropped`** — you've decided **not** to do it ("let it go"). Terminal, and + the sibling of `done`: "didn't do" vs "did". The distinction from `done` is + kept on purpose. Dropping a recurring task ends it — it does **not** roll + forward (only `done` rolls forward). + +Both `done` and `dropped` are "not outstanding", so both leave every agenda +surface — but they remain in the store as a record. + +### Attention (only while outstanding) + +The colors, thought of as feeling, not number ([[design]] §6.2): + +- **white** — doable once its do-date arrives (the default). +- **orange** — top of mind (keep ≤ ~6, reconfirm daily). +- **red** — top of mind **and a consequence exists if it's late** (consequence, + not importance). +- **blue — On Deck** — a backlog item, deliberately cooled. **Still + outstanding** — a real, live task you intend to do *later*. Blue is the + pressure-relief valve that keeps the active set light. + +The common trap is conflating **blue (On Deck)** with **dropped**. They are not +the same: blue = "later", dropped = "not at all". The §6.2 *blue keep/drop +review* is precisely the act of looking at your On Deck pile and, for each item, +either keeping it blue ("later") or **dropping** it ("let go"). + +## Delete (tombstone) — a third thing, below the lifecycle + +`dropped` still leaves a record. **Tombstoning** (delete) goes further: it marks +the task node `tombstoned` and removes it from *everything* — the agenda, +full-text search, and export. It is a **soft delete** (the row is retained with +a flag, recoverable at the database level, and CRDT-safe — heph never hard-deletes, +see [[tech-spec]] §12), but for all practical purposes the task is gone. + +Deleting a task tombstones **only the task node** — its canonical-context doc +(your notes/checklist for it) is **kept**, so deleting a task doesn't throw away +the writing attached to it. + +## Where each task shows up + +This is the practical payoff of the model — what's visible where: + +| | outstanding (incl. blue / On Deck) | dropped | tombstoned (deleted) | +|---|---|---|---| +| Agenda views (`next` / `list` / `view` / project) | ✅ shown | ❌ gone | ❌ gone | +| Full-text search (`/`) | ✅ | ✅ still findable | ❌ gone | +| Export / raw store | ✅ | ✅ retained | ❌ (flagged deleted) | + +A `dropped` task is gone from **all** agendas (including project views) but +stays findable in search — so "let go" is recoverable if you change your mind, +without cluttering your working set. + +## How you move a task between states + +The model is surface-agnostic; the gestures differ per surface. In the TUI +([[tech-spec]] §8.1): + +| Gesture | Effect | +|---|---| +| `a` | capture a new task → `outstanding` | +| `A` | cycle attention (white → orange → red → blue) | +| `b` | push to **blue / On Deck** (still outstanding) | +| `x` | **done** (a recurring task rolls forward) | +| `s` | **skip** a recurring task to its next occurrence (no completion logged) | +| `d` | **drop** (terminal "let go") | +| `D` | **delete / tombstone** (with confirmation) | + +The CLI mirrors these (`heph done` / `drop` / `skip` / `attention` / +`node rm`), and a context item can be **promoted** into a brand-new outstanding +committed task ([[design]] §6.3). + +## Related + +- [[design]] §6.2 (the lived priority discipline), §6.3 (commitments vs context items) +- [[tech-spec]] §4.3 (task semantics), §7 (ranking), §8.1 (the TUI), §12 (tombstones) -- 2.50.1 (Apple Git-155) From 890ba9c8b57c866d7f21d263bb8e84b1d0388b61 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 10:28:36 -0700 Subject: [PATCH 61/91] =?UTF-8?q?docs(spec):=20record=20the=202026-06-03?= =?UTF-8?q?=20UX=20roadmap=20(=C2=A78.1/=C2=A78.3/=C2=A78.4/=C2=A714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the agreed remaining-v1 plan: TUI move-to-project + the task-list UX wave (flag column, project-colored bullets, sort toggle, scrollbar), tags model, YAML frontmatter as an edit surface (§8.3), and wiki-links by node id (§8.4). Reorders the §14 resume list to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/reference/tech-spec.md | 54 ++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 34f295b..c9bd9ee 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -1,6 +1,6 @@ --- title: Technical Specification -modified: 2026-06-01 +modified: 2026-06-03 tags: - reference - design @@ -258,6 +258,11 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. +- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows except move-to-project: + - **`m` move-to-project** — re-file the selected task via the new `task.set_project` RPC (the last Todoist-parity gap). + - **flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (HSL→truecolor, 256-color fallback; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement. + - **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort. + - **scrollbar** — a `ratatui` `Scrollbar` on the task list when it overflows. ## 8.2 Filter views (saved agenda slices) — built @@ -296,6 +301,29 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2. > **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1. +## 8.3 Frontmatter as an edit surface (planned) + +> **Status: planned** (§14 roadmap, 2026-06-03). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) should be visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body. + +The resolving principle is a **two-layer split** that keeps `heph-core` safe against *any* client while making `heph.nvim` a rich editor: + +- **`heph-core` is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. A pure `frontmatter` module (sibling to `extract.rs`) provides `render(node, task?, project, tags)` (prepended by `get_node` and friends) and `strip(body) → body_without` (applied by `update_node` **before** the `yrs` CRDT diff). Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and core drops it; the canonical block regenerates on the next read. +- **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core. + +Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, `do_date`, `late_on`, `recurrence`, `project`, `tags`, and `state` are editable. **`state` is editable but has no picker or hint** (to keep the UI simple) — a mistyped status value returns a **validation error** rather than guessing. Frontmatter is rendered for any editable-body node. + +**Inline `#hashtags`** are a **`heph.nvim` feature**, not core extraction (for now): the plugin detects them on save and routes them through the same `tag.add`/`tag.remove` path. (Core-side hashtag extraction can come later, e.g. for the zk import.) + +## 8.4 Wiki-links by node id (planned) + +> **Status: planned** (§14 roadmap, 2026-06-03). Today bodies store the human text `[[Title]]` and links are materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. + +- **At rest:** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. Extraction reads the id directly — no resolution, no ambiguity — and the canonical-context exclusion hack in `resolve_id` is **removed**. +- **Projection (same philosophy as §8.3):** on **read**, `heph-core` expands a bare `[[NODEID]]` → `[[NODEID|Current Name]]`, so buffers, `heph export`, and any dumb reader show readable, always-fresh link text. On **write**, a `|text` that equals the target's current name **collapses back** to bare `[[NODEID]]`; a `|text` that differs is preserved as a real override. Needs an **id→name batch resolve** RPC for the expansion. +- **`heph.nvim` authoring:** typing `[[` triggers a picker (reuse `picker.lua` / Telescope, **no new dependency**) that searches titles via the `search` RPC and inserts `[[NODEID]]`; a **"Create new: «typed»"** entry mints a `doc` and inserts its id. +- **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), and revealed in raw form when the cursor is on it. +- **Migration:** a **one-time fixup script** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable). No special care is warranted — there is no critical data in the store yet — and a first-class migrations feature stays **deferred**. + ## 9. Testing strategy (TDD, layered) All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts/build` to run `cargo test` and the nvim e2e suite; `prek` already runs in `build.yaml`). **The Forgejo runner image must provide `neovim` + `plenary.nvim`** for the headless e2e suite. @@ -414,15 +442,23 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi **Not yet done (resume order)** -> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist ([[design]] §6.2.1), **filter views (§8.2) are built** (`heph view`), and the **`heph-tui` daily-driver core is built** (§8.1, T1–T2c). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (core done, T3 polish remains), **nvim = context/KB**. Remaining work, in order: +> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist ([[design]] §6.2.1), **filter views (§8.2) are built** (`heph view`), and the **`heph-tui` daily-driver core is built** (§8.1, T1–T2c). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (core done, the UX wave below remains), **nvim = context/KB**. +> +> The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order: -1. ⏳ **`heph-tui` — move-to-project (§8.1) — last Todoist-parity gap:** NL quick-add and `/` FTS search are **done**; re-filing a task's project needs a new **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — there's no link-remove RPC today). Small backend slice + a TUI gesture (`m`). -2. ⏳ **nvim task-navigation polish (§8) — small:** show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). -3. ⏳ **Tags + project-hierarchy depth (§4, §6.2.1) — deferred:** tags are barely used (5/387) so low priority; project hierarchy beyond `project add --parent` (and the subtree `scope` the filter-views slice introduced) is a refinement. -4. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -5. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). -6. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -7. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **`heph-tui` — move-to-project (§8.1) — last Todoist-parity gap:** NL quick-add and `/` FTS search are **done**; re-filing a task's project needs a new **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — there's no link-remove RPC today; OR-set link semantics, no task-scalar op). Small backend slice + a TUI gesture (`m`). Also **unblocks** the project-edit path of the frontmatter surface (§8.3). +2. ⏳ **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** + - **(a) flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**), and the **bullet `●` colored by its project** via a stable `hash(project_id) → hue` (HSL→truecolor `Color::Rgb`, 256-color fallback). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). + - **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. + - **(e) scrollbar** — the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows the pane. + - **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). +3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. +4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. +5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. +6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). +8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +9. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related -- 2.50.1 (Apple Git-155) From df7f43788bc183c15ef9d1df8d48a1eb5dfb6712 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 10:35:16 -0700 Subject: [PATCH 62/91] =?UTF-8?q?feat(core):=20task.set=5Fproject=20?= =?UTF-8?q?=E2=80=94=20move-to-project=20with=20OR-set=20link=20semantics?= =?UTF-8?q?=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Store::set_task_project` (heph-core + RemoteStore) and the `task.set_project` RPC: tombstone the task's existing `in-project` link(s) and add a new one (or none, to unfile). A given project id must name a live project-kind node, else InvalidArg/NodeNotFound. Route `heph edit --project` through it, fixing a duplicate-link bug (the old path added an in-project link without removing the prior one); `--project none` now unfiles. Factor a `links::tombstone` helper out of `sync_wiki_links`. Tests: core move/unfile/reject + a duplicate-link regression; a socket dispatch test. The TUI `m` gesture follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/links.rs | 13 ++- crates/heph-core/src/sqlite/mod.rs | 5 ++ crates/heph-core/src/sqlite/tasks.rs | 34 ++++++++ crates/heph-core/src/store.rs | 6 ++ crates/heph-core/tests/tasks_and_links.rs | 99 +++++++++++++++++++++++ crates/heph/src/main.rs | 15 +++- crates/hephd/src/remote.rs | 7 ++ crates/hephd/src/rpc.rs | 12 +++ crates/hephd/tests/rpc_socket.rs | 63 +++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 3 +- 10 files changed, 249 insertions(+), 8 deletions(-) diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 26c78d9..ab20b15 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -70,6 +70,15 @@ pub(super) fn add( Ok(link) } +/// Tombstone a single link by id, recording the OR-set `link.remove` op. +/// Monotonic: re-tombstoning an already-dead link is a harmless no-op write. +pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, link_id: &str) -> Result<()> { + conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; + let hlc = next_hlc(conn, now)?; + ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?; + Ok(()) +} + /// The destination of the first non-tombstoned link of `link_type` out of /// `src_id`, if any (e.g. a task's canonical-context doc or its log doc). pub(super) fn first_dst( @@ -144,9 +153,7 @@ pub(super) fn sync_wiki_links( // Tombstone links whose target is no longer referenced. for (link_id, dst) in &existing { if !desired_set.contains(dst) { - conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; - let hlc = next_hlc(conn, now)?; - ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?; + tombstone(conn, owner, now, link_id)?; } } // Add links for newly-referenced targets. diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 04f7fa6..630ba0e 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -237,6 +237,11 @@ impl Store for LocalStore { tasks::set_schedule(&self.conn, &self.owner_id, now, node_id, patch) } + fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task> { + let now = self.clock.now_ms(); + tasks::set_project(&mut self.conn, &self.owner_id, now, node_id, project_id) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index f751539..197fc45 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -535,6 +535,40 @@ pub(super) fn set_attention( require(conn, node_id) } +/// Re-file a task under `project_id`, or unfile it entirely when `None` +/// (tech-spec §8.1 move-to-project). OR-set link semantics: tombstone the +/// task's existing `in-project` links, then add a fresh one if a project is +/// given. A given `project_id` must name a live `project`-kind node. Records +/// only link ops (no task-scalar change), all in one transaction. +pub(super) fn set_project( + conn: &mut Connection, + owner: &str, + now: i64, + node_id: &str, + project_id: Option<&str>, +) -> Result<Task> { + require(conn, node_id)?; // task must exist + if let Some(pid) = project_id { + let project = nodes::get(conn, pid)?.ok_or_else(|| Error::NodeNotFound(pid.into()))?; + if project.tombstoned || project.kind != NodeKind::Project { + return Err(Error::InvalidArg(format!("{pid} is not a project node"))); + } + } + + let tx = conn.transaction()?; + for link in links::outgoing(&tx, node_id)? { + if link.link_type == LinkType::InProject { + links::tombstone(&tx, owner, now, &link.id)?; + } + } + if let Some(pid) = project_id { + links::add(&tx, owner, now, node_id, pid, LinkType::InProject)?; + } + tx.commit()?; + + require(conn, node_id) +} + /// Apply a partial schedule update (do-date / late-on / recurrence) — the /// "reschedule" path (tech-spec §6). Reads the current row, overlays the /// present `patch` fields (a double-option per field: absent = leave, `null` = diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index fbd6aeb..5044b15 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -83,6 +83,12 @@ pub trait Store { /// set. This is the "reschedule" path (the scalars with no dedicated setter). fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task>; + /// Re-file a task under a project (or unfile it when `project_id` is + /// `None`) — the move-to-project path (tech-spec §8.1). OR-set link + /// semantics: the old `in-project` link is tombstoned and a new one added. + /// A given `project_id` must name a live `project`-kind node. + fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task>; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a /// committed task, rewriting that source line into a `[[link]]` to the new /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs index c089311..3c97c91 100644 --- a/crates/heph-core/tests/tasks_and_links.rs +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -213,6 +213,105 @@ fn project_link_is_created_when_given() { assert!(has_project); } +#[test] +fn set_project_moves_then_unfiles_and_rejects_non_projects() { + let mut s = store(); + let project = |s: &mut LocalStore, title: &str| { + s.create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id + }; + let chores = project(&mut s, "Chores"); + let garden = project(&mut s, "Garden"); + + let task = s + .create_task(NewTask { + title: "Water the beds".into(), + project_id: Some(chores.clone()), + ..Default::default() + }) + .unwrap(); + let id = task.node_id; + + // The active `in-project` destinations of the task. + let projects_of = |s: &mut LocalStore| -> Vec<String> { + s.outgoing_links(&id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::InProject) + .map(|l| l.dst_id) + .collect() + }; + + // Move Chores → Garden: exactly one active link, now to Garden. + s.set_task_project(&id, Some(&garden)).unwrap(); + assert_eq!(projects_of(&mut s), vec![garden.clone()]); + + // Unfile (None): no active in-project links remain. + s.set_task_project(&id, None).unwrap(); + assert!(projects_of(&mut s).is_empty()); + + // A non-project destination is rejected (and nothing is filed). + let doc = s.create_node(NewNode::doc("Just a note", "")).unwrap(); + assert!(s.set_task_project(&id, Some(&doc.id)).is_err()); + assert!(projects_of(&mut s).is_empty()); + + // A missing project id is rejected too. + assert!(s.set_task_project(&id, Some("does-not-exist")).is_err()); +} + +/// Regression: re-filing a task must never accumulate duplicate `in-project` +/// links. The old CLI `edit --project` path added a link without tombstoning +/// the previous one, so a task could end up filed under two (or N) projects at +/// once. `set_task_project` replaces, so the active count stays exactly one — +/// even when re-filing to the *same* project repeatedly. +#[test] +fn set_project_never_accumulates_duplicate_links() { + let mut s = store(); + let project = |s: &mut LocalStore, title: &str| { + s.create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id + }; + let chores = project(&mut s, "Chores"); + let garden = project(&mut s, "Garden"); + + let id = s + .create_task(NewTask { + title: "Mulch".into(), + project_id: Some(chores.clone()), + ..Default::default() + }) + .unwrap() + .node_id; + + let active_in_project = |s: &mut LocalStore| -> usize { + s.outgoing_links(&id) + .unwrap() + .iter() + .filter(|l| l.link_type == LinkType::InProject) + .count() + }; + + // Re-file to the same project several times, then to a different one. + for target in [&chores, &chores, &garden, &garden] { + s.set_task_project(&id, Some(target)).unwrap(); + assert_eq!( + active_in_project(&mut s), + 1, + "exactly one active project link" + ); + } +} + #[test] fn updating_a_body_materializes_resolved_wiki_links() { let mut s = store(); diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index a383a0b..53de382 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -122,7 +122,7 @@ enum Command { /// Set attention: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option<String>, - /// File under a project (by name; adds an in-project link). + /// Re-file under a project (by name); `none` unfiles the task. #[arg(long)] project: Option<String>, }, @@ -473,10 +473,17 @@ fn main() -> Result<()> { if let Some(a) = attention { client.call("task.set_attention", json!({ "id": id, "attention": a }))?; } - if let Some(pid) = resolve_project(&mut client, project.as_deref())? { + if let Some(spec) = project.as_deref() { + // Re-file (or unfile with `none`) via the move-to-project path, + // which tombstones the old `in-project` link rather than piling + // a second one on top of it. + let project_id = match spec { + "none" | "clear" => None, + name => resolve_project(&mut client, Some(name))?, + }; client.call( - "links.add", - json!({ "src": id, "dst": pid, "link_type": "in-project" }), + "task.set_project", + json!({ "id": id, "project_id": project_id }), )?; } println!("Edited task {id}"); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 959e6a3..952982b 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -167,6 +167,13 @@ impl Store for RemoteStore { self.call_as("task.set_schedule", params) } + fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task> { + self.call_as( + "task.set_project", + json!({ "id": node_id, "project_id": project_id }), + ) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index fa7d160..00ba9af 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -143,6 +143,14 @@ struct SetAttentionParams { attention: Attention, } +#[derive(Deserialize)] +struct SetProjectParams { + id: String, + /// Target project node id; `null`/absent unfiles the task. + #[serde(default)] + project_id: Option<String>, +} + #[derive(Deserialize)] struct PromoteParams { container_id: String, @@ -273,6 +281,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let patch: SchedulePatch = parse(params)?; json!(store.set_task_schedule(&id.id, patch)?) } + "task.set_project" => { + let p: SetProjectParams = parse(params)?; + json!(store.set_task_project(&p.id, p.project_id.as_deref())?) + } "task.skip" => { let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 1c5d122..d9a188d 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -164,6 +164,69 @@ fn task_set_schedule_patches_over_socket() { assert!(got["recurrence"].is_null()); } +#[test] +fn task_set_project_moves_and_unfiles_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let mk_project = |c: &mut Client, title: &str| -> String { + c.call("node.create", json!({ "kind": "project", "title": title })) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() + }; + let chores = mk_project(&mut c, "Chores"); + let garden = mk_project(&mut c, "Garden"); + + let task = c + .call( + "task.create", + json!({ "title": "Water the beds", "project_id": chores }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + // Active in-project destinations of the task. + let projects_of = |c: &mut Client| -> Vec<String> { + c.call("links.outgoing", json!({ "id": id })) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter(|l| l["link_type"] == "in-project") + .map(|l| l["dst_id"].as_str().unwrap().to_string()) + .collect() + }; + + // Move Chores → Garden: exactly one active link, to Garden. + c.call( + "task.set_project", + json!({ "id": id, "project_id": garden }), + ) + .unwrap(); + assert_eq!(projects_of(&mut c), vec![garden.clone()]); + + // Unfile (null project): no active in-project links. + c.call("task.set_project", json!({ "id": id, "project_id": null })) + .unwrap(); + assert!(projects_of(&mut c).is_empty()); + + // A non-project destination is rejected. + let doc = c + .call("node.create", json!({ "kind": "doc", "title": "Note" })) + .unwrap(); + let doc_id = doc["id"].as_str().unwrap(); + assert!( + c.call( + "task.set_project", + json!({ "id": id, "project_id": doc_id }) + ) + .is_err(), + "filing under a non-project node must error" + ); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index bb97724..eef0a10 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -23,4 +23,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). -- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. Re-filing a task to a different project is the one remaining capture gap. +- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. +- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. `heph edit <task> --project <name>` now routes through it (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. -- 2.50.1 (Apple Git-155) From 288e9025732207da40ad6ba82dba1be8b60fd689 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 10:40:31 -0700 Subject: [PATCH 63/91] =?UTF-8?q?feat(tui):=20m=20move-to-project=20picker?= =?UTF-8?q?=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `m` opens a list-pick overlay on the highlighted task — "(Unfile)" then every project — and re-files it via `task.set_project` (cursor starts on the task's current project). j/k navigate, Enter applies, Esc cancels. Adds `Backend::set_project`, a `Mode::MoveToProject` overlay, and its render. Navigation tests cover refile + cancel. Closes the last Todoist-parity capture gap (§14 item 1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 85 ++++++++++++++++++++++++ crates/heph-tui/src/backend.rs | 10 +++ crates/heph-tui/src/main.rs | 13 ++++ crates/heph-tui/src/ui.rs | 42 +++++++++++- crates/heph-tui/tests/navigation.rs | 58 ++++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 8 +-- 7 files changed, 210 insertions(+), 8 deletions(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index c6446ff..5673ae0 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -12,6 +12,8 @@ use crate::backend::{Backend, Project, SearchHit}; pub enum Mode { Normal, Input(InputState), + /// A list-pick overlay for re-filing the highlighted task to a project. + MoveToProject(MoveState), } /// A single-line text prompt overlay (guided add / reschedule). `prompt` labels @@ -51,6 +53,23 @@ pub struct PendingDelete { pub title: String, } +/// One choice in the move-to-project picker: a project (or `None` = unfile). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveOption { + pub project_id: Option<String>, + pub label: String, +} + +/// The move-to-project picker state: which task is being re-filed, the choices +/// (an "(Unfile)" entry then every project), and the highlighted row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveState { + pub task_id: String, + pub task_title: String, + pub options: Vec<MoveOption>, + pub cursor: usize, +} + /// The attention cycle for the `A` gesture: default → top-of-mind → consequence /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. pub fn next_attention(current: Option<Attention>) -> Attention { @@ -388,6 +407,72 @@ impl<B: Backend> App<B> { self.status = "delete cancelled".into(); } + // --- move-to-project picker (§8.1) --- + + /// Open the move-to-project picker for the highlighted task. Choices are an + /// "(Unfile)" entry followed by every project; the cursor starts on the + /// task's current project when it has one. + pub fn begin_move(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + let mut options = vec![MoveOption { + project_id: None, + label: "(Unfile)".into(), + }]; + for p in self.project_list() { + options.push(MoveOption { + project_id: Some(p.id), + label: p.title, + }); + } + let cursor = t + .project_id + .as_deref() + .and_then(|pid| { + options + .iter() + .position(|o| o.project_id.as_deref() == Some(pid)) + }) + .unwrap_or(0); + self.mode = Mode::MoveToProject(MoveState { + task_id: t.node_id, + task_title: t.title, + options, + cursor, + }); + } + + /// Move the picker cursor by `delta` (clamped). + pub fn move_picker_move(&mut self, delta: isize) { + if let Mode::MoveToProject(m) = &mut self.mode { + let max = m.options.len() as isize - 1; + m.cursor = (m.cursor as isize + delta).clamp(0, max) as usize; + } + } + + /// Apply the highlighted choice: re-file (or unfile) the task and reload. + pub fn move_picker_submit(&mut self) { + let Mode::MoveToProject(m) = &self.mode else { + return; + }; + let Some(choice) = m.options.get(m.cursor).cloned() else { + return; + }; + let task_id = m.task_id.clone(); + let ok = format!("→ {}: {}", choice.label, m.task_title); + self.mode = Mode::Normal; + self.mutate(ok, |b| { + b.set_project(&task_id, choice.project_id.as_deref()) + }); + } + + /// Dismiss the picker without re-filing. + pub fn move_picker_cancel(&mut self) { + self.mode = Mode::Normal; + self.status = "move cancelled".into(); + } + // --- input modal (T2c: guided add + reschedule) --- fn current_project_id(&self) -> Option<String> { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 60d9e80..9db073b 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -57,6 +57,8 @@ pub trait Backend { fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>; /// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option. fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>; + /// Re-file a task under a project (or unfile it when `project_id` is `None`). + fn set_project(&mut self, task_id: &str, project_id: Option<&str>) -> Result<()>; /// Capture a committed task; returns its node id. fn create_task( &mut self, @@ -181,6 +183,14 @@ impl Backend for ClientBackend { Ok(()) } + fn set_project(&mut self, task_id: &str, project_id: Option<&str>) -> Result<()> { + self.call( + "task.set_project", + json!({ "id": task_id, "project_id": project_id }), + )?; + Ok(()) + } + fn create_task( &mut self, title: &str, diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 7fa989a..c48a42c 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,6 +119,18 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A return None; } + // The move-to-project picker captures navigation/select/cancel. + if matches!(app.mode, Mode::MoveToProject(_)) { + match key.code { + KeyCode::Esc => app.move_picker_cancel(), + KeyCode::Enter => app.move_picker_submit(), + KeyCode::Char('j') | KeyCode::Down => app.move_picker_move(1), + KeyCode::Char('k') | KeyCode::Up => app.move_picker_move(-1), + _ => {} + } + return None; + } + // While search results are shown, the center pane navigates them. if app.search.is_some() { app.status.clear(); @@ -154,6 +166,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A KeyCode::Char('s') => app.skip_selected(), KeyCode::Char('A') => app.cycle_attention_selected(), KeyCode::Char('b') => app.push_to_blue_selected(), + KeyCode::Char('m') => app.begin_move(), KeyCode::Char('D') => app.begin_delete(), // open the task's context doc in nvim (handled by the event loop) KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 8ede817..6151c27 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -10,7 +10,7 @@ use ratatui::{ Frame, }; -use crate::app::{App, Focus, InputState, Mode, SidebarEntry}; +use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry}; use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; @@ -44,11 +44,47 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { render_preview(frame, app, panes[2]); render_status(frame, app, outer[1]); - if let Mode::Input(state) = &app.mode { - render_input(frame, state); + match &app.mode { + Mode::Input(state) => render_input(frame, state), + Mode::MoveToProject(state) => render_move(frame, state), + Mode::Normal => {} } } +/// A centered list popup for re-filing the highlighted task to a project. +fn render_move(frame: &mut Frame, state: &MoveState) { + let area = frame.area(); + let width = area.width.saturating_sub(8).clamp(24, 50); + let rows = state.options.len() as u16; + let height = (rows + 2).min(area.height.saturating_sub(2)).max(3); + let popup = Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + area.height.saturating_sub(height) / 3, + width, + height, + }; + frame.render_widget(Clear, popup); + let items: Vec<ListItem> = state + .options + .iter() + .enumerate() + .map(|(i, o)| { + let mut style = Style::default(); + if i == state.cursor { + style = style.fg(Color::Black).bg(Color::Cyan); + } + ListItem::new(Line::from(Span::styled(o.label.clone(), style))) + }) + .collect(); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" Move \"{}\" to ", state.task_title)), + ); + frame.render_widget(list, popup); +} + /// A centered single-line input popup (guided add / reschedule). fn render_input(frame: &mut Frame, state: &InputState) { let area = frame.area(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index effcc8c..db85c8d 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -28,6 +28,7 @@ struct Recorder { created: Vec<CreatedTask>, scheduled: Vec<(String, SchedulePatch)>, tombstoned: Vec<String>, + refiled: Vec<(String, Option<String>)>, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -102,6 +103,13 @@ impl Backend for Fake { self.rec.borrow_mut().scheduled.push((t.into(), p)); Ok(()) } + fn set_project(&mut self, t: &str, project_id: Option<&str>) -> Result<()> { + self.rec + .borrow_mut() + .refiled + .push((t.into(), project_id.map(str::to_string))); + Ok(()) + } fn create_task( &mut self, title: &str, @@ -323,6 +331,56 @@ fn empty_title_cancels_the_add() { assert!(rec.borrow().created.is_empty()); } +#[test] +fn move_to_project_picker_refiles_the_selected_task() { + use heph_tui::app::Mode; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + // Open the picker on the first ToM task (t1). Options: (Unfile) + Camano. + app.begin_move(); + match &app.mode { + Mode::MoveToProject(m) => { + assert_eq!(m.task_id, "t1"); + assert_eq!( + m.options + .iter() + .map(|o| o.label.as_str()) + .collect::<Vec<_>>(), + vec!["(Unfile)", "Camano"] + ); + assert_eq!(m.cursor, 0, "t1 has no project → cursor starts at (Unfile)"); + } + _ => panic!("expected the move picker"), + } + + // Pick Camano (the second option) and submit. + app.move_picker_move(1); + app.move_picker_submit(); + + assert!(matches!(app.mode, Mode::Normal)); + let refiled = &rec.borrow().refiled; + assert_eq!(refiled.len(), 1); + assert_eq!(refiled[0], ("t1".into(), Some("p1".into()))); +} + +#[test] +fn move_to_project_cancel_refiles_nothing() { + use heph_tui::app::Mode; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_move(); + app.move_picker_cancel(); + + assert!(matches!(app.mode, Mode::Normal)); + assert!(rec.borrow().refiled.is_empty()); +} + #[test] fn reschedule_with_blank_clears_the_do_date() { let rec = Rc::new(RefCell::new(Recorder::default())); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index eef0a10..baa9155 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -24,4 +24,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). - `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. -- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. `heph edit <task> --project <name>` now routes through it (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. +- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index c9bd9ee..b72cb7f 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,12 +254,12 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet; humanizing the displayed RRULE is later polish.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. -- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows except move-to-project: - - **`m` move-to-project** — re-file the selected task via the new `task.set_project` RPC (the last Todoist-parity gap). +- **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap. +- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows: - **flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (HSL→truecolor, 256-color fallback; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement. - **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort. - **scrollbar** — a `ratatui` `Scrollbar` on the task list when it overflows. @@ -446,7 +446,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi > > The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order: -1. ⏳ **`heph-tui` — move-to-project (§8.1) — last Todoist-parity gap:** NL quick-add and `/` FTS search are **done**; re-filing a task's project needs a new **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — there's no link-remove RPC today; OR-set link semantics, no task-scalar op). Small backend slice + a TUI gesture (`m`). Also **unblocks** the project-edit path of the frontmatter surface (§8.3). +1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project <name>|none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3). 2. ⏳ **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** - **(a) flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**), and the **bullet `●` colored by its project** via a stable `hash(project_id) → hue` (HSL→truecolor `Color::Rgb`, 256-color fallback). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). - **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. -- 2.50.1 (Apple Git-155) From ecfe64435c900415232dd152c024a70c7361743b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 10:53:19 -0700 Subject: [PATCH 64/91] =?UTF-8?q?feat(tui):=20attention=20flag=20column=20?= =?UTF-8?q?+=20project-colored=20bullets=20+=20scrollbar=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each task row now leads with a colored attention flag (⚑ for red/orange/blue, blank for white/none) and a project-colored bullet (●). The bullet color is derived stably from the project id (FNV-1a → HSL → truecolor RGB) so it survives projects being added/removed; a per-project override on the model is a later refinement. The glyph shape is reserved for future semantics. The task list also gains a scrollbar and ListState-driven scroll-to-visible so a selected task below the fold stays reachable. Tests: fmt::project_color determinism unit; a flag-glyph render assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/fmt.rs | 58 +++++++++++++++++++++++ crates/heph-tui/src/ui.rs | 59 ++++++++++++++++++------ crates/heph-tui/tests/agenda.rs | 2 + docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 18 ++++---- 5 files changed, 116 insertions(+), 22 deletions(-) diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 439c979..651d805 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -2,6 +2,7 @@ //! read the wall clock (unlike `heph-core`, which is clock-injected). use chrono::{DateTime, Datelike, Local, NaiveDate}; +use ratatui::style::Color; /// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`, /// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`. @@ -24,6 +25,46 @@ pub fn today_local() -> NaiveDate { Local::now().date_naive() } +/// A stable display color for a project, derived from its node id (§8.1) so the +/// task list's bullets read as project identity. Hashing the id (rather than a +/// position-based palette) keeps each project's color **stable as others are +/// added or removed**, trading perfect spread for occasional near-collisions — +/// acceptable per the design. `None` (no project) is a neutral gray. A future +/// per-project override stored on the model would take precedence over this. +pub fn project_color(project_id: Option<&str>) -> Color { + let Some(id) = project_id else { + return Color::DarkGray; + }; + // FNV-1a over the id → a hue in [0,360); fixed saturation/lightness tuned to + // stay legible on a dark terminal background. + let mut h: u32 = 0x811c_9dc5; + for b in id.bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x0100_0193); + } + let hue = (h as f64 / u32::MAX as f64) * 360.0; + let (r, g, b) = hsl_to_rgb(hue, 0.55, 0.65); + Color::Rgb(r, g, b) +} + +/// HSL (hue 0–360, saturation/lightness 0–1) → 8-bit RGB. +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h / 60.0; + let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match hp as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + let to = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8; + (to(r), to(g), to(b)) +} + #[cfg(test)] mod tests { use super::*; @@ -49,4 +90,21 @@ mod tests { assert_eq!(fmt_date(ms(day(2026, 12, 25)), today), "12-25"); assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); } + + #[test] + fn project_color_is_stable_distinct_and_neutral_when_absent() { + assert_eq!(project_color(None), Color::DarkGray); + // Deterministic: the same id always maps to the same color. + assert_eq!( + project_color(Some("01J_chores")), + project_color(Some("01J_chores")) + ); + // Distinct ids generally differ (these two do). + assert_ne!( + project_color(Some("01J_chores")), + project_color(Some("01J_garden")) + ); + // A project id resolves to a concrete RGB (not a named palette slot). + assert!(matches!(project_color(Some("01J_work")), Color::Rgb(..))); + } } diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6151c27..0aa2182 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -3,16 +3,19 @@ use heph_core::Attention; use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, + ScrollbarOrientation, ScrollbarState, Wrap, + }, Frame, }; use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry}; use crate::backend::Backend; -use crate::fmt::{fmt_date, today_local}; +use crate::fmt::{fmt_date, project_color, today_local}; const HINTS: &str = " j/k move a add x done e date A attn b→blue D del o edit / search q quit"; @@ -157,13 +160,15 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { frame.render_widget(list, area); } -fn attention_style(a: Option<Attention>) -> (char, Style) { +/// The leading flag glyph + color for an attention band: a colored `⚑` for the +/// bands that demand attention (red/orange/blue), blank for white/none. The +/// bullet beside it carries project identity instead (§8.1). +fn flag_style(a: Option<Attention>) -> (&'static str, Style) { match a { - Some(Attention::Red) => ('●', Style::default().fg(Color::Red)), - Some(Attention::Orange) => ('●', Style::default().fg(Color::Yellow)), - Some(Attention::White) => ('○', Style::default().fg(Color::White)), - Some(Attention::Blue) => ('·', Style::default().fg(Color::Blue)), - None => ('·', Style::default().fg(Color::DarkGray)), + Some(Attention::Red) => ("⚑", Style::default().fg(Color::Red)), + Some(Attention::Orange) => ("⚑", Style::default().fg(Color::Yellow)), + Some(Attention::Blue) => ("⚑", Style::default().fg(Color::Blue)), + Some(Attention::White) | None => (" ", Style::default()), } } @@ -214,7 +219,8 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { .iter() .enumerate() .map(|(i, t)| { - let (glyph, gstyle) = attention_style(t.attention); + let (flag, flag_st) = flag_style(t.attention); + let bullet_st = Style::default().fg(project_color(t.project_id.as_deref())); // Right-aligned date chip (late > do). let (chip, chip_style) = if let Some(late) = t .late_on @@ -245,7 +251,7 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { // Pad the title so the right side (↻ + date chip) aligns right. let chip_w = chip.len(); let recur_w = if recur { 2 } else { 0 }; // "↻ " - let fixed = 1 + 2 + 1; // cursor + "glyph " + trailing space + let fixed = 1 + 1 + 1 + 1 + 1; // cursor + flag + bullet + space + trailing space let avail = width.saturating_sub(fixed + recur_w + chip_w); let mut title: String = t.title.chars().take(avail).collect(); let pad = avail.saturating_sub(title.chars().count()); @@ -253,7 +259,9 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { let mut header = vec![ Span::styled(cursor, Style::default().fg(Color::Cyan)), - Span::styled(format!("{glyph} "), gstyle), + Span::styled(flag, flag_st), + Span::styled("●", bullet_st), + Span::raw(" "), Span::styled(title, title_style), Span::raw(" "), ]; @@ -278,7 +286,32 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { .border_style(pane_border(focused)) .title(title), ); - frame.render_widget(list, area); + // A `ListState` selection drives scroll-to-visible so a task below the fold + // stays reachable; the row's own REVERSED styling remains the highlight. + let mut state = ListState::default(); + if !app.tasks.is_empty() { + state.select(Some(app.task_cursor)); + } + frame.render_stateful_widget(list, area, &mut state); + + // A scrollbar appears once the list can't show every task at once. Position + // tracks the selected row (item-indexed — an approximation with the inline + // detail block expanded, but a faithful "where am I in the list" signal). + let inner_h = area.height.saturating_sub(2) as usize; + if app.tasks.len() > inner_h { + let mut sb = ScrollbarState::new(app.tasks.len()).position(app.task_cursor); + let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None); + frame.render_stateful_widget( + bar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut sb, + ); + } } fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 56fa7ed..5625c8a 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -95,6 +95,8 @@ fn agenda_renders_views_projects_and_tasks() { !s.contains("Someday backlog item"), "blue task should not be in Top of Mind:\n{s}" ); + // The red/orange tasks carry a flag glyph in the leading column (§8.1). + assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}"); assert!(s.contains("Preview"), "preview pane missing:\n{s}"); } diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index baa9155..68523d2 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -25,3 +25,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). - `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. - Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. +- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index b72cb7f..e7dbca6 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -253,16 +253,16 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba > **Status: daily-driver core built** (slices T1–T2c; NL quick-add + search are the remaining T3 polish). The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it. - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. -- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). +- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`). - **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. - **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap. -- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows: - - **flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (HSL→truecolor, 256-color fallback; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement. +- **flag column + project-colored bullets** ✅ — a leading **flag glyph** (`⚑`) colored by attention (red/orange/blue; **blank for white/none**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb` truecolor; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement (derived client-side for now). +- **scrollbar** ✅ — a `ratatui` `Scrollbar` on the task list once it overflows (a `ListState` selection drives scroll-to-visible so a task below the fold stays reachable). +- **Planned UX wave** (§14 roadmap, 2026-06-03) — remaining, client-side over the existing `RankedTask` rows: - **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort. - - **scrollbar** — a `ratatui` `Scrollbar` on the task list when it overflows. ## 8.2 Filter views (saved agenda slices) — built @@ -447,11 +447,11 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi > The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order: 1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project <name>|none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3). -2. ⏳ **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** - - **(a) flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**), and the **bullet `●` colored by its project** via a stable `hash(project_id) → hue` (HSL→truecolor `Color::Rgb`, 256-color fallback). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). - - **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. - - **(e) scrollbar** — the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows the pane. - - **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). +2. **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** + - ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). + - ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view. + - ⏳ **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. + - ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). 3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. 4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. -- 2.50.1 (Apple Git-155) From 4f291ce373e6baf7cdefe9d7260d67379377e981 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:01:51 -0700 Subject: [PATCH 65/91] =?UTF-8?q?feat(tui):=20s=20sort=20toggle=20?= =?UTF-8?q?=E2=80=94=20default=20vs=20project-grouped=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `s` flips the task list between two orders: - default: attention (red→orange→white→blue) → most-overdue (desc) → project name → created_at (FIFO) - project: project first, with dimmed ──── Name ──── separators riding atop each group's first task (the cursor only lands on real tasks) The view filter still runs before the sort. Pure comparator (`cmp_tasks`/ `sort_tasks`, today injected) with unit tests for both modes + a navigation test for the toggle. `skip` moved from `s` to `S` to free `s`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 201 +++++++++++++++++++++++ crates/heph-tui/src/fmt.rs | 11 ++ crates/heph-tui/src/main.rs | 3 +- crates/heph-tui/src/ui.rs | 35 +++- crates/heph-tui/tests/navigation.rs | 42 +++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 9 +- 7 files changed, 293 insertions(+), 9 deletions(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 5673ae0..9b1af11 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -2,10 +2,82 @@ //! [`Backend`] so it is testable without a terminal or a daemon. Rendering //! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. +use std::cmp::{Ordering, Reverse}; +use std::collections::HashMap; + use anyhow::Result; +use chrono::NaiveDate; use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; use crate::backend::{Backend, Project, SearchHit}; +use crate::fmt::{days_overdue, today_local}; + +/// How the task list is ordered (toggled in the UI, §8.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortMode { + /// Attention band → most-overdue → project → creation (FIFO). + Default, + /// Project first (grouped, with separators), then the default sub-order. + Project, +} + +/// Attention sort rank: red → orange → white → blue → (none last). +fn attention_rank(a: Option<Attention>) -> u8 { + match a { + Some(Attention::Red) => 0, + Some(Attention::Orange) => 1, + Some(Attention::White) => 2, + Some(Attention::Blue) => 3, + None => 4, + } +} + +/// The project sort component: tasks with a project sort first (by lowercased +/// name), project-less tasks last. +fn project_key(t: &RankedTask, names: &HashMap<String, String>) -> (bool, String) { + match &t.project_id { + Some(id) => ( + false, + names + .get(id) + .cloned() + .unwrap_or_else(|| id.clone()) + .to_lowercase(), + ), + None => (true, String::new()), + } +} + +/// Total order for the agenda (§8.1). **Default**: attention → most-overdue +/// (descending) → project → FIFO. **Project**: project first, then the same +/// sub-order. `today` resolves "how overdue"; `names` maps project id → title. +pub fn cmp_tasks( + a: &RankedTask, + b: &RankedTask, + names: &HashMap<String, String>, + today: NaiveDate, + mode: SortMode, +) -> Ordering { + let att = || attention_rank(a.attention).cmp(&attention_rank(b.attention)); + let over = + || Reverse(days_overdue(a.do_date, today)).cmp(&Reverse(days_overdue(b.do_date, today))); + let proj = || project_key(a, names).cmp(&project_key(b, names)); + let fifo = || a.created_at.cmp(&b.created_at); + match mode { + SortMode::Default => att().then_with(over).then_with(proj).then_with(fifo), + SortMode::Project => proj().then_with(att).then_with(over).then_with(fifo), + } +} + +/// Sort `tasks` in place by [`cmp_tasks`]. +pub fn sort_tasks( + tasks: &mut [RankedTask], + names: &HashMap<String, String>, + today: NaiveDate, + mode: SortMode, +) { + tasks.sort_by(|a, b| cmp_tasks(a, b, names, today, mode)); +} /// The interaction mode: normal navigation, or collecting a line of text. #[derive(Debug, Clone, PartialEq, Eq)] @@ -130,6 +202,8 @@ pub struct App<B: Backend> { pub preview: Preview, pub focus: Focus, pub mode: Mode, + /// How the task list is ordered (`s` toggles it). + pub sort_mode: SortMode, /// When `Some`, a full-text search overlays the task list. pub search: Option<SearchView>, /// When `Some`, a delete is awaiting y/N confirmation. @@ -170,6 +244,7 @@ impl<B: Backend> App<B> { preview: Preview::default(), focus: Focus::Sidebar, mode: Mode::Normal, + sort_mode: SortMode::Default, search: None, pending_delete: None, status: String::new(), @@ -226,6 +301,7 @@ impl<B: Backend> App<B> { match self.load_tasks() { Ok(tasks) => { self.tasks = tasks; + self.apply_sort(); if self.task_cursor >= self.tasks.len() { self.task_cursor = self.tasks.len().saturating_sub(1); } @@ -235,6 +311,40 @@ impl<B: Backend> App<B> { self.reload_preview(); } + /// Project id → title, from the sidebar (for project-aware sorting + the + /// project-mode separators). + fn project_names(&self) -> HashMap<String, String> { + self.sidebar + .iter() + .filter_map(|e| match e { + SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())), + _ => None, + }) + .collect() + } + + /// Re-order the loaded tasks for the current [`SortMode`]. + fn apply_sort(&mut self) { + let names = self.project_names(); + sort_tasks(&mut self.tasks, &names, today_local(), self.sort_mode); + } + + /// Flip between the default and project sort orders (the `s` gesture), + /// re-sorting in place and resetting the cursor to the top. + pub fn toggle_sort(&mut self) { + self.sort_mode = match self.sort_mode { + SortMode::Default => SortMode::Project, + SortMode::Project => SortMode::Default, + }; + self.apply_sort(); + self.task_cursor = 0; + self.reload_preview(); + self.status = match self.sort_mode { + SortMode::Default => "sort: default".into(), + SortMode::Project => "sort: by project".into(), + }; + } + fn load_tasks(&mut self) -> Result<Vec<RankedTask>> { match self.current_target() { Some(Target::View(name)) => self.backend.view(&name), @@ -687,3 +797,94 @@ fn parse_optional_date(s: &str) -> Result<Option<i64>> { Ok(Some(hephd::datespec::parse_date_ms(s)?)) } } + +#[cfg(test)] +mod sort_tests { + use super::*; + use heph_core::TaskState; + + fn t( + id: &str, + att: Option<Attention>, + project: Option<&str>, + do_date: Option<i64>, + created: i64, + ) -> RankedTask { + RankedTask { + node_id: id.into(), + title: id.into(), + attention: att, + do_date, + late_on: None, + state: TaskState::Outstanding, + recurrence: None, + tombstoned: false, + project_id: project.map(str::to_string), + canonical_context_id: None, + created_at: created, + } + } + + fn names() -> HashMap<String, String> { + [ + ("pA".to_string(), "Alpha".to_string()), + ("pB".to_string(), "Beta".to_string()), + ] + .into_iter() + .collect() + } + + fn ids(v: &[RankedTask]) -> Vec<&str> { + v.iter().map(|t| t.node_id.as_str()).collect() + } + + fn day(y: i32, m: u32, d: u32) -> i64 { + NaiveDate::from_ymd_opt(y, m, d) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap() + .and_local_timezone(chrono::Local) + .unwrap() + .timestamp_millis() + } + + #[test] + fn default_sort_is_attention_then_overdue_then_project_then_fifo() { + let today = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(); + let mut tasks = vec![ + t("blue", Some(Attention::Blue), Some("pA"), None, 0), + t( + "red_2d", + Some(Attention::Red), + Some("pB"), + Some(day(2026, 6, 1)), + 5, + ), + t( + "red_4d", + Some(Attention::Red), + Some("pA"), + Some(day(2026, 5, 30)), + 9, + ), + t("white", Some(Attention::White), None, None, 1), + ]; + sort_tasks(&mut tasks, &names(), today, SortMode::Default); + // reds first (most-overdue first), then white, then blue. + assert_eq!(ids(&tasks), vec!["red_4d", "red_2d", "white", "blue"]); + } + + #[test] + fn project_sort_groups_by_name_then_default_suborder_with_none_last() { + let today = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(); + let mut tasks = vec![ + t("b_white", Some(Attention::White), Some("pB"), None, 0), + t("a_blue", Some(Attention::Blue), Some("pA"), None, 1), + t("a_red", Some(Attention::Red), Some("pA"), None, 2), + t("none_red", Some(Attention::Red), None, None, 3), + ]; + sort_tasks(&mut tasks, &names(), today, SortMode::Project); + // Alpha group (red before blue), then Beta, then project-less tasks last. + assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]); + } +} diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 651d805..3fc7373 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -25,6 +25,17 @@ pub fn today_local() -> NaiveDate { Local::now().date_naive() } +/// How many days past its do-date a task is (0 if not overdue, no do-date, or +/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1). +pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 { + match do_date.and_then(DateTime::from_timestamp_millis) { + Some(dt) => (today - dt.with_timezone(&Local).date_naive()) + .num_days() + .max(0), + None => 0, + } +} + /// A stable display color for a project, derived from its node id (§8.1) so the /// task list's bullets read as project identity. Hashing the id (rather than a /// position-based palette) keeps each project's color **stable as others are diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index c48a42c..89ee6e6 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -163,7 +163,8 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A // triage mutations (act on the highlighted task) KeyCode::Char('x') => app.complete_selected(), KeyCode::Char('d') => app.drop_selected(), - KeyCode::Char('s') => app.skip_selected(), + KeyCode::Char('S') => app.skip_selected(), + KeyCode::Char('s') => app.toggle_sort(), KeyCode::Char('A') => app.cycle_attention_selected(), KeyCode::Char('b') => app.push_to_blue_selected(), KeyCode::Char('m') => app.begin_move(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 0aa2182..acf5f35 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -13,12 +13,12 @@ use ratatui::{ Frame, }; -use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry}; +use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry, SortMode}; use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; const HINTS: &str = - " j/k move a add x done e date A attn b→blue D del o edit / search q quit"; + " j/k move a add x done S skip e date A attn b→blue m move D del s sort o edit / search q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; @@ -160,6 +160,22 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { frame.render_widget(list, area); } +/// A dimmed `──── Project ────` group header for the project sort mode, padded +/// to `width` columns with the name centered. +fn project_separator(name: &str, width: usize) -> Line<'static> { + let label = format!(" {name} "); + let dashes = width.saturating_sub(label.chars().count()); + let left = dashes / 2; + let right = dashes - left; + let text = format!("{}{}{}", "─".repeat(left), label, "─".repeat(right)); + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) +} + /// The leading flag glyph + color for an attention band: a colored `⚑` for the /// bands that demand attention (red/orange/blue), blank for white/none. The /// bullet beside it carries project identity instead (§8.1). @@ -270,7 +286,20 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { } header.push(Span::styled(chip, chip_style)); - let mut lines = vec![Line::from(header)]; + let mut lines = Vec::new(); + // In project sort, head each project group with a separator row + // (non-selectable — it rides atop the group's first task). + if app.sort_mode == SortMode::Project + && (i == 0 || app.tasks[i - 1].project_id != t.project_id) + { + let name = t + .project_id + .as_deref() + .and_then(|id| app.project_name(id)) + .unwrap_or_else(|| "(No project)".into()); + lines.push(project_separator(&name, width)); + } + lines.push(Line::from(header)); if selected { lines.extend(task_detail_lines(app, t, today)); } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index db85c8d..b056f90 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -366,6 +366,48 @@ fn move_to_project_picker_refiles_the_selected_task() { assert_eq!(refiled[0], ("t1".into(), Some("p1".into()))); } +#[test] +fn toggle_sort_switches_mode_and_regroups_by_project() { + use heph_tui::app::SortMode; + let mut beta = task("b1", "beta one", Attention::Red, None); + beta.project_id = Some("pB".into()); + let mut alpha = task("a1", "alpha one", Attention::White, None); + alpha.project_id = Some("pA".into()); + let mut fake = Fake { + projects: vec![ + Project { + id: "pB".into(), + title: "Beta".into(), + }, + Project { + id: "pA".into(), + title: "Alpha".into(), + }, + ], + ..Default::default() + }; + fake.views.insert("tom".into(), vec![beta, alpha]); + + let mut app = App::new(fake).unwrap(); + let order = + |a: &App<Fake>| -> Vec<String> { a.tasks.iter().map(|t| t.node_id.clone()).collect() }; + + // Default sort: attention first → red (b1) before white (a1). + assert_eq!(app.sort_mode, SortMode::Default); + assert_eq!(order(&app), vec!["b1", "a1"]); + + // Project sort: Alpha group before Beta → a1 before b1. + app.toggle_sort(); + assert_eq!(app.sort_mode, SortMode::Project); + assert_eq!(order(&app), vec!["a1", "b1"]); + assert!(app.status.contains("project"), "status: {}", app.status); + + // Toggling back restores the default order. + app.toggle_sort(); + assert_eq!(app.sort_mode, SortMode::Default); + assert_eq!(order(&app), vec!["b1", "a1"]); +} + #[test] fn move_to_project_cancel_refiles_nothing() { use heph_tui::app::Mode; diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 68523d2..6394d78 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -26,3 +26,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. - Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. +- `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index e7dbca6..d70930f 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,15 +254,14 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `S` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `s` **sort toggle** (default ↔ project-grouped) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. - **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap. - **flag column + project-colored bullets** ✅ — a leading **flag glyph** (`⚑`) colored by attention (red/orange/blue; **blank for white/none**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb` truecolor; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement (derived client-side for now). - **scrollbar** ✅ — a `ratatui` `Scrollbar` on the task list once it overflows (a `ListState` selection drives scroll-to-visible so a task below the fold stays reachable). -- **Planned UX wave** (§14 roadmap, 2026-06-03) — remaining, client-side over the existing `RankedTask` rows: - - **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort. +- **sort toggle `s`** ✅ — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with dimmed **`──── Name ────` group separators** that ride atop each group's first task (so the cursor only ever lands on real tasks). The view filter always runs before the sort. (`skip` moved to `S`.) ## 8.2 Filter views (saved agenda slices) — built @@ -450,8 +449,8 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi 2. **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** - ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). - ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view. - - ⏳ **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. - - ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). + - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) + - ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). *(The only piece of item 2 left.)* 3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. 4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. -- 2.50.1 (Apple Git-155) From 9d84eb7427c9c527cb7ced2e69e9a9c2de0b4bcd Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:05:02 -0700 Subject: [PATCH 66/91] =?UTF-8?q?feat(nvim):=20do/late=20date=20chip=20(+?= =?UTF-8?q?=20=E2=86=BB)=20on=20task-view=20rows=20(=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:Heph next`/`list` rows now render a compact relative do/late date chip (today/tomorrow/yesterday/MM-DD/YYYY-MM-DD, mirroring heph-tui's fmt) and a recurrence ↻, so scheduling is visible at a glance. `<CR>` already jumps to a row's canonical-context doc. e2e: a do-date-chip render assertion. Completes the §14 item-2 task-list UX wave. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 5 +-- docs/reference/tech-spec.md | 4 +-- heph.nvim/lua/heph/view.lua | 39 +++++++++++++++++++++++- heph.nvim/tests/e2e/view_spec.lua | 12 ++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 6394d78..daf4b2a 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -27,3 +27,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) +- `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index e28ddaf..99da295 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -1,6 +1,6 @@ --- title: heph.nvim -modified: 2026-06-01 +modified: 2026-06-03 tags: - reference - design @@ -84,7 +84,8 @@ doc whose owning task is followed via its `canonical-context` backlink. The `next`/`list` views render the titled rows the daemon returns (`list` enriched to carry titles + the context id, so no N+1 `node.get`) and 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. Pickers use built-in +marks the task under the cursor done, `r` refreshes. Each row also shows a +compact **do/late date chip** (and a recurrence `↻`). Pickers use built-in `vim.ui.select`, auto-upgrading to Telescope when installed. **Promotion** (`:Heph promote`) mints a committed task from the `- [ ]` line diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index d70930f..39ce07b 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -446,11 +446,11 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi > The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order: 1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project <name>|none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3). -2. **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** +2. ✅ **`heph-tui` task-list UX wave + nvim nav polish (§8.1/§8) — DONE (no backend; `RankedTask` already carries every field):** - ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). - ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view. - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - - ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). *(The only piece of item 2 left.)* + - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. 4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua index fbc10fa..4ceabe2 100644 --- a/heph.nvim/lua/heph/view.lua +++ b/heph.nvim/lua/heph/view.lua @@ -17,9 +17,46 @@ local HINT = " <CR> open a add d done r refresh" local ATTENTIONS = { "white", "orange", "red", "blue" } +-- Compact relative date for a do/late epoch-ms value (mirrors heph-tui's fmt): +-- today / tomorrow / yesterday, MM-DD within the year, else YYYY-MM-DD. +local function fmt_date(ms) + local d = os.date("*t", math.floor(ms / 1000)) + local n = os.date("*t") + local d_noon = os.time({ year = d.year, month = d.month, day = d.day, hour = 12 }) + local n_noon = os.time({ year = n.year, month = n.month, day = n.day, hour = 12 }) + local days = math.floor((d_noon - n_noon) / 86400 + 0.5) + if days == 0 then + return "today" + elseif days == 1 then + return "tomorrow" + elseif days == -1 then + return "yesterday" + elseif d.year == n.year then + return string.format("%02d-%02d", d.month, d.day) + else + return string.format("%04d-%02d-%02d", d.year, d.month, d.day) + end +end + +-- The right-side date chip: a late marker once past due, else the do-date. +local function date_chip(t) + if t.late_on and os.time() * 1000 > t.late_on then + return "late:" .. fmt_date(t.late_on) + elseif t.do_date then + return "do:" .. fmt_date(t.do_date) + end + return "" +end + local function row(t) local tag = t.attention and ("[" .. t.attention .. "]") or "[ ]" - return string.format("%s %s", tag, t.title) + local recur = t.recurrence and " ↻" or "" + local left = string.format("%s %s%s", tag, t.title, recur) + local chip = date_chip(t) + if chip ~= "" then + return string.format("%-50s %s", left, chip) + end + return left end local function task_on_line(buf) diff --git a/heph.nvim/tests/e2e/view_spec.lua b/heph.nvim/tests/e2e/view_spec.lua index fc88358..7bc12e1 100644 --- a/heph.nvim/tests/e2e/view_spec.lua +++ b/heph.nvim/tests/e2e/view_spec.lua @@ -33,6 +33,18 @@ describe("filter views", function() assert.is_falsy(text:find("cool thing", 1, true), "blue task should not be in ToM") end) + it("shows a do-date chip on a dated task row", function() + -- A past do-date keeps the task actionable (so it appears in ToM) and, with + -- no late_on, renders as a `do:` chip rather than a `late:` one. + ctx.q:call("task.create", { title = "dated thing", attention = "red", do_date = 1704067200000 }) + + require("heph.view").view("tom") + local buf = vim.api.nvim_get_current_buf() + local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + assert.is_truthy(text:find("dated thing", 1, true), "dated task missing") + assert.is_truthy(text:find("do:", 1, true), "do-date chip missing from the row") + end) + it("scopes the chores view to chore projects via the daemon", function() local chores = ctx.q:call("node.create", { kind = "project", title = "Chores" }) ctx.q:call("task.create", { title = "take out trash", attention = "white", project_id = chores.id }) -- 2.50.1 (Apple Git-155) From 4cdf0de64c52d4ca65cb06cb4f4cc6ea683542e6 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:18:51 -0700 Subject: [PATCH 67/91] =?UTF-8?q?feat(core):=20tags=20=E2=80=94=20canonica?= =?UTF-8?q?l=20tag=20nodes=20+=20OR-set=20tagging=20(=C2=A74,=20=C2=A78.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag is a `tag`-kind node with a deterministic id in (owner, name) (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag shared across nodes and replicas converge with no duplicates. Tagging is an OR-set `tagged` link (mirroring in-project): - heph-core: `nodes::open_or_create_tag` (bodyless, deterministic id), `tags::{add,remove,of}`, and `Store::{add_tag,remove_tag,tags_of}`. Enumerate all tags via the existing `list_nodes(Tag)`. - hephd: `tag.add`/`tag.remove`/`tag.list` RPCs + RemoteStore forwarding. - heph: `heph tag add|rm|list` (a node's tags, or every tag). Names are trimmed; canonical case/spelling normalization is deferred to the zk import. Unblocks the `tags:` line of the frontmatter surface. Tests: core add/dedupe/remove/canonical-id/trim/missing-node + a socket add/list/enumerate/remove test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/mod.rs | 15 +++++ crates/heph-core/src/sqlite/nodes.rs | 40 +++++++++++++ crates/heph-core/src/sqlite/tags.rs | 73 ++++++++++++++++++++++++ crates/heph-core/src/store.rs | 16 ++++++ crates/heph-core/tests/tags.rs | 66 +++++++++++++++++++++ crates/heph/src/main.rs | 58 +++++++++++++++++++ crates/hephd/src/remote.rs | 13 +++++ crates/hephd/src/rpc.rs | 21 +++++++ crates/hephd/tests/rpc_socket.rs | 40 +++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 2 +- 11 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 crates/heph-core/src/sqlite/tags.rs create mode 100644 crates/heph-core/tests/tags.rs diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 630ba0e..01b455d 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -17,6 +17,7 @@ mod migrations; mod nodes; mod ops; mod syncstate; +mod tags; mod tasks; pub use migrations::latest_version; @@ -306,6 +307,20 @@ impl Store for LocalStore { links::backlinks(&self.conn, id) } + fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node> { + let now = self.clock.now_ms(); + tags::add(&mut self.conn, &self.owner_id, now, node_id, tag) + } + + fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()> { + let now = self.clock.now_ms(); + tags::remove(&mut self.conn, &self.owner_id, now, node_id, tag) + } + + fn tags_of(&self, node_id: &str) -> Result<Vec<String>> { + tags::of(&self.conn, node_id) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { let now = self.clock.now_ms(); let tx = self.conn.transaction()?; diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index c919e7d..3378c63 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -216,6 +216,46 @@ pub(super) fn open_or_create_journal( Ok(node) } +/// Open (creating if absent) the `tag`-kind node named `name`. Like the journal +/// (§3.1), its id is **deterministic** in `(owner, name)`, so the same name is +/// one canonical tag and independent offline taggings converge. Tags are +/// bodyless. `name` must be non-empty (callers trim first). +pub(super) fn open_or_create_tag( + conn: &Connection, + owner: &str, + now: i64, + name: &str, +) -> Result<Node> { + if name.is_empty() { + return Err(Error::Integrity("tag name must not be empty".into())); + } + let id = deterministic_id(owner, NodeKind::Tag, name); + if let Some(existing) = get(conn, &id)? { + return Ok(existing); + } + let node = Node { + id, + owner_id: owner.to_string(), + kind: NodeKind::Tag, + title: name.to_string(), + body: None, + created_at: now, + modified_at: now, + hlc: next_hlc(conn, now)?, + tombstoned: false, + }; + insert(conn, &node)?; + ops::record( + conn, + owner, + &node.hlc, + op_type::NODE_CREATE, + &node.id, + create_payload(&node, None), + )?; + Ok(node) +} + fn is_iso_date(s: &str) -> bool { let b = s.as_bytes(); b.len() == 10 diff --git a/crates/heph-core/src/sqlite/tags.rs b/crates/heph-core/src/sqlite/tags.rs new file mode 100644 index 0000000..09dbaac --- /dev/null +++ b/crates/heph-core/src/sqlite/tags.rs @@ -0,0 +1,73 @@ +//! Tag operations (tech-spec §4, §8.3). A tag is a `tag`-kind node with a +//! deterministic id (so a name is one canonical tag, §3.1); tagging is an +//! **OR-set** `tagged` link from the tagged node to the tag node — mirroring +//! `in-project`. Names are trimmed; case is preserved (a canonical +//! normalization is deferred to the zk import, [[design]]). + +use rusqlite::Connection; + +use super::{links, nodes}; +use crate::error::{Error, Result}; +use crate::model::{Link, LinkType, Node}; + +/// Tag `node_id` with `name`, creating the canonical tag node on first use. +/// Idempotent: a node already carrying the tag is left unchanged. Returns the +/// tag node. Errors if the target node is missing or the name is blank. +pub(super) fn add( + conn: &mut Connection, + owner: &str, + now: i64, + node_id: &str, + name: &str, +) -> Result<Node> { + let name = name.trim(); + if name.is_empty() { + return Err(Error::Integrity("tag name must not be empty".into())); + } + if nodes::get(conn, node_id)?.is_none() { + return Err(Error::NodeNotFound(node_id.into())); + } + + let tx = conn.transaction()?; + let tag = nodes::open_or_create_tag(&tx, owner, now, name)?; + let already = links::outgoing(&tx, node_id)? + .iter() + .any(|l: &Link| l.link_type == LinkType::Tagged && l.dst_id == tag.id); + if !already { + links::add(&tx, owner, now, node_id, &tag.id, LinkType::Tagged)?; + } + tx.commit()?; + Ok(tag) +} + +/// Untag `node_id` — tombstone its `tagged` link(s) to the `name` tag. A no-op +/// if the node isn't tagged with it. The tag node itself persists. +pub(super) fn remove( + conn: &mut Connection, + owner: &str, + now: i64, + node_id: &str, + name: &str, +) -> Result<()> { + let name = name.trim(); + let tag_id = crate::model::deterministic_id(owner, crate::model::NodeKind::Tag, name); + let tx = conn.transaction()?; + for link in links::outgoing(&tx, node_id)? { + if link.link_type == LinkType::Tagged && link.dst_id == tag_id { + links::tombstone(&tx, owner, now, &link.id)?; + } + } + tx.commit()?; + Ok(()) +} + +/// The tag names on `node_id`, sorted (tombstoned tags excluded). +pub(super) fn of(conn: &Connection, node_id: &str) -> Result<Vec<String>> { + let mut stmt = conn.prepare( + "SELECT n.title FROM links l JOIN nodes n ON n.id = l.dst_id + WHERE l.src_id = ?1 AND l.type = 'tagged' AND l.tombstoned = 0 AND n.tombstoned = 0 + ORDER BY n.title", + )?; + let rows = stmt.query_map([node_id], |r| r.get::<_, String>(0))?; + Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 5044b15..656b1cb 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -141,6 +141,22 @@ pub trait Store { /// All non-tombstoned links pointing at `id` (backlinks). fn backlinks(&self, id: &str) -> Result<Vec<Link>>; + // --- tags (tech-spec §4, §8.3) --- + + /// Tag `node_id` with `tag` (trimmed), creating the canonical `tag`-kind + /// node on first use — its id is deterministic in `(owner, name)`, so a + /// name is one shared tag and replicas converge. OR-set `tagged` link; + /// idempotent. Returns the tag node. Errors on a missing node or blank name. + fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node>; + + /// Remove `tag` from `node_id` (tombstone the `tagged` link); a no-op if it + /// isn't tagged. The tag node itself persists. + fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()>; + + /// The tag names on `node_id`, sorted. (Enumerate *all* tags via + /// [`Store::list_nodes`] with [`NodeKind::Tag`].) + fn tags_of(&self, node_id: &str) -> Result<Vec<String>>; + // --- per-task log ([[design]] §6.4) --- /// Append a line to a task's append-only log (creating the log on first diff --git a/crates/heph-core/tests/tags.rs b/crates/heph-core/tests/tags.rs new file mode 100644 index 0000000..5d6d386 --- /dev/null +++ b/crates/heph-core/tests/tags.rs @@ -0,0 +1,66 @@ +//! Public-API tests for tags (tech-spec §4, §8.3): a tag is a `tag`-kind node, +//! tagging is an OR-set `tagged` link, and tag node ids are **deterministic in +//! (owner, name)** so the same name is one canonical tag (and replicas converge). + +use heph_core::{FixedClock, LocalStore, NewNode, NodeKind, Store}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn add_lists_dedupes_and_removes_tags() { + let mut s = store(); + let doc = s.create_node(NewNode::doc("Roof notes", "")).unwrap(); + + // Add two tags; `tags_of` returns them sorted by name. + s.add_tag(&doc.id, "house").unwrap(); + s.add_tag(&doc.id, "urgent").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["house", "urgent"]); + + // Re-adding a tag is idempotent — no duplicate `tagged` link. + s.add_tag(&doc.id, "house").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["house", "urgent"]); + + // Removing one leaves the other. + s.remove_tag(&doc.id, "house").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["urgent"]); + + // Removing a tag the node doesn't have is a harmless no-op. + s.remove_tag(&doc.id, "house").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["urgent"]); +} + +#[test] +fn a_tag_name_is_one_canonical_node_shared_across_nodes() { + let mut s = store(); + let a = s.create_node(NewNode::doc("A", "")).unwrap(); + let b = s.create_node(NewNode::doc("B", "")).unwrap(); + + let ta = s.add_tag(&a.id, "shared").unwrap(); + let tb = s.add_tag(&b.id, "shared").unwrap(); + + assert_eq!(ta.id, tb.id, "same name → one canonical tag node"); + assert_eq!(ta.kind, NodeKind::Tag); + + // The tag is enumerable, and there is exactly one for the name. + let tags = s.list_nodes(Some(NodeKind::Tag)).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "shared"); +} + +#[test] +fn add_tag_trims_and_rejects_blank_or_missing_node() { + let mut s = store(); + let d = s.create_node(NewNode::doc("D", "")).unwrap(); + + // Leading/trailing whitespace is trimmed (so " house " == "house"). + s.add_tag(&d.id, " house ").unwrap(); + assert_eq!(s.tags_of(&d.id).unwrap(), vec!["house"]); + + assert!(s.add_tag(&d.id, " ").is_err(), "blank tag rejected"); + assert!( + s.add_tag("nope", "x").is_err(), + "missing target node rejected" + ); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 53de382..fef4a3c 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -209,6 +209,11 @@ enum Command { #[command(subcommand)] action: ProjectAction, }, + /// Tag operations: add/remove a tag on a node, or list tags. + Tag { + #[command(subcommand)] + action: TagAction, + }, /// Force a sync cycle (or show sync status with --status). Sync { /// Show status instead of syncing. @@ -284,6 +289,29 @@ enum ProjectAction { List, } +#[derive(Subcommand, Debug)] +enum TagAction { + /// Tag a node. + Add { + /// Node id to tag. + node: String, + /// Tag name. + tag: String, + }, + /// Remove a tag from a node. + Rm { + /// Node id. + node: String, + /// Tag name. + tag: String, + }, + /// List a node's tags, or every tag in the store (with no node). + List { + /// Node id (omit to list all tags). + node: Option<String>, + }, +} + #[derive(Subcommand, Debug)] enum ConflictAction { /// Resolve a conflict by choosing the local or remote value. @@ -638,6 +666,36 @@ fn main() -> Result<()> { } } }, + Command::Tag { action } => match action { + TagAction::Add { node, tag } => { + let result = client.call("tag.add", json!({ "node_id": node, "tag": tag }))?; + let t: Node = serde_json::from_value(result)?; + println!("{node} tagged #{}", t.title); + } + TagAction::Rm { node, tag } => { + client.call("tag.remove", json!({ "node_id": node, "tag": tag }))?; + println!("{node} untagged #{tag}"); + } + TagAction::List { node } => { + let tags: Vec<String> = match node { + Some(node) => { + let result = client.call("tag.list", json!({ "node_id": node }))?; + serde_json::from_value(result)? + } + None => { + let result = client.call("node.list", json!({ "kind": "tag" }))?; + let nodes: Vec<Node> = serde_json::from_value(result)?; + nodes.into_iter().map(|n| n.title).collect() + } + }; + if tags.is_empty() { + println!("(no tags)"); + } + for t in &tags { + println!("#{t}"); + } + } + }, Command::Sync { status } => { let method = if status { "sync.status" } else { "sync.now" }; let result = client.call(method, json!({}))?; diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 952982b..7449bc8 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -235,6 +235,19 @@ impl Store for RemoteStore { self.call_as("links.backlinks", json!({ "id": id })) } + fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node> { + self.call_as("tag.add", json!({ "node_id": node_id, "tag": tag })) + } + + fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()> { + self.call("tag.remove", json!({ "node_id": node_id, "tag": tag })) + .map(|_| ()) + } + + fn tags_of(&self, node_id: &str) -> Result<Vec<String>> { + self.call_as("tag.list", json!({ "node_id": node_id })) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { self.call("log.append", json!({ "task_id": task_id, "text": text })) .map(|_| ()) diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 00ba9af..9dbf024 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -143,6 +143,14 @@ struct SetAttentionParams { attention: Attention, } +#[derive(Deserialize)] +struct TagParams { + node_id: String, + /// The tag name. Unused by `tag.list` (which sends only `node_id`). + #[serde(default)] + tag: String, +} + #[derive(Deserialize)] struct SetProjectParams { id: String, @@ -326,6 +334,19 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: LinkParams = parse(params)?; json!(store.backlinks(&p.id)?) } + "tag.add" => { + let p: TagParams = parse(params)?; + json!(store.add_tag(&p.node_id, &p.tag)?) + } + "tag.remove" => { + let p: TagParams = parse(params)?; + store.remove_tag(&p.node_id, &p.tag)?; + json!({ "ok": true }) + } + "tag.list" => { + let p: TagParams = parse(params)?; + json!(store.tags_of(&p.node_id)?) + } "log.append" => { let p: LogAppendParams = parse(params)?; store.log_append(&p.task_id, &p.text)?; diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index d9a188d..687f3ff 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -227,6 +227,46 @@ fn task_set_project_moves_and_unfiles_over_socket() { ); } +#[test] +fn tag_add_list_remove_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let doc = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Roof notes" }), + ) + .unwrap(); + let id = doc["id"].as_str().unwrap().to_string(); + + // Add two tags (trimmed); tag.list returns them sorted. + c.call("tag.add", json!({ "node_id": id, "tag": "house" })) + .unwrap(); + c.call("tag.add", json!({ "node_id": id, "tag": " urgent " })) + .unwrap(); + let tags = c.call("tag.list", json!({ "node_id": id })).unwrap(); + assert_eq!(tags, json!(["house", "urgent"])); + + // The canonical tag set is enumerable via node.list kind=tag. + let all = c.call("node.list", json!({ "kind": "tag" })).unwrap(); + let names: Vec<&str> = all + .as_array() + .unwrap() + .iter() + .map(|n| n["title"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["house", "urgent"]); + + // Remove one. + c.call("tag.remove", json!({ "node_id": id, "tag": "house" })) + .unwrap(); + assert_eq!( + c.call("tag.list", json!({ "node_id": id })).unwrap(), + json!(["urgent"]) + ); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index daf4b2a..1dfe9a2 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,3 +28,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. +- Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 39ce07b..0c535f8 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -451,7 +451,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view. - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). -3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. +3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). 4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -- 2.50.1 (Apple Git-155) From ef56c5d5f2723355d36ae2d491727a201f4d14e3 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:32:59 -0700 Subject: [PATCH 68/91] =?UTF-8?q?feat(core,hephd):=20frontmatter=20project?= =?UTF-8?q?ion=20=E2=80=94=20render=20on=20read,=20strip=20on=20write=20(?= =?UTF-8?q?=C2=A78.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-side half of the frontmatter edit surface: - heph-core `frontmatter::strip` runs in `update_node` before the yrs diff, so frontmatter never enters the body or CRDT. Conservative (only a leading `---` block whose first line is a YAML key; a prose hrule survives) and idempotent → read→write round-trip is a no-op. - hephd `frontmatter::render` (local-tz dates via new `datespec::fmt_iso`) behind `node.get {frontmatter: true}`: id/kind/title/tags, and for a task or its canonical-context doc the owning task's scalars + a `task:` ref. Subject-task + project-name resolution in dispatch. Safe against any client (inbound frontmatter always stripped). Tests: strip unit (incl. hrule/idempotency), render unit, socket round-trip + task-context-doc projection. The heph.nvim diff-into-RPCs layer is next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/frontmatter.rs | 92 +++++++++++++++ crates/heph-core/src/lib.rs | 1 + crates/heph-core/src/sqlite/nodes.rs | 3 + crates/hephd/src/datespec.rs | 9 ++ crates/hephd/src/frontmatter.rs | 138 +++++++++++++++++++++++ crates/hephd/src/lib.rs | 1 + crates/hephd/src/rpc.rs | 56 ++++++++- crates/hephd/tests/rpc_socket.rs | 87 ++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 28 ++++- 10 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 crates/heph-core/src/frontmatter.rs create mode 100644 crates/hephd/src/frontmatter.rs diff --git a/crates/heph-core/src/frontmatter.rs b/crates/heph-core/src/frontmatter.rs new file mode 100644 index 0000000..b437b58 --- /dev/null +++ b/crates/heph-core/src/frontmatter.rs @@ -0,0 +1,92 @@ +//! YAML frontmatter is a **projection** (tech-spec §8.3): the daemon renders an +//! editable block on read; heph-core **strips** it on write so it never enters +//! the stored body or the text CRDT. This module owns only the strip side (the +//! render side needs timezone-aware date formatting and lives in `hephd`). +//! +//! Stripping is **conservative**: it removes only a leading, well-formed +//! `---`-delimited block whose first line is a YAML key, so a leading `---` +//! thematic break in ordinary prose is left intact. It is idempotent — a body +//! that has already been stripped is returned unchanged — which keeps the +//! read→write round-trip a no-op even when a client echoes the rendered +//! frontmatter straight back. + +/// Return `body` without a leading YAML frontmatter block. If `body` has no +/// conforming frontmatter, it is returned unchanged (borrowed). +pub fn strip(body: &str) -> &str { + // Must open with the fence on its own first line. + let Some(rest) = body.strip_prefix("---\n") else { + return body; + }; + // The first line inside must look like a YAML key (`key:` / `key: value`). + // A markdown thematic break (`---` then a blank line / heading / prose) + // never does, so this rejects hrules. + if !looks_like_yaml_key(rest.lines().next().unwrap_or("")) { + return body; + } + // Find the closing fence — a line that is exactly `---` — and return what + // follows it. No closing fence ⇒ not frontmatter; leave the body untouched. + let mut offset = 0; + for line in rest.split_inclusive('\n') { + if line.strip_suffix('\n').unwrap_or(line) == "---" { + return &rest[offset + line.len()..]; + } + offset += line.len(); + } + body +} + +/// Does `line` begin with a bare YAML key followed by a colon +/// (`[A-Za-z0-9_-]+:`)? Used to distinguish frontmatter from an hrule. +fn looks_like_yaml_key(line: &str) -> bool { + let b = line.as_bytes(); + let mut i = 0; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'-') { + i += 1; + } + i > 0 && b.get(i) == Some(&b':') +} + +#[cfg(test)] +mod tests { + use super::strip; + + #[test] + fn strips_a_leading_frontmatter_block() { + let body = "---\nid: x\ntitle: Roof\n---\n# Roof\n\nnotes\n"; + assert_eq!(strip(body), "# Roof\n\nnotes\n"); + } + + #[test] + fn empty_body_after_frontmatter() { + assert_eq!(strip("---\nid: x\n---\n"), ""); + } + + #[test] + fn idempotent_when_already_stripped() { + let body = "# Roof\n\nnotes\n"; + assert_eq!(strip(body), body); + assert_eq!(strip(strip(body)), body); + } + + #[test] + fn leaves_a_leading_thematic_break_intact() { + // A real markdown hrule: `---` then a heading, not `key:` lines. + let body = "---\n\n# Heading\n\ntext\n"; + assert_eq!(strip(body), body); + // …and a `---` separating prose later in the doc is never touched. + let mid = "para one\n\n---\n\npara two\n"; + assert_eq!(strip(mid), mid); + } + + #[test] + fn unterminated_block_is_not_frontmatter() { + let body = "---\nid: x\nno closing fence here\n"; + assert_eq!(strip(body), body); + } + + #[test] + fn only_the_first_block_is_removed() { + let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n"; + assert_eq!(strip(body), "body\n\n---\n\nmore\n"); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 73978e0..98d4a8c 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod error; pub mod export; pub mod extract; pub mod filter; +pub mod frontmatter; pub mod hlc; pub mod model; pub mod oplog; diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 3378c63..9dbd5db 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -294,6 +294,9 @@ pub(super) fn update( if let Some(t) = title { node.title = t; } + // Frontmatter is a read-only projection (§8.3): strip any leading block a + // client echoes back so it never enters the stored body or the text CRDT. + let body = body.map(|b| crate::frontmatter::strip(&b).to_string()); let body_changed = match body { Some(b) => { let changed = node.body.as_deref() != Some(b.as_str()); diff --git a/crates/hephd/src/datespec.rs b/crates/hephd/src/datespec.rs index 1ffadfe..71d23e6 100644 --- a/crates/hephd/src/datespec.rs +++ b/crates/hephd/src/datespec.rs @@ -62,6 +62,15 @@ pub fn to_epoch_ms(date: NaiveDate) -> i64 { } } +/// Absolute `YYYY-MM-DD` for an epoch-ms date (local). The round-trippable form +/// for the frontmatter edit surface (§8.3) — `parse_date` reads it straight back. +pub fn fmt_iso(ms: i64) -> String { + match Local.timestamp_millis_opt(ms).earliest() { + Some(dt) => dt.date_naive().format("%Y-%m-%d").to_string(), + None => ms.to_string(), + } +} + /// Compact display of an epoch-ms date: `MM-DD` within the current year, /// `YYYY-MM-DD` otherwise. pub fn fmt_date(ms: i64) -> String { diff --git a/crates/hephd/src/frontmatter.rs b/crates/hephd/src/frontmatter.rs new file mode 100644 index 0000000..de77357 --- /dev/null +++ b/crates/hephd/src/frontmatter.rs @@ -0,0 +1,138 @@ +//! Render the editable YAML frontmatter projection for a node (tech-spec §8.3). +//! +//! This is the **read** side: the daemon prepends this block to a node's body +//! when a client requests `node.get {frontmatter: true}`. `heph-core` strips it +//! back off on write, so it never persists. Lives in `hephd` (not `heph-core`) +//! because formatting `do_date`/`late_on` for humans needs the local timezone +//! (via [`crate::datespec`]). +//! +//! Schema (a curated, *editable* subset — not the full export snapshot): +//! `id`/`kind` are read-only; `title` and `tags` edit the node itself; when the +//! node **is** a task or backs one (its canonical-context doc), the task's +//! scalars (`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`) are +//! rendered and a read-only `task:` id is included so the editor knows where to +//! route those edits. + +use std::fmt::Write as _; + +use heph_core::{Node, Task}; + +use crate::datespec; + +/// Render the frontmatter block (with the `---` fences and a trailing newline) +/// for `node`. `task` is the node's own task or — for a task's +/// canonical-context doc — its owning task. `project_name`/`tags` are resolved +/// by the caller. +pub fn render( + node: &Node, + task: Option<&Task>, + project_name: Option<&str>, + tags: &[String], +) -> String { + let mut fm = String::new(); + let _ = writeln!(fm, "---"); + let _ = writeln!(fm, "id: {}", node.id); + let _ = writeln!(fm, "kind: {}", node.kind.as_str()); + let _ = writeln!(fm, "title: {}", scalar(&node.title)); + let _ = writeln!(fm, "tags: {}", flow_seq(tags)); + + if let Some(t) = task { + let _ = writeln!(fm, "task: {}", t.node_id); + let _ = writeln!(fm, "state: {}", t.state.as_str()); + if let Some(a) = t.attention { + let _ = writeln!(fm, "attention: {}", a.as_str()); + } + if let Some(d) = t.do_date { + let _ = writeln!(fm, "do_date: {}", datespec::fmt_iso(d)); + } + if let Some(l) = t.late_on { + let _ = writeln!(fm, "late_on: {}", datespec::fmt_iso(l)); + } + if let Some(r) = &t.recurrence { + let _ = writeln!(fm, "recurrence: {}", scalar(r)); + } + if let Some(p) = project_name { + let _ = writeln!(fm, "project: {}", scalar(p)); + } + } + + let _ = writeln!(fm, "---"); + fm +} + +/// A YAML scalar, quoted only when bare would be ambiguous (empty, surrounding +/// whitespace, or a character that would break a bare flow scalar). +fn scalar(s: &str) -> String { + let needs_quote = s.is_empty() + || s != s.trim() + || s.starts_with([ + '#', '-', '[', ']', '{', '}', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`', + ]) + || s.contains([':', ',', '\n']); + if needs_quote { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + s.to_string() + } +} + +/// A YAML flow sequence: `[a, b]`, each element scalar-quoted as needed; `[]` +/// when empty. +fn flow_seq(items: &[String]) -> String { + let inner: Vec<String> = items.iter().map(|s| scalar(s)).collect(); + format!("[{}]", inner.join(", ")) +} + +#[cfg(test)] +mod tests { + use super::*; + use heph_core::{Attention, NodeKind, TaskState}; + + fn doc(title: &str) -> Node { + Node { + id: "doc1".into(), + owner_id: "u".into(), + kind: NodeKind::Doc, + title: title.into(), + body: Some(String::new()), + created_at: 0, + modified_at: 0, + hlc: "0".into(), + tombstoned: false, + } + } + + #[test] + fn renders_a_plain_doc_block() { + let fm = render(&doc("Roof"), None, None, &["house".into(), "urgent".into()]); + assert_eq!( + fm, + "---\nid: doc1\nkind: doc\ntitle: Roof\ntags: [house, urgent]\n---\n" + ); + } + + #[test] + fn renders_task_scalars_and_project_when_a_task_is_present() { + let task = Task { + node_id: "task1".into(), + attention: Some(Attention::Red), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + recurrence: None, + }; + let fm = render(&doc("Fix roof"), Some(&task), Some("Camano"), &[]); + assert!(fm.contains("task: task1")); + assert!(fm.contains("state: outstanding")); + assert!(fm.contains("attention: red")); + assert!(fm.contains("project: Camano")); + assert!(fm.contains("tags: []")); + } + + #[test] + fn quotes_titles_that_need_it() { + assert_eq!(scalar("plain"), "plain"); + assert_eq!(scalar("has: colon"), "\"has: colon\""); + assert_eq!(scalar(""), "\"\""); + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index d4521b1..7408103 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod client; pub mod clock; pub mod datespec; +pub mod frontmatter; pub mod lock; pub mod oauth; pub mod remote; diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 9dbf024..d2613de 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use heph_core::{ - Attention, LinkType, ListFilter, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState, + Attention, LinkType, ListFilter, NewNode, NewTask, Node, NodeKind, SchedulePatch, Store, Task, + TaskState, }; /// A JSON-RPC request line. @@ -111,6 +112,14 @@ struct IdParam { id: String, } +#[derive(Deserialize)] +struct GetNodeParams { + id: String, + /// Prepend the editable YAML frontmatter projection to the body (§8.3). + #[serde(default)] + frontmatter: bool, +} + #[derive(Deserialize)] struct ResolveParams { title: String, @@ -237,13 +246,54 @@ const DEFAULT_LIMIT: usize = 5; /// Default `log.tail` size. const DEFAULT_TAIL: usize = 10; +/// The task whose frontmatter `node` carries: the node itself if it's a task, +/// else the task whose canonical-context doc this node is (§8.3). `None` for a +/// standalone doc/journal. +fn subject_task(store: &dyn Store, node: &Node) -> Result<Option<Task>, RpcError> { + if node.kind == NodeKind::Task { + return Ok(store.get_task(&node.id)?); + } + for link in store.backlinks(&node.id)? { + if link.link_type == LinkType::CanonicalContext { + return Ok(store.get_task(&link.src_id)?); + } + } + Ok(None) +} + +/// The name of the project a task is filed under (its `in-project` link), if any. +fn project_name_of(store: &dyn Store, task_id: &str) -> Result<Option<String>, RpcError> { + for link in store.outgoing_links(task_id)? { + if link.link_type == LinkType::InProject { + return Ok(store.get_node(&link.dst_id)?.map(|n| n.title)); + } + } + Ok(None) +} + /// Dispatch one method call against `store`. Synchronous — the transport runs /// this on a blocking pool. pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Value, RpcError> { Ok(match method { "node.get" => { - let p: IdParam = parse(params)?; - json!(store.get_node(&p.id)?) + let p: GetNodeParams = parse(params)?; + match store.get_node(&p.id)? { + Some(mut node) if p.frontmatter => { + // Render the editable frontmatter projection (§8.3) from the + // node's own fields + its (owning) task, and prepend it. + let task = subject_task(store, &node)?; + let tags = store.tags_of(&node.id)?; + let project = match &task { + Some(t) => project_name_of(store, &t.node_id)?, + None => None, + }; + let fm = + crate::frontmatter::render(&node, task.as_ref(), project.as_deref(), &tags); + node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or(""))); + json!(node) + } + other => json!(other), + } } "node.create" => { let p: NewNode = parse(params)?; diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 687f3ff..c889cb5 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -267,6 +267,93 @@ fn tag_add_list_remove_over_socket() { ); } +#[test] +fn frontmatter_renders_on_read_and_is_stripped_on_write() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let doc = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Roof", "body": "# Roof\n\nnotes\n" }), + ) + .unwrap(); + let id = doc["id"].as_str().unwrap().to_string(); + c.call("tag.add", json!({ "node_id": id, "tag": "house" })) + .unwrap(); + + // Read with frontmatter: a leading `---` block carries id/kind/title/tags, + // and the original body follows it. + let got = c + .call("node.get", json!({ "id": id, "frontmatter": true })) + .unwrap(); + let body = got["body"].as_str().unwrap(); + assert!(body.starts_with("---\n"), "no frontmatter fence:\n{body}"); + assert!(body.contains(&format!("id: {id}")), "no id:\n{body}"); + assert!(body.contains("title: Roof"), "no title:\n{body}"); + assert!(body.contains("tags: [house]"), "no tags:\n{body}"); + assert!( + body.contains("# Roof\n\nnotes\n"), + "original body missing:\n{body}" + ); + + // Echo that whole buffer back: the frontmatter is stripped, body unchanged. + c.call("node.update", json!({ "id": id, "body": body })) + .unwrap(); + let plain = c.call("node.get", json!({ "id": id })).unwrap(); + assert_eq!( + plain["body"], "# Roof\n\nnotes\n", + "round-trip altered the body" + ); +} + +#[test] +fn task_context_doc_frontmatter_carries_the_owning_tasks_scalars() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let proj = c + .call( + "node.create", + json!({ "kind": "project", "title": "Camano" }), + ) + .unwrap(); + let pid = proj["id"].as_str().unwrap().to_string(); + let task = c + .call( + "task.create", + json!({ "title": "Fix roof", "attention": "red", "project_id": pid, "do_date": 1_704_067_200_000_i64 }), + ) + .unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + + // The task's canonical-context doc. + let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); + let ctx = links + .as_array() + .unwrap() + .iter() + .find(|l| l["link_type"] == "canonical-context") + .unwrap()["dst_id"] + .as_str() + .unwrap() + .to_string(); + + // Its frontmatter surfaces the owning task's scalars + a `task:` ref. + let got = c + .call("node.get", json!({ "id": ctx, "frontmatter": true })) + .unwrap(); + let body = got["body"].as_str().unwrap(); + assert!( + body.contains(&format!("task: {task_id}")), + "no task ref:\n{body}" + ); + assert!(body.contains("state: outstanding"), "no state:\n{body}"); + assert!(body.contains("attention: red"), "no attention:\n{body}"); + assert!(body.contains("project: Camano"), "no project:\n{body}"); + assert!(body.contains("do_date:"), "no do_date:\n{body}"); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 1dfe9a2..48323c9 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,4 +28,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. +- Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 0c535f8..7fe03b9 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -300,14 +300,30 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2. > **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1. -## 8.3 Frontmatter as an edit surface (planned) +## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next) -> **Status: planned** (§14 roadmap, 2026-06-03). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) should be visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body. +> **Status: backend built** (the projection); the `heph.nvim` diff layer is next. When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body. -The resolving principle is a **two-layer split** that keeps `heph-core` safe against *any* client while making `heph.nvim` a rich editor: +The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor: -- **`heph-core` is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. A pure `frontmatter` module (sibling to `extract.rs`) provides `render(node, task?, project, tags)` (prepended by `get_node` and friends) and `strip(body) → body_without` (applied by `update_node` **before** the `yrs` CRDT diff). Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and core drops it; the canonical block regenerates on the next read. -- **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core. +- ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read. +- ⏳ **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core. + +**The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route: + +```yaml +--- +id: 01J… # read-only +kind: doc # read-only +title: Fix the roof # editable → rename +tags: [house, roof] # editable → tag.add / tag.remove +task: 01J… # present iff this node is/backs a task (read-only ref) +state: outstanding # editable (a mistyped status is a validation error — no picker) +attention: red # editable → task.set_attention +do_date: 2026-06-10 # editable → task.set_schedule (YYYY-MM-DD, local) +project: Camano # editable → task.set_project (by name) +--- +``` Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, `do_date`, `late_on`, `recurrence`, `project`, `tags`, and `state` are editable. **`state` is editable but has no picker or hint** (to keep the UI simple) — a mistyped status value returns a **validation error** rather than guessing. Frontmatter is rendered for any editable-body node. @@ -452,7 +468,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). -4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. +4. ◐ **YAML frontmatter as an edit surface (§8.3) — backend DONE, nvim next:** ✅ the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref. Round-trip is a no-op; inbound frontmatter is always stripped (safe vs any client). ⏳ Remaining: the `heph.nvim` diff-on-`BufWriteCmd` → structured RPCs (`title`→rename, `attention`→set_attention, dates→set_schedule, `project`→set_project, `tags`→tag.add/remove) + inline `#hashtags` on save. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). -- 2.50.1 (Apple Git-155) From 0e9cfc1fd7edd893ac8b123406a90c02d29d5a9d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:44:39 -0700 Subject: [PATCH 69/91] =?UTF-8?q?feat(nvim):=20frontmatter=20edit=20surfac?= =?UTF-8?q?e=20=E2=80=94=20diff=20block=20into=20RPCs=20on=20save=20(?= =?UTF-8?q?=C2=A78.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node buffers now open with the editable YAML frontmatter block on top (node.get {frontmatter: true}); on :w, `frontmatter.lua` parses the block, diffs it against what was rendered, and routes each changed field to the right RPC: - title → node.update rename - attention → task.set_attention - do_date/late_on/recurrence → task.set_schedule (YYYY-MM-DD → local-ms; a removed line clears via null) - project → task.set_project (resolved by name) - tags → tag.add / tag.remove A mistyped state surfaces the daemon's validation error; a buffer with no block edits no metadata (deleting the block can't wipe tags). Body rides node.update as before (the store strips any echoed frontmatter). Body-position features are content-relative, so the prepended block doesn't disturb them; e2e specs that targeted absolute line 1 now locate body lines by content via a new `h.find` helper. New frontmatter_spec covers render + the full diff→RPC round-trip. 21 nvim e2e specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 8 ++ docs/reference/tech-spec.md | 8 +- heph.nvim/lua/heph/frontmatter.lua | 172 +++++++++++++++++++++++ heph.nvim/lua/heph/node.lua | 48 +++++-- heph.nvim/tests/e2e/follow_link_spec.lua | 11 +- heph.nvim/tests/e2e/frontmatter_spec.lua | 85 +++++++++++ heph.nvim/tests/e2e/helpers.lua | 13 ++ heph.nvim/tests/e2e/promote_spec.lua | 5 +- 9 files changed, 328 insertions(+), 23 deletions(-) create mode 100644 heph.nvim/lua/heph/frontmatter.lua create mode 100644 heph.nvim/tests/e2e/frontmatter_spec.lua diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 48323c9..6371ba0 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,5 +28,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. +- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 99da295..cbfa0dd 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -79,6 +79,14 @@ edits, else adding the `wiki` link directly). | `:Heph promote [attention]` | Promote the `- [ ]` line under the cursor to a committed task | | `:Heph log <text>` | Append a breadcrumb to the current task's log | +A node buffer opens with an editable **YAML frontmatter** block on top (`id`, +`kind`, `title`, `tags`, and — for a task or its context doc — the task's +`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` +ref). On `:w`, `frontmatter.lua` diffs the block against what was rendered and +issues the right RPC per changed field (rename, `set_attention`, `set_schedule`, +`set_project`, `tag.add`/`tag.remove`); the store strips the block so it never +enters the body (tech-spec §8.3). A buffer with no block edits no metadata. + "Current task" is resolved from the buffer: a `task` node, or a canonical-context doc whose owning task is followed via its `canonical-context` backlink. The `next`/`list` views render the titled rows the daemon returns (`list` enriched to diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 7fe03b9..e8a23dc 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -300,14 +300,14 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2. > **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1. -## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next) +## 8.3 Frontmatter as an edit surface (built) -> **Status: backend built** (the projection); the `heph.nvim` diff layer is next. When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body. +> **Status: built** (inline `#hashtags` on save are a small deferred follow-up). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body. The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor: - ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read. -- ⏳ **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core. +- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **Remaining:** inline `#hashtags` detected on save. **The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route: @@ -468,7 +468,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). -4. ◐ **YAML frontmatter as an edit surface (§8.3) — backend DONE, nvim next:** ✅ the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref. Round-trip is a no-op; inbound frontmatter is always stripped (safe vs any client). ⏳ Remaining: the `heph.nvim` diff-on-`BufWriteCmd` → structured RPCs (`title`→rename, `attention`→set_attention, dates→set_schedule, `project`→set_project, `tags`→tag.add/remove) + inline `#hashtags` on save. +4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata). **Deferred follow-up:** inline `#hashtags` detected on save. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). diff --git a/heph.nvim/lua/heph/frontmatter.lua b/heph.nvim/lua/heph/frontmatter.lua new file mode 100644 index 0000000..de567be --- /dev/null +++ b/heph.nvim/lua/heph/frontmatter.lua @@ -0,0 +1,172 @@ +--- Frontmatter as an edit surface (tech-spec §8.3). The daemon renders an +--- editable YAML block atop a node's body on read (`node.get {frontmatter}`) +--- and strips it on write; this module is the **smart-client** half: parse the +--- buffer's frontmatter and translate each changed field into the right +--- structured RPC (title→rename, attention→set_attention, dates→set_schedule, +--- project→set_project, tags→tag.add/remove). The body itself rides `node.update`. +local rpc = require("heph.rpc") + +local M = {} + +local function trim(s) + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +--- Unquote a YAML scalar (`"…"` with `\"`/`\\` escapes), else return it trimmed. +local function unquote(s) + s = trim(s) + local inner = s:match('^"(.*)"$') + if inner then + return (inner:gsub('\\"', '"'):gsub("\\\\", "\\")) + end + return s +end + +--- Parse a YAML flow sequence `[a, b]` (or `[]`) into a list of scalars. +local function parse_flow_seq(s) + local inner = s:match("^%[(.*)%]$") + if not inner then + return {} + end + local out = {} + for item in (inner .. ","):gmatch("(.-),") do + item = trim(item) + if item ~= "" then + out[#out + 1] = unquote(item) + end + end + return out +end + +--- Parse a leading `---` frontmatter block. Returns `(fm, body)` where `fm` is a +--- table of string fields plus `tags` (a list), or `(nil, text)` when there is +--- no conforming block (so a frontmatter-less buffer never looks like metadata). +function M.parse(text) + if text:sub(1, 4) ~= "---\n" then + return nil, text + end + local lines = vim.split(text, "\n", { plain = true }) + local close + for i = 2, #lines do + if lines[i] == "---" then + close = i + break + end + end + if not close then + return nil, text + end + local fm = { tags = {} } + for j = 2, close - 1 do + local key, val = lines[j]:match("^([%w_%-]+):%s*(.*)$") + if key == "tags" then + fm.tags = parse_flow_seq(val) + elseif key then + fm[key] = unquote(val) + end + end + local body = table.concat({ unpack(lines, close + 1) }, "\n") + return fm, body +end + +--- A `YYYY-MM-DD` string → epoch ms at **local midnight** (matching the +--- daemon's `datespec`). Returns `nil, err` on a malformed date. +local function date_to_ms(s) + local y, m, d = tostring(s):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + if not y then + return nil, "expected a YYYY-MM-DD date, got " .. tostring(s) + end + return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000 +end + +--- Set difference of two scalar lists → (added, removed). +local function set_diff(old, new) + local oldset, newset = {}, {} + for _, x in ipairs(old or {}) do + oldset[x] = true + end + for _, x in ipairs(new or {}) do + newset[x] = true + end + local added, removed = {}, {} + for x in pairs(newset) do + if not oldset[x] then + added[#added + 1] = x + end + end + for x in pairs(oldset) do + if not newset[x] then + removed[#removed + 1] = x + end + end + return added, removed +end + +--- Apply the difference between `canonical` (rendered on read) and the buffer's +--- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags +--- target); task scalars route to the owning task (`fm.task`). Raises on any +--- RPC error (e.g. a mistyped `state` or an unknown `project`). +function M.apply(node_id, canonical, fm) + canonical = canonical or {} + + -- Tags on the opened node. + local added, removed = set_diff(canonical.tags, fm.tags) + for _, t in ipairs(added) do + rpc.call("tag.add", { node_id = node_id, tag = t }) + end + for _, t in ipairs(removed) do + rpc.call("tag.remove", { node_id = node_id, tag = t }) + end + + -- Task scalars route to the owning task, if this node is or backs one. + local task = fm.task or canonical.task + if task then + if fm.attention and fm.attention ~= canonical.attention then + rpc.call("task.set_attention", { id = task, attention = fm.attention }) + end + + -- Schedule (double-option): a present value sets, a removed line clears. + local patch, changed = { id = task }, false + local function sched(field) + if fm[field] == canonical[field] then + return + end + changed = true + if fm[field] == nil or fm[field] == "" then + patch[field] = vim.NIL -- cleared + elseif field == "recurrence" then + patch[field] = fm[field] -- a raw RRULE + else + local ms, err = date_to_ms(fm[field]) + if not ms then + error("heph: " .. err) + end + patch[field] = ms + end + end + sched("do_date") + sched("late_on") + sched("recurrence") + if changed then + rpc.call("task.set_schedule", patch) + end + + if fm.state and fm.state ~= canonical.state then + rpc.call("task.set_state", { id = task, state = fm.state }) -- a typo → rpc error + end + + if fm.project ~= canonical.project then + local pid = vim.NIL -- removed / blank → unfile + if fm.project and fm.project ~= "" then + local node = rpc.call("node.resolve", { title = fm.project }) + if not node then + error("heph: no node named '" .. fm.project .. "' to file under") + end + pid = node.id + end + rpc.call("task.set_project", { id = task, project_id = pid }) + end + end +end + +return M diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua index 05a812c..0d4be1c 100644 --- a/heph.nvim/lua/heph/node.lua +++ b/heph.nvim/lua/heph/node.lua @@ -1,26 +1,34 @@ --- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a ---- real buffer named `heph://node/<id>`. `:e` loads it via `node.get`; `:w` ---- saves the whole buffer back via `node.update` (the backend CRDT-diffs the ---- whole-buffer text, so sending the full body is correct and idempotent). +--- real buffer named `heph://node/<id>`. `:e` loads it via `node.get` +--- (with the editable YAML **frontmatter** block prepended, §8.3); `:w` diffs +--- the frontmatter into structured RPCs, then saves the body via `node.update` +--- (the backend CRDT-diffs the whole body, and strips any frontmatter echoed +--- back — so sending the stripped body is correct and idempotent). local rpc = require("heph.rpc") local util = require("heph.util") +local frontmatter = require("heph.frontmatter") local M = {} ---- `BufReadCmd` handler for `heph://node/<id>`: load the body into the buffer. +-- buf -> the frontmatter table rendered on read, so `write` can diff against it. +M._canonical = {} + +--- `BufReadCmd` handler for `heph://node/<id>`: load frontmatter + body. function M.read(buf, uri) local _, id = util.parse_uri(uri) if not id then error("heph: not a node uri: " .. tostring(uri)) end - local node = rpc.call("node.get", { id = id }) - local body = (node and node.body) or "" + local node = rpc.call("node.get", { id = id, frontmatter = true }) + local text = (node and node.body) or "" + local fm = frontmatter.parse(text) -- `plain` split keeps a trailing "" element for a trailing newline, so the - -- body round-trips exactly through `table.concat` on write. - vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true })) + -- text round-trips exactly through `table.concat` on write. + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(text, "\n", { plain = true })) vim.b[buf].heph_node_id = id vim.b[buf].heph_node_kind = (node and node.kind) or "doc" + M._canonical[buf] = fm or {} vim.bo[buf].buftype = "acwrite" -- written via BufWriteCmd, not to a file vim.bo[buf].filetype = "markdown" vim.bo[buf].fileformat = "unix" @@ -28,14 +36,32 @@ function M.read(buf, uri) require("heph.link").attach(buf) end ---- `BufWriteCmd` handler: persist the whole buffer as the node body. +--- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the body. function M.write(buf, _uri) local id = vim.b[buf].heph_node_id if not id then error("heph: buffer has no heph node id") end - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - rpc.call("node.update", { id = id, body = table.concat(lines, "\n") }) + local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + local fm, body = frontmatter.parse(text) + + local params = { id = id } + if fm then + -- A frontmatter block is present: translate its edits to RPCs. (Absent + -- block ⇒ the user removed it; treat the whole buffer as body, touch no + -- metadata.) The backend also strips defensively, so `body` is what stores. + local canonical = M._canonical[buf] or {} + frontmatter.apply(id, canonical, fm) + if fm.title and fm.title ~= canonical.title then + params.title = fm.title + end + params.body = body + M._canonical[buf] = fm + else + params.body = text + end + + rpc.call("node.update", params) vim.bo[buf].modified = false end diff --git a/heph.nvim/tests/e2e/follow_link_spec.lua b/heph.nvim/tests/e2e/follow_link_spec.lua index 2079ace..1e0b51b 100644 --- a/heph.nvim/tests/e2e/follow_link_spec.lua +++ b/heph.nvim/tests/e2e/follow_link_spec.lua @@ -16,11 +16,10 @@ describe("follow link", function() local a = h.create_doc("A", "see [[B]] here") local buf = h.open(a.id) - local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] - local open_at = line:find("%[%[B") -- start of "[[B" - assert.is_truthy(open_at) + local lnum, col = h.find(buf, "%[%[B") -- the body line, below the frontmatter + assert.is_truthy(lnum) -- Put the cursor on the target inside the brackets. - vim.api.nvim_win_set_cursor(0, { 1, open_at + 1 }) + vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) require("heph.link").follow() @@ -32,8 +31,8 @@ describe("follow link", function() it("creates the target doc when following an unresolved [[link]]", function() local a = h.create_doc("Daily", "see [[New Topic]]") local buf = h.open(a.id) - local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[New") - vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) -- inside [[New Topic]] + local lnum, col = h.find(buf, "%[%[New") + vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) -- inside [[New Topic]] require("heph.link").follow() diff --git a/heph.nvim/tests/e2e/frontmatter_spec.lua b/heph.nvim/tests/e2e/frontmatter_spec.lua new file mode 100644 index 0000000..496bd30 --- /dev/null +++ b/heph.nvim/tests/e2e/frontmatter_spec.lua @@ -0,0 +1,85 @@ +-- Frontmatter edit surface (tech-spec §8.3): a node buffer opens with an +-- editable YAML block on top, and saving routes each changed field to the right +-- structured RPC (rename / set_attention / set_schedule / set_project / +-- tag.add). The body itself still round-trips through node.update. + +local h = require("e2e.helpers") + +-- Replace `key: …` in the frontmatter `lines`, or insert it before the closing +-- `---` fence when the key isn't present yet. +local function set_field(lines, key, value) + for i, line in ipairs(lines) do + if line:match("^" .. key .. ":") then + lines[i] = key .. ": " .. value + return + end + end + local fences = 0 + for i, line in ipairs(lines) do + if line == "---" then + fences = fences + 1 + if fences == 2 then + table.insert(lines, i, key .. ": " .. value) + return + end + end + end +end + +describe("frontmatter edit surface", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("renders an editable block atop a node body", function() + local doc = h.create_doc("Roof", "# Roof\n\nnotes") + local buf = h.open(doc.id) + local first = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] + assert.are.equal("---", first, "buffer should open with a frontmatter fence") + assert.is_truthy(h.find(buf, "^title: Roof"), "title not in frontmatter") + assert.is_truthy(h.find(buf, "# Roof"), "body content missing below frontmatter") + end) + + it("routes frontmatter edits to structured RPCs on save", function() + local proj = ctx.q:call("node.create", { kind = "project", title = "Camano" }) + local task = ctx.q:call("task.create", { title = "Fix roof", attention = "red" }) + local ctxid + for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do + if l.link_type == "canonical-context" then + ctxid = l.dst_id + end + end + + local buf = h.open(ctxid) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + set_field(lines, "title", "Fix the roof") -- rename the (doc) node + set_field(lines, "attention", "blue") -- → task.set_attention + set_field(lines, "tags", "[roofing]") -- → tag.add on the doc + set_field(lines, "project", "Camano") -- → task.set_project + set_field(lines, "do_date", "2026-06-10") -- → task.set_schedule + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + h.save(buf) + + -- The task picked up attention, project, and a do-date. + local t = ctx.q:call("task.get", { id = task.node_id }) + assert.are.equal("blue", t.attention) + assert.is_truthy(t.do_date, "do_date should be set") + local filed = false + for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do + if l.link_type == "in-project" and l.dst_id == proj.id then + filed = true + end + end + assert.is_true(filed, "task should be filed under Camano") + + -- The opened node was renamed and tagged. + assert.are.equal("Fix the roof", ctx.q:call("node.get", { id = ctxid }).title) + local tags = ctx.q:call("tag.list", { node_id = ctxid }) + assert.are.equal(1, #tags) + assert.are.equal("roofing", tags[1]) + end) +end) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua index f1a2dcb..f69c2be 100644 --- a/heph.nvim/tests/e2e/helpers.lua +++ b/heph.nvim/tests/e2e/helpers.lua @@ -178,6 +178,19 @@ function M.open(id) return vim.api.nvim_get_current_buf() end +--- The (1-based line, 0-based byte col) of the first match of Lua `pattern` in +--- `buf`, or nil. Lets specs target body content by what it says rather than an +--- absolute line number — node buffers now carry a frontmatter block on top +--- (tech-spec §8.3), so the body no longer starts at line 1. +function M.find(buf, pattern) + for i, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do + local s = line:find(pattern) + if s then + return i, s - 1 + end + end +end + function M.set_lines(buf, lines) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) end diff --git a/heph.nvim/tests/e2e/promote_spec.lua b/heph.nvim/tests/e2e/promote_spec.lua index df7e625..203075f 100644 --- a/heph.nvim/tests/e2e/promote_spec.lua +++ b/heph.nvim/tests/e2e/promote_spec.lua @@ -15,7 +15,8 @@ describe("promote context item", function() it("mints a task, links the source line, and surfaces it in next", function() local container = h.create_doc("Errands", "- [ ] call plumber") local buf = h.open(container.id) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- on the context-item line + local lnum = h.find(buf, "call plumber") -- the context-item line, below frontmatter + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) local task = require("heph.task").promote_under_cursor({ attention = "orange" }) assert.is_truthy(task.node_id) @@ -32,7 +33,7 @@ describe("promote context item", function() -- The source line became a wiki-link to the task (persisted + in-buffer). assert.are.equal("- [[call plumber]]", ctx.q:call("node.get", { id = container.id }).body) local reloaded = vim.api.nvim_get_current_buf() - assert.are.equal("- [[call plumber]]", vim.api.nvim_buf_get_lines(reloaded, 0, 1, false)[1]) + assert.is_truthy(h.find(reloaded, "%- %[%[call plumber%]%]"), "rewritten link line missing from buffer") -- ...and the container backlinks the task. local linked = false -- 2.50.1 (Apple Git-155) From d85ce3362fca2ced4d9367b83128ce41c3a9b2dc Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:55:07 -0700 Subject: [PATCH 70/91] infra(nvim): add stylua formatter + prek hook; normalize heph.nvim Lua A `.stylua.toml` (Spaces/2, else stylua defaults) + a `stylua-system` prek hook make Lua whitespace formatter-enforced (the repo had no Lua formatter, so style was hand-maintained and drifted). Normalized the three non-conformant files in passing. 21 nvim e2e specs still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .stylua.toml | 5 +++++ docs/changelog.d/v1-prototype.infra.md | 1 + heph.nvim/lua/heph/init.lua | 5 +---- heph.nvim/tests/e2e/capture_spec.lua | 5 +---- heph.nvim/tests/e2e/helpers.lua | 20 +++++++++++--------- prek.toml | 7 +++++++ 6 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 .stylua.toml diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..9482c7c --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,5 @@ +# Opinionated Lua formatting for heph.nvim, enforced via prek's stylua hook. +# Spaces/2 matches the existing plugin style; everything else is stylua's +# defaults (the formatter is the source of truth — don't hand-format). +indent_type = "Spaces" +indent_width = 2 diff --git a/docs/changelog.d/v1-prototype.infra.md b/docs/changelog.d/v1-prototype.infra.md index 7392070..33f276c 100644 --- a/docs/changelog.d/v1-prototype.infra.md +++ b/docs/changelog.d/v1-prototype.infra.md @@ -1 +1,2 @@ +- Lua is now formatted by **stylua** (a `.stylua.toml` + a `stylua-system` prek hook), so heph.nvim's whitespace is enforced by an opinionated formatter rather than by hand. - `mise run import-todoist` — a one-way importer that seeds a heph store from your Todoist projects + active tasks (project hierarchy, priority→attention, do-dates, natural-language recurrence, descriptions + sub-tasks as context items). Dry-run by default; `-- --commit` writes into your real store after backing it up. See [[import-todoist]]. diff --git a/heph.nvim/lua/heph/init.lua b/heph.nvim/lua/heph/init.lua index c29e605..dd12fdc 100644 --- a/heph.nvim/lua/heph/init.lua +++ b/heph.nvim/lua/heph/init.lua @@ -30,10 +30,7 @@ function M.setup(opts) rpc.call("health", {}) end) if not ok then - require("heph.util").notify( - "no hephd at " .. cfg.socket .. " — run `heph daemon start`", - vim.log.levels.WARN - ) + require("heph.util").notify("no hephd at " .. cfg.socket .. " — run `heph daemon start`", vim.log.levels.WARN) end config.apply_keymaps(cfg) diff --git a/heph.nvim/tests/e2e/capture_spec.lua b/heph.nvim/tests/e2e/capture_spec.lua index f4891a7..9a2b8f4 100644 --- a/heph.nvim/tests/e2e/capture_spec.lua +++ b/heph.nvim/tests/e2e/capture_spec.lua @@ -34,10 +34,7 @@ describe("task capture to done", function() vim.api.nvim_win_set_cursor(0, { 2, 0 }) require("heph.view").open_under_cursor() local ctxbuf = vim.api.nvim_get_current_buf() - assert.are.equal( - "heph://node/" .. ranked[1].canonical_context_id, - vim.api.nvim_buf_get_name(ctxbuf) - ) + assert.are.equal("heph://node/" .. ranked[1].canonical_context_id, vim.api.nvim_buf_get_name(ctxbuf)) -- Add a checklist item and save. vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [ ] buy shingles" }) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua index f69c2be..a3d8ceb 100644 --- a/heph.nvim/tests/e2e/helpers.lua +++ b/heph.nvim/tests/e2e/helpers.lua @@ -79,11 +79,16 @@ end --- tests of the no-daemon-running case). `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 } + return { + dir = dir, + sock = dir .. "/s", + db = dir .. "/db", + rm = function() + pcall(function() + vim.fn.delete(dir, "rf") + end) + end, + } end --- Start a daemon on explicit paths and bind the plugin's rpc to it. Returns a @@ -91,10 +96,7 @@ end function M.start_on(dir, sock, db) assert(#sock < 104, "socket path too long for sun_path: " .. sock) local bin = M.hephd_bin() - assert( - vim.fn.executable(bin) == 1, - "hephd not built/executable: " .. bin .. " (run: cargo build -p hephd)" - ) + assert(vim.fn.executable(bin) == 1, "hephd not built/executable: " .. bin .. " (run: cargo build -p hephd)") local exited = { done = false } local d = spawn({ diff --git a/prek.toml b/prek.toml index caa02e1..7de95ab 100644 --- a/prek.toml +++ b/prek.toml @@ -76,6 +76,13 @@ repo = "https://github.com/rbubley/mirrors-prettier" rev = "v3.8.1" hooks = [{ id = "prettier", types_or = ["json"], args = ["--tab-width", "2"] }] +# Lua formatting (heph.nvim) - stylua, configured by .stylua.toml. Uses the +# system binary (installed locally; CI runs Rust/nvim suites via Dagger, not prek). +[[repos]] +repo = "https://github.com/JohnnyMorganz/StyLua" +rev = "v2.4.1" +hooks = [{ id = "stylua-system" }] + # GitHub/Forgejo Actions workflow linting [[repos]] repo = "https://github.com/rhysd/actionlint" -- 2.50.1 (Apple Git-155) From 8dc98dc9c15546dda90e7d8ffddf2351ca801757 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 11:57:25 -0700 Subject: [PATCH 71/91] =?UTF-8?q?feat(nvim):=20inline=20#hashtags=20become?= =?UTF-8?q?=20tags=20on=20save=20(=C2=A78.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On save, whitespace-prefixed `#hashtags` in a node's body are unioned into its tag set (via `frontmatter.hashtags` + the existing tag diff), so you can tag a note by writing `#kitchen` inline. A markdown `# heading` has a space after the `#`, so it never matches. e2e covers it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 6 ++-- heph.nvim/lua/heph/frontmatter.lua | 41 +++++++++++++++++++++--- heph.nvim/lua/heph/node.lua | 2 +- heph.nvim/tests/e2e/frontmatter_spec.lua | 12 +++++++ 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 6371ba0..68d643c 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,6 +28,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Link-follow and promotion are unaffected (they're content-relative, not line-absolute). +- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count). Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index e8a23dc..201142e 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -302,12 +302,12 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2. ## 8.3 Frontmatter as an edit surface (built) -> **Status: built** (inline `#hashtags` on save are a small deferred follow-up). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body. +> **Status: built.** When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body. The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor: - ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read. -- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **Remaining:** inline `#hashtags` detected on save. +- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. Inline **`#hashtags`** in the body are unioned into the tag set on save (a `# heading` has a space after `#`, so it doesn't match). (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route: @@ -468,7 +468,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). -4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata). **Deferred follow-up:** inline `#hashtags` detected on save. +4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). diff --git a/heph.nvim/lua/heph/frontmatter.lua b/heph.nvim/lua/heph/frontmatter.lua index de567be..6e9588c 100644 --- a/heph.nvim/lua/heph/frontmatter.lua +++ b/heph.nvim/lua/heph/frontmatter.lua @@ -79,6 +79,35 @@ local function date_to_ms(s) return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000 end +--- Whitespace-prefixed inline `#hashtags` in `body`, de-duplicated in order. +--- A markdown heading (`# Title`, `## foo`) has a space after the `#`, so it +--- never matches. (Scanned across the whole body, code fences included — a +--- pragmatic v1 simplification.) +function M.hashtags(body) + local seen, out = {}, {} + for tag in (" " .. (body or "")):gmatch("%s#([%w_%-]+)") do + if not seen[tag] then + seen[tag] = true + out[#out + 1] = tag + end + end + return out +end + +--- Union of two scalar lists, order-stable, de-duplicated. +local function union(a, b) + local seen, out = {}, {} + for _, list in ipairs({ a or {}, b or {} }) do + for _, x in ipairs(list) do + if not seen[x] then + seen[x] = true + out[#out + 1] = x + end + end + end + return out +end + --- Set difference of two scalar lists → (added, removed). local function set_diff(old, new) local oldset, newset = {}, {} @@ -104,12 +133,16 @@ end --- Apply the difference between `canonical` (rendered on read) and the buffer's --- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags ---- target); task scalars route to the owning task (`fm.task`). Raises on any ---- RPC error (e.g. a mistyped `state` or an unknown `project`). -function M.apply(node_id, canonical, fm) +--- target); task scalars route to the owning task (`fm.task`). Inline +--- `#hashtags` in `body` are unioned into the desired tag set (so they manage +--- tags too); `fm.tags` is updated to that union so the caller caches it as the +--- new canonical. Raises on any RPC error (a mistyped `state`, an unknown +--- `project`). +function M.apply(node_id, canonical, fm, body) canonical = canonical or {} - -- Tags on the opened node. + -- Tags on the opened node = the frontmatter list ∪ inline #hashtags. + fm.tags = union(fm.tags, M.hashtags(body)) local added, removed = set_diff(canonical.tags, fm.tags) for _, t in ipairs(added) do rpc.call("tag.add", { node_id = node_id, tag = t }) diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua index 0d4be1c..6bd9f2c 100644 --- a/heph.nvim/lua/heph/node.lua +++ b/heph.nvim/lua/heph/node.lua @@ -51,7 +51,7 @@ function M.write(buf, _uri) -- block ⇒ the user removed it; treat the whole buffer as body, touch no -- metadata.) The backend also strips defensively, so `body` is what stores. local canonical = M._canonical[buf] or {} - frontmatter.apply(id, canonical, fm) + frontmatter.apply(id, canonical, fm, body) if fm.title and fm.title ~= canonical.title then params.title = fm.title end diff --git a/heph.nvim/tests/e2e/frontmatter_spec.lua b/heph.nvim/tests/e2e/frontmatter_spec.lua index 496bd30..da0dc32 100644 --- a/heph.nvim/tests/e2e/frontmatter_spec.lua +++ b/heph.nvim/tests/e2e/frontmatter_spec.lua @@ -82,4 +82,16 @@ describe("frontmatter edit surface", function() assert.are.equal(1, #tags) assert.are.equal("roofing", tags[1]) end) + + it("adds inline #hashtags from the body as tags on save", function() + local doc = h.create_doc("Notes", "# Notes") + local buf = h.open(doc.id) + -- Append a body line with an inline hashtag (a `# heading` must not match). + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "", "see #kitchen and # not-a-tag" }) + h.save(buf) + + local tags = ctx.q:call("tag.list", { node_id = doc.id }) + assert.are.equal(1, #tags, "exactly the one inline tag") + assert.are.equal("kitchen", tags[1]) + end) end) -- 2.50.1 (Apple Git-155) From a030ad3034d3ec5309380095acae799a5987eb4b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 12:01:36 -0700 Subject: [PATCH 72/91] =?UTF-8?q?feat(nvim):=20italicize=20inline=20#hasht?= =?UTF-8?q?ags=20in=20node=20buffers=20(=C2=A78.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A buffer-local syntax match (`HephHashtag`, italic, `default`-overridable) highlights whitespace-prefixed #hashtags so they're visually obvious — mirroring the save-time tag detection (a `# heading` doesn't match). Attached alongside the <CR> link follow. Syntax match is the right-sized tool here (treesitter has no hashtag node; extmark conceal is reserved for the upcoming [[link]] display layer). e2e asserts the italic hl group. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- heph.nvim/lua/heph/link.lua | 11 ++++++++++- heph.nvim/tests/e2e/frontmatter_spec.lua | 2 ++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 68d643c..451405b 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,6 +28,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count). Link-follow and promotion are unaffected (they're content-relative, not line-absolute). +- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 1af44a4..7b8e963 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -68,11 +68,20 @@ function M.follow() require("heph.node").open(node.id) end ---- Attach the buffer-local `<CR>` follow keymap (only on heph:// buffers). +--- Attach the buffer-local `<CR>` follow keymap and inline-`#hashtag` +--- highlighting (only on heph:// buffers). function M.attach(buf) vim.keymap.set("n", "<CR>", function() M.follow() end, { buffer = buf, desc = "heph: follow [[link]]" }) + + -- Render inline #hashtags in italics so they stand out — matching the + -- save-time tag detection (whitespace-prefixed `#word`, never a `# heading`). + -- `default = true` leaves a user's own `HephHashtag` definition intact. + vim.api.nvim_set_hl(0, "HephHashtag", { italic = true, default = true }) + vim.api.nvim_buf_call(buf, function() + vim.cmd([[syntax match HephHashtag /\v%(^|\s)@<=#[0-9A-Za-z_-]+/ containedin=ALL]]) + end) end return M diff --git a/heph.nvim/tests/e2e/frontmatter_spec.lua b/heph.nvim/tests/e2e/frontmatter_spec.lua index da0dc32..c0317b4 100644 --- a/heph.nvim/tests/e2e/frontmatter_spec.lua +++ b/heph.nvim/tests/e2e/frontmatter_spec.lua @@ -42,6 +42,8 @@ describe("frontmatter edit surface", function() assert.are.equal("---", first, "buffer should open with a frontmatter fence") assert.is_truthy(h.find(buf, "^title: Roof"), "title not in frontmatter") assert.is_truthy(h.find(buf, "# Roof"), "body content missing below frontmatter") + -- Inline #hashtags are rendered italic for visibility. + assert.is_true(vim.api.nvim_get_hl(0, { name = "HephHashtag" }).italic, "hashtag hl not italic") end) it("routes frontmatter edits to structured RPCs on save", function() -- 2.50.1 (Apple Git-155) From 4e8f6743cf2e2db629fbd2f1dd8330d90ae3580d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 12:07:46 -0700 Subject: [PATCH 73/91] =?UTF-8?q?feat:=20wiki-links=20by=20id=20=E2=80=94?= =?UTF-8?q?=20id-first=20resolution=20+=20heph.nvim=20[[=20picker=20(?= =?UTF-8?q?=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: `links::resolve_id` now checks for an exact live node id before alias/title, so a canonical `[[NODEID]]` link resolves to its node and can't be shadowed by a like-named node. Legacy `[[Name]]` links still resolve by name (until the migration), so this is additive. heph.nvim: `link.insert` (bound to insert-mode `[[` and `:Heph link`) searches via the `search` RPC and inserts `[[NODEID]]`, with a "+ Create new doc" entry; `<CR>` follow resolves the id directly. e2e covers search→insert→materialize and the create path. Remaining (§8.4): read-expansion/conceal display + the one-time [[Title]]→[[NODEID]] migration (then retire name-resolution + the hack). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/links.rs | 9 +++ crates/hephd/tests/rpc_socket.rs | 7 +++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 17 +++--- heph.nvim/lua/heph/command.lua | 3 + heph.nvim/lua/heph/link.lua | 38 +++++++++++- heph.nvim/tests/e2e/link_insert_spec.lua | 73 ++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 heph.nvim/tests/e2e/link_insert_spec.lua diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index ab20b15..0783e16 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -169,6 +169,15 @@ pub(super) fn sync_wiki_links( /// first, then an exact title. `None` if nothing matches. Shared by `wiki` /// link materialization and the `node.resolve` surface (tech-spec §5, §6). pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result<Option<String>> { + // 1. An exact **node id** — the canonical at-rest link form (`[[NODEID]]`, + // tech-spec §8.4). Checked first so id-addressed links never collide with a + // like-named node. (Legacy `[[Name]]` links still resolve below, until the + // one-time migration rewrites them.) + if let Some(node) = super::nodes::get(conn, target)? { + if node.owner_id == owner && !node.tombstoned { + return Ok(Some(node.id)); + } + } let by_alias: Option<String> = conn .query_row( "SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index c889cb5..40f59dd 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -95,6 +95,13 @@ fn node_resolve_is_exact_not_fuzzy_over_socket() { let got = c.call("node.resolve", json!({ "title": "Roof" })).unwrap(); assert_eq!(got["id"], target_id); + // A node id resolves to itself — the canonical `[[NODEID]]` link form (§8.4), + // checked ahead of name resolution. + let by_id = c + .call("node.resolve", json!({ "title": target_id })) + .unwrap(); + assert_eq!(by_id["id"], target_id); + // An unresolved link is JSON null, not an error (tech-spec §5). let missing = c .call("node.resolve", json!({ "title": "Nonexistent" })) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 451405b..9d1b55a 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,6 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; readable display/conceal of the ids is next.) - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 201142e..5c5f514 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -329,15 +329,16 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, **Inline `#hashtags`** are a **`heph.nvim` feature**, not core extraction (for now): the plugin detects them on save and routes them through the same `tag.add`/`tag.remove` path. (Core-side hashtag extraction can come later, e.g. for the zk import.) -## 8.4 Wiki-links by node id (planned) +## 8.4 Wiki-links by node id (authoring built; display + migration next) -> **Status: planned** (§14 roadmap, 2026-06-03). Today bodies store the human text `[[Title]]` and links are materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. +> **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. -- **At rest:** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. Extraction reads the id directly — no resolution, no ambiguity — and the canonical-context exclusion hack in `resolve_id` is **removed**. -- **Projection (same philosophy as §8.3):** on **read**, `heph-core` expands a bare `[[NODEID]]` → `[[NODEID|Current Name]]`, so buffers, `heph export`, and any dumb reader show readable, always-fresh link text. On **write**, a `|text` that equals the target's current name **collapses back** to bare `[[NODEID]]`; a `|text` that differs is preserved as a real override. Needs an **id→name batch resolve** RPC for the expansion. -- **`heph.nvim` authoring:** typing `[[` triggers a picker (reuse `picker.lua` / Telescope, **no new dependency**) that searches titles via the `search` RPC and inserts `[[NODEID]]`; a **"Create new: «typed»"** entry mints a `doc` and inserts its id. -- **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), and revealed in raw form when the cursor is on it. -- **Migration:** a **one-time fixup script** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable). No special care is warranted — there is no critical data in the store yet — and a first-class migrations feature stays **deferred**. +- ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID]]`; a **"+ Create new doc"** entry mints a `doc` and inserts its id. Follow (`<CR>`) resolves the id directly. +- **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. +- ⏳ **Projection (same philosophy as §8.3):** on **read**, expand a bare `[[NODEID]]` → `[[NODEID|Current Name]]` so buffers, `heph export`, and any dumb reader show readable, always-fresh link text; on **write**, a `|text` equal to the target's current name **collapses back** to bare. Needs an **id→name batch resolve** RPC. +- ⏳ **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), revealed in raw form when the cursor is on it. +- ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**. ## 9. Testing strategy (TDD, layered) @@ -469,7 +470,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). 4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save. -5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. +5. ◐ **Wiki-links by node id (§8.4) — authoring DONE, display + migration next:** ✅ id-first resolution (`[[NODEID]]` resolves ahead of name; legacy `[[Name]]` still works) + the `heph.nvim` `[[` picker (`search` → insert `[[NODEID]]`, "+ Create" mints a doc) + id-direct follow. ⏳ Remaining: read-expansion/write-collapse projection (`[[ID]]`⟷`[[ID|Name]]`, needs an id→name batch RPC), conceal display, and the one-time `[[Title]]`→`[[ID]]` migration (then retire name-resolution + the canonical-context hack). See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). 8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 774c58e..620ebb9 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -25,6 +25,9 @@ M.subs = { follow = function() require("heph.link").follow() end, + link = function() + require("heph.link").insert() + end, open = function(args) if args[1] then require("heph.node").open(args[1]) diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 7b8e963..bbff461 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -68,12 +68,48 @@ function M.follow() require("heph.node").open(node.id) end ---- Attach the buffer-local `<CR>` follow keymap and inline-`#hashtag` +--- Pick a node (by full-text search) and insert a canonical `[[NODEID]]` link at +--- the cursor — the authoring path for wiki-links-by-id (§8.4); a node id is the +--- only thing that ever enters a stored link, so there's no name ambiguity. A +--- "Create" entry mints a new doc named after the query. No-op if cancelled. +function M.insert() + vim.ui.input({ prompt = "Link to: " }, function(query) + if not query or query == "" then + return + end + local items = {} + for _, hit in ipairs(rpc.call("search", { query = query }) or {}) do + items[#items + 1] = hit + end + items[#items + 1] = { __create = true, title = query } + require("heph.picker").select(items, { + prompt = "Link to", + format = function(it) + if it.__create then + return "+ Create new doc: " .. it.title + end + return it.title .. " [" .. (it.kind or "node") .. "]" + end, + }, function(choice) + if not choice then + return + end + local id = choice.__create and rpc.call("node.create", { kind = "doc", title = choice.title }).id or choice.id + vim.api.nvim_put({ "[[" .. id .. "]]" }, "c", true, true) + end) + end) +end + +--- Attach the buffer-local follow/insert keymaps and inline-`#hashtag` --- highlighting (only on heph:// buffers). function M.attach(buf) vim.keymap.set("n", "<CR>", function() M.follow() end, { buffer = buf, desc = "heph: follow [[link]]" }) + -- Typing `[[` opens the node picker (Obsidian-style), inserting `[[NODEID]]`. + vim.keymap.set("i", "[[", function() + M.insert() + end, { buffer = buf, desc = "heph: insert [[link]]" }) -- Render inline #hashtags in italics so they stand out — matching the -- save-time tag detection (whitespace-prefixed `#word`, never a `# heading`). diff --git a/heph.nvim/tests/e2e/link_insert_spec.lua b/heph.nvim/tests/e2e/link_insert_spec.lua new file mode 100644 index 0000000..9a5a8ef --- /dev/null +++ b/heph.nvim/tests/e2e/link_insert_spec.lua @@ -0,0 +1,73 @@ +-- Wiki-links by node id (§8.4): the `[[` picker searches nodes and inserts a +-- canonical `[[NODEID]]` link; a "Create" entry mints a new doc. The inserted +-- id resolves on follow and materializes as a `wiki` link on save. + +local h = require("e2e.helpers") + +describe("link insert picker", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + -- Drive `link.insert` with a stubbed query + choice. + local function with_picker(query, pick, fn) + vim.g.heph_force_ui_select = true + local oi, os = vim.ui.input, vim.ui.select + vim.ui.input = function(_o, cb) + cb(query) + end + vim.ui.select = function(items, _o, cb) + cb(pick(items)) + end + local ok, err = pcall(fn) + vim.ui.input, vim.ui.select, vim.g.heph_force_ui_select = oi, os, nil + assert.is_true(ok, tostring(err)) + end + + it("inserts a canonical [[NODEID]] for a searched node and materializes the link", function() + local target = h.create_doc("Roofing", "the roofing doc") + local src = h.create_doc("Daily", "") + local buf = h.open(src.id) + -- Put the cursor on an empty body line below the frontmatter. + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) + vim.api.nvim_win_set_cursor(0, { vim.api.nvim_buf_line_count(buf), 0 }) + + with_picker("roofing", function(items) + return items[1] -- the search hit (FTS matches the "roofing" token) + end, function() + require("heph.link").insert() + end) + + -- The buffer now carries `[[<target id>]]`, and saving materializes the link. + assert.is_truthy(h.find(buf, "%[%[" .. target.id), "[[id]] not inserted") + h.save(buf) + local linked = false + for _, l in ipairs(ctx.q:call("links.backlinks", { id = target.id })) do + if l.src_id == src.id and l.link_type == "wiki" then + linked = true + end + end + assert.is_true(linked, "expected a wiki link from src to the picked node") + end) + + it("creates a new doc when the Create entry is chosen", function() + local src = h.create_doc("Notes", "") + local buf = h.open(src.id) + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) + vim.api.nvim_win_set_cursor(0, { vim.api.nvim_buf_line_count(buf), 0 }) + + with_picker("Brand New Topic", function(items) + return items[#items] -- the "+ Create" sentinel is last + end, function() + require("heph.link").insert() + end) + + local created = ctx.q:call("node.resolve", { title = "Brand New Topic" }) + assert.is_truthy(created, "create entry should mint the doc") + assert.is_truthy(h.find(buf, "%[%[" .. created.id), "[[new id]] not inserted") + end) +end) -- 2.50.1 (Apple Git-155) From ef2081fd8b65ce2ed812dd309bb31bb58aca5769 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 12:32:24 -0700 Subject: [PATCH 74/91] =?UTF-8?q?feat(core,hephd):=20wiki-link=20expand-on?= =?UTF-8?q?-read=20/=20collapse-on-write=20(=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep canonical `[[NODEID]]` links readable without storing names. New pure `heph-core::wikilink` (injected id→title): `expand` turns a bare `[[id]]` into `[[id|Current Name]]`, `collapse` turns a name-matching `[[id|text]]` back to bare (a custom label is preserved as an override). - `node.get` expands on every read (nvim buffer + TUI preview both readable), then prepends frontmatter when asked. - `update_node` strips frontmatter, then collapses links, then CRDT-diffs — so neither projection ever persists and an unchanged read→write is a no-op to the bare id. Tests: wikilink unit (expand/collapse/round-trip), a heph-core collapse + materialize-by-id integration test, and a socket expand→collapse round-trip. `heph export` still emits raw ids (later polish). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/lib.rs | 1 + crates/heph-core/src/sqlite/nodes.rs | 21 +++- crates/heph-core/src/wikilink.rs | 120 +++++++++++++++++++++++ crates/heph-core/tests/wikilinks.rs | 39 ++++++++ crates/hephd/src/rpc.rs | 51 +++++++--- crates/hephd/tests/rpc_socket.rs | 36 +++++++ docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 2 +- 8 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 crates/heph-core/src/wikilink.rs create mode 100644 crates/heph-core/tests/wikilinks.rs diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 98d4a8c..d9b9ecd 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod ranking; pub mod recurrence; pub mod sqlite; pub mod store; +pub mod wikilink; pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 9dbd5db..19e098b 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -281,6 +281,16 @@ pub(super) fn get(conn: &Connection, id: &str) -> Result<Option<Node>> { /// Update a node's title and/or body. A body change re-runs extraction and /// reconciles this node's `wiki` links (tech-spec §5). +/// The title of node `id` if it's a live node owned by `owner` — the id→title +/// lookup behind wiki-link collapse (§8.4). +fn title_if_live(conn: &Connection, owner: &str, id: &str) -> Option<String> { + get(conn, id) + .ok() + .flatten() + .filter(|n| !n.tombstoned && n.owner_id == owner) + .map(|n| n.title) +} + pub(super) fn update( conn: &mut Connection, owner: &str, @@ -294,9 +304,14 @@ pub(super) fn update( if let Some(t) = title { node.title = t; } - // Frontmatter is a read-only projection (§8.3): strip any leading block a - // client echoes back so it never enters the stored body or the text CRDT. - let body = body.map(|b| crate::frontmatter::strip(&b).to_string()); + // Two display projections are undone before the body is stored (§8.3, §8.4): + // strip any echoed-back frontmatter block, then collapse `[[id|name]]` links + // whose label still equals the target's name back to the canonical bare id. + // Both run before the text CRDT diff so neither ever enters the stored body. + let body = body.map(|b| { + let stripped = crate::frontmatter::strip(&b); + crate::wikilink::collapse(stripped, |id| title_if_live(conn, owner, id)) + }); let body_changed = match body { Some(b) => { let changed = node.body.as_deref() != Some(b.as_str()); diff --git a/crates/heph-core/src/wikilink.rs b/crates/heph-core/src/wikilink.rs new file mode 100644 index 0000000..5a01d07 --- /dev/null +++ b/crates/heph-core/src/wikilink.rs @@ -0,0 +1,120 @@ +//! Wiki-link display projection (tech-spec §8.4). At rest a link is +//! `[[NODEID]]` (or `[[NODEID|custom text]]` when the author gave explicit +//! display text). For readability we **expand** a bare id to +//! `[[NODEID|Current Name]]` on read, and **collapse** a `|text` that still +//! equals the target's current name back to bare on write — so a rename keeps +//! display text fresh while at-rest stays canonical, and an unchanged +//! read→write round-trips. Pure: the id→title lookup is injected (the store +//! wiring lives in the SQLite layer / daemon). + +/// Rewrite each `[[…]]` span via `f(target, display) -> Option<replacement>`, +/// where `display` is `None` for a bare `[[target]]` and the text after the +/// first `|` otherwise (both trimmed). `f` returning `None` leaves the span +/// verbatim. Text outside links is preserved exactly. +fn rewrite_spans(body: &str, f: impl Fn(&str, Option<&str>) -> Option<String>) -> String { + let mut out = String::with_capacity(body.len()); + let mut rest = body; + while let Some(open) = rest.find("[[") { + out.push_str(&rest[..open]); + let after = &rest[open + 2..]; + let Some(close) = after.find("]]") else { + // No closing fence — emit the `[[` literally and scan on. + out.push_str("[["); + rest = after; + continue; + }; + let inner = &after[..close]; + if inner.contains("[[") { + // A stray `[[` inside — emit the first literally, rescan from it. + out.push_str("[["); + rest = after; + continue; + } + let (target, display) = match inner.split_once('|') { + Some((t, d)) => (t.trim(), Some(d.trim())), + None => (inner.trim(), None), + }; + match f(target, display) { + Some(rep) => out.push_str(&rep), + None => { + out.push_str("[["); + out.push_str(inner); + out.push_str("]]"); + } + } + rest = &after[close + 2..]; + } + out.push_str(rest); + out +} + +/// Expand a bare `[[NODEID]]` to `[[NODEID|Current Name]]` when `title_of` +/// knows the id. Links that already carry display text, and ids `title_of` +/// doesn't resolve (e.g. legacy `[[Name]]`), are left untouched. +pub fn expand(body: &str, title_of: impl Fn(&str) -> Option<String>) -> String { + rewrite_spans(body, |target, display| { + if display.is_some() { + return None; + } + title_of(target).map(|t| format!("[[{target}|{t}]]")) + }) +} + +/// Collapse `[[NODEID|text]]` back to bare `[[NODEID]]` when `text` still equals +/// the target's current name (i.e. it's an auto-expanded label, not a custom +/// override). Everything else — bare ids, real overrides, legacy `[[Name]]` — +/// is left untouched. +pub fn collapse(body: &str, title_of: impl Fn(&str) -> Option<String>) -> String { + rewrite_spans(body, |target, display| match display { + Some(text) if title_of(target).as_deref() == Some(text) => Some(format!("[[{target}]]")), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn titles() -> impl Fn(&str) -> Option<String> { + let m: HashMap<&str, &str> = [("01ID", "Roof"), ("02ID", "Garden")].into_iter().collect(); + move |id: &str| m.get(id).map(|s| s.to_string()) + } + + #[test] + fn expand_only_bare_known_ids() { + let t = titles(); + assert_eq!(expand("see [[01ID]] now", &t), "see [[01ID|Roof]] now"); + // Already-labelled, unknown id, and legacy name are untouched. + assert_eq!(expand("[[01ID|Mine]]", &t), "[[01ID|Mine]]"); + assert_eq!(expand("[[unknown]]", &t), "[[unknown]]"); + assert_eq!(expand("[[Some Title]]", &t), "[[Some Title]]"); + } + + #[test] + fn collapse_only_name_matching_labels() { + let t = titles(); + // Auto-label collapses; a custom override stays; bare stays. + assert_eq!(collapse("[[01ID|Roof]]", &t), "[[01ID]]"); + assert_eq!(collapse("[[01ID|My roof]]", &t), "[[01ID|My roof]]"); + assert_eq!(collapse("[[01ID]]", &t), "[[01ID]]"); + assert_eq!(collapse("[[Some Title]]", &t), "[[Some Title]]"); + } + + #[test] + fn expand_then_collapse_round_trips_to_bare() { + let t = titles(); + let stored = "a [[01ID]] and [[02ID|My garden]] end"; + let shown = expand(stored, &t); + assert_eq!(shown, "a [[01ID|Roof]] and [[02ID|My garden]] end"); + assert_eq!(collapse(&shown, &t), stored); + } + + #[test] + fn preserves_surrounding_text_and_handles_unterminated() { + let t = titles(); + assert_eq!(expand("no links here", &t), "no links here"); + assert_eq!(expand("dangling [[01ID", &t), "dangling [[01ID"); + assert_eq!(expand("", &t), ""); + } +} diff --git a/crates/heph-core/tests/wikilinks.rs b/crates/heph-core/tests/wikilinks.rs new file mode 100644 index 0000000..a56fa3e --- /dev/null +++ b/crates/heph-core/tests/wikilinks.rs @@ -0,0 +1,39 @@ +//! Wiki-link display projection (tech-spec §8.4): a body's links are stored as +//! canonical bare `[[NODEID]]`. `update_node` collapses a name-matching +//! `[[NODEID|Name]]` label back to bare (the daemon expands it again on read); +//! a custom label is preserved. `get_node` here returns the **raw stored** body +//! (expansion is a daemon-read concern), so it shows the collapsed form. + +use heph_core::{FixedClock, LinkType, LocalStore, NewNode, Store}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn update_collapses_name_matching_labels_and_materializes_by_id() { + let mut s = store(); + let target = s.create_node(NewNode::doc("Roof", "")).unwrap(); + let src = s.create_node(NewNode::doc("Daily", "")).unwrap(); + + // The buffer the user saves carries the expanded label `[[id|Roof]]`. + let updated = s + .update_node(&src.id, None, Some(format!("see [[{}|Roof]] here", target.id))) + .unwrap(); + // Stored body collapsed the auto-label back to the canonical bare id. + assert_eq!( + updated.body.as_deref(), + Some(format!("see [[{}]] here", target.id).as_str()) + ); + // And the `[[id]]` materialized as a wiki link by id. + assert!(s + .outgoing_links(&src.id) + .unwrap() + .iter() + .any(|l| l.link_type == LinkType::Wiki && l.dst_id == target.id)); + + // A custom label (≠ the target's current name) is a real override — kept. + let custom = format!("[[{}|my roof]]", target.id); + let u2 = s.update_node(&src.id, None, Some(custom.clone())).unwrap(); + assert_eq!(u2.body.as_deref(), Some(custom.as_str())); +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index d2613de..461d882 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -261,6 +261,20 @@ fn subject_task(store: &dyn Store, node: &Node) -> Result<Option<Task>, RpcError Ok(None) } +/// Expand bare `[[id]]` links in `body` to `[[id|Current Name]]` for display +/// (§8.4), looking each id up via the store (best-effort: an unknown/tombstoned +/// id or a legacy `[[Name]]` link is left untouched). +fn expand_wikilinks(store: &dyn Store, body: &str) -> String { + heph_core::wikilink::expand(body, |id| { + store + .get_node(id) + .ok() + .flatten() + .filter(|n| !n.tombstoned) + .map(|n| n.title) + }) +} + /// The name of the project a task is filed under (its `in-project` link), if any. fn project_name_of(store: &dyn Store, task_id: &str) -> Result<Option<String>, RpcError> { for link in store.outgoing_links(task_id)? { @@ -278,21 +292,32 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va "node.get" => { let p: GetNodeParams = parse(params)?; match store.get_node(&p.id)? { - Some(mut node) if p.frontmatter => { - // Render the editable frontmatter projection (§8.3) from the - // node's own fields + its (owning) task, and prepend it. - let task = subject_task(store, &node)?; - let tags = store.tags_of(&node.id)?; - let project = match &task { - Some(t) => project_name_of(store, &t.node_id)?, - None => None, - }; - let fm = - crate::frontmatter::render(&node, task.as_ref(), project.as_deref(), &tags); - node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or(""))); + None => Value::Null, + Some(mut node) => { + // Expand `[[id]]` → `[[id|Current Name]]` for readability + // (§8.4); the body collapses back to bare ids on write. + if let Some(b) = node.body.take() { + node.body = Some(expand_wikilinks(store, &b)); + } + if p.frontmatter { + // Prepend the editable frontmatter projection (§8.3) from + // the node's own fields + its (owning) task. + let task = subject_task(store, &node)?; + let tags = store.tags_of(&node.id)?; + let project = match &task { + Some(t) => project_name_of(store, &t.node_id)?, + None => None, + }; + let fm = crate::frontmatter::render( + &node, + task.as_ref(), + project.as_deref(), + &tags, + ); + node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or(""))); + } json!(node) } - other => json!(other), } } "node.create" => { diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 40f59dd..9426258 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -274,6 +274,42 @@ fn tag_add_list_remove_over_socket() { ); } +#[test] +fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let target = c + .call("node.create", json!({ "kind": "doc", "title": "Roof" })) + .unwrap(); + let tid = target["id"].as_str().unwrap().to_string(); + let src = c + .call("node.create", json!({ "kind": "doc", "title": "Daily" })) + .unwrap(); + let sid = src["id"].as_str().unwrap().to_string(); + + // Store a canonical bare link (as the `[[` picker inserts it). + c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}]]") })) + .unwrap(); + + // On read, the bare id is expanded to a readable, current-name label. + let got = c.call("node.get", json!({ "id": sid })).unwrap(); + assert_eq!(got["body"], json!(format!("see [[{tid}|Roof]]"))); + + // Saving that expanded buffer back collapses it to the bare id again — a + // no-op round-trip — and the wiki link is materialized by id. + c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}|Roof]]") })) + .unwrap(); + let again = c.call("node.get", json!({ "id": sid })).unwrap(); + assert_eq!(again["body"], json!(format!("see [[{tid}|Roof]]"))); + let links = c.call("links.outgoing", json!({ "id": sid })).unwrap(); + assert!(links + .as_array() + .unwrap() + .iter() + .any(|l| l["link_type"] == "wiki" && l["dst_id"] == tid)); +} + #[test] fn frontmatter_renders_on_read_and_is_stripped_on_write() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 9d1b55a..5730820 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; readable display/conceal of the ids is next.) +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; in-editor conceal of the id is next.) - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 5c5f514..c3b9fa8 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -336,7 +336,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). - ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID]]`; a **"+ Create new doc"** entry mints a `doc` and inserts its id. Follow (`<CR>`) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. -- ⏳ **Projection (same philosophy as §8.3):** on **read**, expand a bare `[[NODEID]]` → `[[NODEID|Current Name]]` so buffers, `heph export`, and any dumb reader show readable, always-fresh link text; on **write**, a `|text` equal to the target's current name **collapses back** to bare. Needs an **id→name batch resolve** RPC. +- ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ⏳ **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), revealed in raw form when the cursor is on it. - ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**. -- 2.50.1 (Apple Git-155) From fd010a7066b3380dce60f643314bc045c5d65964 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 12:35:24 -0700 Subject: [PATCH 75/91] =?UTF-8?q?feat(nvim):=20conceal=20wiki-link=20ids?= =?UTF-8?q?=20to=20styled=20name=20hyperlinks=20(=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `conceal.lua` hides the `[[id|` prefix and `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink`; `conceallevel=2` + empty `concealcursor` reveal the raw `[[id|Name]]` on the cursor's line so it stays editable. The `[[` picker now inserts the labelled `[[id|Name]]` form (readable + conceal-ready; collapses to bare on save). e2e asserts the conceal extmarks + conceallevel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 6 +-- heph.nvim/lua/heph/conceal.lua | 66 ++++++++++++++++++++++++ heph.nvim/lua/heph/link.lua | 12 ++++- heph.nvim/lua/heph/node.lua | 1 + heph.nvim/tests/e2e/link_insert_spec.lua | 22 ++++++++ 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 heph.nvim/lua/heph/conceal.lua diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 5730820..46c7ab4 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; in-editor conceal of the id is next.) +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them.) - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index c3b9fa8..138c608 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,10 +334,10 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, > **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). -- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID]]`; a **"+ Create new doc"** entry mints a `doc` and inserts its id. Follow (`<CR>`) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save); a **"+ Create new doc"** entry mints a `doc`. Follow (`<CR>`) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* -- ⏳ **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), revealed in raw form when the cursor is on it. +- ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. - ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**. ## 9. Testing strategy (TDD, layered) @@ -470,7 +470,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). 4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save. -5. ◐ **Wiki-links by node id (§8.4) — authoring DONE, display + migration next:** ✅ id-first resolution (`[[NODEID]]` resolves ahead of name; legacy `[[Name]]` still works) + the `heph.nvim` `[[` picker (`search` → insert `[[NODEID]]`, "+ Create" mints a doc) + id-direct follow. ⏳ Remaining: read-expansion/write-collapse projection (`[[ID]]`⟷`[[ID|Name]]`, needs an id→name batch RPC), conceal display, and the one-time `[[Title]]`→`[[ID]]` migration (then retire name-resolution + the canonical-context hack). See §8.4. +5. ◐ **Wiki-links by node id (§8.4) — authoring + display DONE, migration next:** ✅ id-first resolution; the `heph.nvim` `[[` picker (`search` → insert `[[NODEID|Name]]`, "+ Create" mints a doc) + id-direct follow; the **expand-on-read / collapse-on-write** projection (`heph-core::wikilink`); and **conceal display** (`conceal.lua` hides the id, shows the label as a `HephLink`, reveals on the cursor line). ⏳ Remaining: the one-time `[[Title]]`→`[[NODEID]]` migration (then retire name-resolution + the canonical-context hack). See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). 8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. diff --git a/heph.nvim/lua/heph/conceal.lua b/heph.nvim/lua/heph/conceal.lua new file mode 100644 index 0000000..d4c14c1 --- /dev/null +++ b/heph.nvim/lua/heph/conceal.lua @@ -0,0 +1,66 @@ +--- Conceal `[[NODEID|Name]]` links down to a styled "Name" hyperlink in node +--- buffers (tech-spec §8.4). The node id is structural noise to a reader, so we +--- hide the `[[id|` prefix and the `]]` suffix with conceal extmarks, leaving +--- the label visible and highlighted. `conceallevel=2` + an empty +--- `concealcursor` reveal the raw `[[id|Name]]` on the line the cursor is on, so +--- it stays directly editable. A bare `[[id]]` (briefly, before save→reload +--- canonicalises it) just hides its brackets. + +local M = {} + +local ns = vim.api.nvim_create_namespace("heph_link_conceal") + +--- Recompute conceal extmarks for every `[[…]]` span in `buf`. +function M.refresh(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + for lnum, line in ipairs(lines) do + local row = lnum - 1 + local from = 1 + while true do + local s = line:find("[[", from, true) -- 1-based byte of first `[` + if not s then + break + end + local e = line:find("]]", s + 2, true) -- 1-based byte of `]]` + if not e then + break + end + local inner = line:sub(s + 2, e - 1) + local pipe = inner:find("|", 1, true) + -- Byte columns are 0-based for extmarks; end_col is exclusive. + local open_end -- exclusive end of the hidden prefix (`[[` or `[[id|`) + if pipe then + open_end = (s + 1 + pipe) -- 0-based col just past the `|` + else + open_end = s + 1 -- 0-based col just past `[[` + end + -- Hide the prefix, highlight the visible label, hide the closing `]]`. + vim.api.nvim_buf_set_extmark(buf, ns, row, s - 1, { end_col = open_end, conceal = "" }) + vim.api.nvim_buf_set_extmark(buf, ns, row, open_end, { end_col = e - 1, hl_group = "HephLink" }) + vim.api.nvim_buf_set_extmark(buf, ns, row, e - 1, { end_col = e + 1, conceal = "" }) + from = e + 2 + end + end +end + +--- Enable link conceal for the current window + `buf`: define the highlight, +--- set the window conceal options, refresh now, and refresh on edits. +function M.attach(buf) + vim.api.nvim_set_hl(0, "HephLink", { link = "Underlined", default = true }) + vim.wo.conceallevel = 2 + vim.wo.concealcursor = "" -- reveal the raw link on the cursor's line + M.refresh(buf) + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = buf, + callback = function() + M.refresh(buf) + end, + desc = "heph: refresh [[link]] conceal", + }) +end + +return M diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index bbff461..8bcec36 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -94,8 +94,16 @@ function M.insert() if not choice then return end - local id = choice.__create and rpc.call("node.create", { kind = "doc", title = choice.title }).id or choice.id - vim.api.nvim_put({ "[[" .. id .. "]]" }, "c", true, true) + local id, title + if choice.__create then + local node = rpc.call("node.create", { kind = "doc", title = choice.title }) + id, title = node.id, node.title + else + id, title = choice.id, choice.title + end + -- Insert the labelled form `[[id|Name]]` (readable + conceal-ready); it + -- collapses to the canonical bare `[[id]]` on save (§8.4). + vim.api.nvim_put({ "[[" .. id .. "|" .. title .. "]]" }, "c", true, true) end) end) end diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua index 6bd9f2c..a986a13 100644 --- a/heph.nvim/lua/heph/node.lua +++ b/heph.nvim/lua/heph/node.lua @@ -34,6 +34,7 @@ function M.read(buf, uri) vim.bo[buf].fileformat = "unix" vim.bo[buf].modified = false require("heph.link").attach(buf) + require("heph.conceal").attach(buf) end --- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the body. diff --git a/heph.nvim/tests/e2e/link_insert_spec.lua b/heph.nvim/tests/e2e/link_insert_spec.lua index 9a5a8ef..7ad590f 100644 --- a/heph.nvim/tests/e2e/link_insert_spec.lua +++ b/heph.nvim/tests/e2e/link_insert_spec.lua @@ -54,6 +54,28 @@ describe("link insert picker", function() assert.is_true(linked, "expected a wiki link from src to the picked node") end) + it("conceals a link's id, leaving the name as a styled label", function() + local target = h.create_doc("Roofing", "") + -- A bare canonical link in the source; node.get expands it to [[id|Roofing]]. + local src = h.create_doc("Daily", "see [[" .. target.id .. "]]") + local buf = h.open(src.id) + + -- The expanded link is visible in the buffer... + assert.is_truthy(h.find(buf, "%[%[" .. target.id .. "|Roofing%]%]"), "expanded link missing") + -- ...and conceal extmarks hide the `[[id|` prefix + `]]` suffix. + local ns = vim.api.nvim_get_namespaces()["heph_link_conceal"] + assert.is_truthy(ns, "conceal namespace not registered") + local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + local conceals = 0 + for _, m in ipairs(marks) do + if m[4] and m[4].conceal == "" then + conceals = conceals + 1 + end + end + assert.is_true(conceals >= 2, "expected prefix+suffix conceal extmarks, got " .. conceals) + assert.are.equal(2, vim.wo.conceallevel, "conceallevel not set for the buffer's window") + end) + it("creates a new doc when the Create entry is chosen", function() local src = h.create_doc("Notes", "") local buf = h.open(src.id) -- 2.50.1 (Apple Git-155) From b112b0d7c16402db201632667ab075fb241b365e Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 12:40:57 -0700 Subject: [PATCH 76/91] =?UTF-8?q?feat:=20heph=20migrate-links=20=E2=80=94?= =?UTF-8?q?=20rewrite=20legacy=20[[Name]]=20links=20to=20[[id]]=20(=C2=A78?= =?UTF-8?q?.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wikilink::to_ids` rewrites name-addressed links to the canonical id (id-first resolve: an already-id target is left alone, a name → its id with any label preserved). `Store::migrate_wikilinks_to_ids` runs it over every body and re-saves through update_node (which collapses + materializes by id); idempotent. Surfaced as the `migrate.wikilinks` RPC + RemoteStore forward + the `heph migrate-links` CLI command (not auto-run — the owner runs it once per store). Name-resolution + the canonical-context hack stay for now so legacy links keep resolving pre-migration; retiring them is a later tidy. Tests: to_ids unit + a heph-core migrate integration (rewrite + materialize + idempotency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/mod.rs | 23 ++++++++++++++++++ crates/heph-core/src/store.rs | 6 +++++ crates/heph-core/src/wikilink.rs | 31 ++++++++++++++++++++++++ crates/heph-core/tests/wikilinks.rs | 25 +++++++++++++++++++ crates/heph/src/main.rs | 8 ++++++ crates/hephd/src/remote.rs | 4 +++ crates/hephd/src/rpc.rs | 1 + docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 4 +-- 9 files changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 01b455d..577f49c 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -321,6 +321,29 @@ impl Store for LocalStore { tags::of(&self.conn, node_id) } + fn migrate_wikilinks_to_ids(&mut self) -> Result<usize> { + let owner = self.owner_id.clone(); + let candidates: Vec<(String, String)> = { + let mut stmt = self.conn.prepare( + "SELECT id, body FROM nodes + WHERE owner_id = ?1 AND tombstoned = 0 AND body IS NOT NULL", + )?; + let rows = stmt.query_map([&owner], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::<rusqlite::Result<Vec<_>>>()? + }; + let mut changed = 0; + for (id, body) in candidates { + let new_body = crate::wikilink::to_ids(&body, |t| { + links::resolve_id(&self.conn, &owner, t).ok().flatten() + }); + if new_body != body { + self.update_node(&id, None, Some(new_body))?; + changed += 1; + } + } + Ok(changed) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { let now = self.clock.now_ms(); let tx = self.conn.transaction()?; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 656b1cb..52d459f 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -157,6 +157,12 @@ pub trait Store { /// [`Store::list_nodes`] with [`NodeKind::Tag`].) fn tags_of(&self, node_id: &str) -> Result<Vec<String>>; + /// One-time migration (tech-spec §8.4): rewrite legacy name-addressed body + /// links `[[Name]]` to the canonical `[[NODEID]]`, resolving each name and + /// re-materializing the `wiki` links by id. Idempotent (already-id links are + /// left alone). Returns the number of nodes whose body changed. + fn migrate_wikilinks_to_ids(&mut self) -> Result<usize>; + // --- per-task log ([[design]] §6.4) --- /// Append a line to a task's append-only log (creating the log on first diff --git a/crates/heph-core/src/wikilink.rs b/crates/heph-core/src/wikilink.rs index 5a01d07..4173067 100644 --- a/crates/heph-core/src/wikilink.rs +++ b/crates/heph-core/src/wikilink.rs @@ -71,6 +71,22 @@ pub fn collapse(body: &str, title_of: impl Fn(&str) -> Option<String>) -> String }) } +/// Rewrite legacy name-addressed links `[[Name]]`/`[[Name|text]]` to the +/// canonical `[[NODEID]]`/`[[NODEID|text]]` (the one-time migration, §8.4). +/// `resolve(target)` returns the node id a target resolves to (id-first, then +/// name): a target that already *is* an id resolves to itself and is left +/// alone; a name resolves to a different id and is rewritten (its display text, +/// if any, preserved); an unresolvable target is left untouched. +pub fn to_ids(body: &str, resolve: impl Fn(&str) -> Option<String>) -> String { + rewrite_spans(body, |target, display| match resolve(target) { + Some(id) if id != target => Some(match display { + Some(d) => format!("[[{id}|{d}]]"), + None => format!("[[{id}]]"), + }), + _ => None, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -110,6 +126,21 @@ mod tests { assert_eq!(collapse(&shown, &t), stored); } + #[test] + fn to_ids_rewrites_names_keeps_ids_and_preserves_labels() { + // `resolve`: a known name → its id; an id → itself; else None. + let resolve = |t: &str| match t { + "Roof" => Some("01ID".to_string()), + "01ID" => Some("01ID".to_string()), + "02ID" => Some("02ID".to_string()), + _ => None, + }; + assert_eq!(to_ids("see [[Roof]]", resolve), "see [[01ID]]"); + assert_eq!(to_ids("[[Roof|my label]]", resolve), "[[01ID|my label]]"); + assert_eq!(to_ids("[[01ID]]", resolve), "[[01ID]]"); // already an id + assert_eq!(to_ids("[[Unknown]]", resolve), "[[Unknown]]"); // unresolvable + } + #[test] fn preserves_surrounding_text_and_handles_unterminated() { let t = titles(); diff --git a/crates/heph-core/tests/wikilinks.rs b/crates/heph-core/tests/wikilinks.rs index a56fa3e..7ab1915 100644 --- a/crates/heph-core/tests/wikilinks.rs +++ b/crates/heph-core/tests/wikilinks.rs @@ -37,3 +37,28 @@ fn update_collapses_name_matching_labels_and_materializes_by_id() { let u2 = s.update_node(&src.id, None, Some(custom.clone())).unwrap(); assert_eq!(u2.body.as_deref(), Some(custom.as_str())); } + +#[test] +fn migrate_rewrites_legacy_name_links_to_ids() { + let mut s = store(); + let target = s.create_node(NewNode::doc("Roof", "")).unwrap(); + // A legacy body authored with a name-addressed link (pre-§8.4). + let src = s + .create_node(NewNode::doc("Daily", "fix the [[Roof]] soon")) + .unwrap(); + + let changed = s.migrate_wikilinks_to_ids().unwrap(); + assert_eq!(changed, 1); + + // The name was rewritten to the canonical bare id, and the wiki link is by id. + let body = s.get_node(&src.id).unwrap().unwrap().body.unwrap(); + assert_eq!(body, format!("fix the [[{}]] soon", target.id)); + assert!(s + .outgoing_links(&src.id) + .unwrap() + .iter() + .any(|l| l.link_type == LinkType::Wiki && l.dst_id == target.id)); + + // Idempotent: a second run finds nothing to change. + assert_eq!(s.migrate_wikilinks_to_ids().unwrap(), 0); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index fef4a3c..8c2f8b0 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -230,6 +230,9 @@ enum Command { /// Destination directory (created if needed). dir: PathBuf, }, + /// One-time: rewrite legacy `[[Name]]` body links to the canonical + /// `[[node-id]]` form (tech-spec §8.4). Idempotent. + MigrateLinks, /// Manage the hephd daemon as an OS service (launchd / systemd). Daemon { #[command(subcommand)] @@ -726,6 +729,11 @@ fn main() -> Result<()> { let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); println!("Exported {count} nodes to {}", dir.display()); } + Command::MigrateLinks => { + let result = client.call("migrate.wikilinks", json!({}))?; + let n = result.as_u64().unwrap_or(0); + println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s)."); + } Command::Auth { .. } => unreachable!("auth is handled before connecting"), Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 7449bc8..2d997cf 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -248,6 +248,10 @@ impl Store for RemoteStore { self.call_as("tag.list", json!({ "node_id": node_id })) } + fn migrate_wikilinks_to_ids(&mut self) -> Result<usize> { + self.call_as("migrate.wikilinks", json!({})) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { self.call("log.append", json!({ "task_id": task_id, "text": text })) .map(|_| ()) diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 461d882..8f0a77a 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -422,6 +422,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: TagParams = parse(params)?; json!(store.tags_of(&p.node_id)?) } + "migrate.wikilinks" => json!(store.migrate_wikilinks_to_ids()?), "log.append" => { let p: LogAppendParams = parse(params)?; store.log_append(&p.task_id, &p.text)?; diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 46c7ab4..5c55db3 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them.) +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 138c608..31cd673 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -338,7 +338,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. -- ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**. +- ✅ **Migration:** **`heph migrate-links`** (the `migrate.wikilinks` RPC → `Store::migrate_wikilinks_to_ids` → `wikilink::to_ids`) rewrites legacy `[[Name]]` bodies to `[[NODEID]]` and re-materializes the `wiki` links by id; idempotent (already-id links untouched). It is **not auto-run** — the owner runs it once per store. Name-resolution and the canonical-context hack **stay for now** (legacy links keep working until the migration has been run everywhere); removing them is a later tidy. A first-class migrations feature stays **deferred**. ## 9. Testing strategy (TDD, layered) @@ -470,7 +470,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit). 3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). 4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save. -5. ◐ **Wiki-links by node id (§8.4) — authoring + display DONE, migration next:** ✅ id-first resolution; the `heph.nvim` `[[` picker (`search` → insert `[[NODEID|Name]]`, "+ Create" mints a doc) + id-direct follow; the **expand-on-read / collapse-on-write** projection (`heph-core::wikilink`); and **conceal display** (`conceal.lua` hides the id, shows the label as a `HephLink`, reveals on the cursor line). ⏳ Remaining: the one-time `[[Title]]`→`[[NODEID]]` migration (then retire name-resolution + the canonical-context hack). See §8.4. +5. ✅ **Wiki-links by node id (§8.4) — DONE:** id-first resolution; the `heph.nvim` `[[` picker (`search` → insert `[[NODEID|Name]]`, "+ Create" mints a doc) + id-direct follow; the **expand-on-read / collapse-on-write** projection (`heph-core::wikilink`); **conceal display** (`conceal.lua` hides the id, shows the label as a `HephLink`, reveals on the cursor line); and the one-time **`heph migrate-links`** migration of legacy `[[Name]]`→`[[NODEID]]`. *(Follow-up tidy, once the migration is run on every store: retire name-resolution + the canonical-context hack — kept for now so legacy links work pre-migration.)* See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). 8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -- 2.50.1 (Apple Git-155) From d178a657e0eb5496a8fd42f3d089577c64f8b8a7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 13:03:02 -0700 Subject: [PATCH 77/91] =?UTF-8?q?feat(nvim):=20live=20Telescope=20filter?= =?UTF-8?q?=20for=20the=20[[=20link=20picker=20(=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "guess a query, then filter a frozen result set" flow with a live fuzzy filter over every node when Telescope is present: type to narrow `node.list`, <CR> inserts the highlighted node, <C-x> creates a doc named the current prompt (a miss flows straight into making it). The search-then-`vim.ui.select` two-step stays as the no-Telescope fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 2 +- heph.nvim/lua/heph/link.lua | 96 ++++++++++++++++++++---- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 5c55db3..c60d54b 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over all your nodes** — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 31cd673..02cbc85 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,7 +334,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, > **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). -- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save); a **"+ Create new doc"** entry mints a `doc`. Follow (`<CR>`) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over every node** (`node.list`) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (`<CR>`) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 8bcec36..9195f36 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -68,11 +68,74 @@ function M.follow() require("heph.node").open(node.id) end ---- Pick a node (by full-text search) and insert a canonical `[[NODEID]]` link at ---- the cursor — the authoring path for wiki-links-by-id (§8.4); a node id is the ---- only thing that ever enters a stored link, so there's no name ambiguity. A ---- "Create" entry mints a new doc named after the query. No-op if cancelled. -function M.insert() +-- Insert the labelled form `[[id|Name]]` at the cursor (readable + conceal-ready; +-- it collapses to the canonical bare `[[id]]` on save, §8.4). +local function put_link(id, title) + vim.api.nvim_put({ "[[" .. id .. "|" .. title .. "]]" }, "c", true, true) +end + +-- Mint a doc named `title` and insert a link to it. +local function create_and_put(title) + if title and #title > 0 then + local node = rpc.call("node.create", { kind = "doc", title = title }) + put_link(node.id, node.title) + end +end + +-- Telescope is available and not explicitly disabled (tests force ui.select). +local function use_telescope() + return not vim.g.heph_force_ui_select and pcall(require, "telescope") +end + +-- A live, fuzzy-filtered Telescope picker over every node: type to narrow, +-- <CR> inserts the highlighted node, <C-x> creates a doc named the current +-- prompt text (so a miss flows straight into "make it"). Telescope only. +local function telescope_insert() + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + + local nodes = rpc.call("node.list", vim.empty_dict()) or {} + pickers + .new({}, { + prompt_title = "Link to node (<C-x> = create from prompt)", + finder = finders.new_table({ + results = nodes, + entry_maker = function(n) + return { + value = n, + display = n.title .. " [" .. (n.kind or "node") .. "]", + ordinal = n.title, + } + end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(bufnr, map) + actions.select_default:replace(function() + local entry = action_state.get_selected_entry() + actions.close(bufnr) + if entry then + put_link(entry.value.id, entry.value.title) + end + end) + local create = function() + local title = action_state.get_current_line() + actions.close(bufnr) + create_and_put(title) + end + map("i", "<C-x>", create) + map("n", "<C-x>", create) + return true + end, + }) + :find() +end + +-- Fallback when Telescope isn't available: prompt a query, search, then pick +-- from the results (or a "+ Create" entry). `vim.ui.select` can't filter live. +local function uiselect_insert() vim.ui.input({ prompt = "Link to: " }, function(query) if not query or query == "" then return @@ -93,21 +156,26 @@ function M.insert() }, function(choice) if not choice then return - end - local id, title - if choice.__create then - local node = rpc.call("node.create", { kind = "doc", title = choice.title }) - id, title = node.id, node.title + elseif choice.__create then + create_and_put(choice.title) else - id, title = choice.id, choice.title + put_link(choice.id, choice.title) end - -- Insert the labelled form `[[id|Name]]` (readable + conceal-ready); it - -- collapses to the canonical bare `[[id]]` on save (§8.4). - vim.api.nvim_put({ "[[" .. id .. "|" .. title .. "]]" }, "c", true, true) end) end) end +--- Insert a canonical `[[NODEID|Name]]` link at the cursor (§8.4) by picking a +--- node — live-filtered via Telescope when available, else a search-then-select +--- prompt. A node id is the only thing that ever enters a stored link. +function M.insert() + if use_telescope() then + telescope_insert() + else + uiselect_insert() + end +end + --- Attach the buffer-local follow/insert keymaps and inline-`#hashtag` --- highlighting (only on heph:// buffers). function M.attach(buf) -- 2.50.1 (Apple Git-155) From 1737f8c266bf723856850fd4a1d9e0292c2e4c79 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 13:10:44 -0700 Subject: [PATCH 78/91] =?UTF-8?q?feat(nvim):=20preview=20pane=20in=20the?= =?UTF-8?q?=20Telescope=20[[=20link=20picker=20(=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a buffer previewer that shows the highlighted node's body as you filter — for a task (no body of its own), it previews the canonical- context doc instead. RPC-fed (heph nodes aren't files, unlike obsidian.nvim's notes), pcall-guarded so a fetch miss just blanks the preview. Reuses the global fzy_native sorter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 2 +- heph.nvim/lua/heph/link.lua | 27 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index c60d54b..25ac84c 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over all your nodes** — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over all your nodes with a preview pane** (the node's body, or a task's context doc) — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 02cbc85..2dcc357 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,7 +334,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, > **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). -- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over every node** (`node.list`) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (`<CR>`) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over every node** (`node.list`) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (`<CR>`) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 9195f36..059ec09 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -90,12 +90,32 @@ end -- A live, fuzzy-filtered Telescope picker over every node: type to narrow, -- <CR> inserts the highlighted node, <C-x> creates a doc named the current -- prompt text (so a miss flows straight into "make it"). Telescope only. +-- The markdown body to preview for a picker entry: a doc/journal's own body, +-- or — for a task (which has no body of its own) — its canonical-context doc. +local function preview_body(node) + local ok, body = pcall(function() + local id = node.id + if node.kind == "task" then + for _, l in ipairs(rpc.call("links.outgoing", { id = node.id }) or {}) do + if l.link_type == "canonical-context" then + id = l.dst_id + break + end + end + end + local fetched = rpc.call("node.get", { id = id }) + return (fetched and fetched.body) or "" + end) + return vim.split(ok and body or "", "\n", { plain = true }) +end + local function telescope_insert() local pickers = require("telescope.pickers") local finders = require("telescope.finders") local conf = require("telescope.config").values local actions = require("telescope.actions") local action_state = require("telescope.actions.state") + local previewers = require("telescope.previewers") local nodes = rpc.call("node.list", vim.empty_dict()) or {} pickers @@ -112,6 +132,13 @@ local function telescope_insert() end, }), sorter = conf.generic_sorter({}), + previewer = previewers.new_buffer_previewer({ + title = "Preview", + define_preview = function(self, entry) + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, preview_body(entry.value)) + vim.bo[self.state.bufnr].filetype = "markdown" + end, + }), attach_mappings = function(bufnr, map) actions.select_default:replace(function() local entry = action_state.get_selected_entry() -- 2.50.1 (Apple Git-155) From 2fc48a1aa96e6dc79bda030d58f90d2f02bfe51f Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 13:15:29 -0700 Subject: [PATCH 79/91] =?UTF-8?q?feat:=20node.linkable=20=E2=80=94=20first?= =?UTF-8?q?-class=20link=20targets=20for=20the=20[[=20picker=20(=C2=A78.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker listed every node, so each task showed up twice (itself + its same-titled canonical-context doc) plus tag/log noise — and the new preview made the duplicates look identical. New `Store::list_linkable_nodes` / `node.linkable` returns non-tombstoned nodes minus `tag`s and the docs that are a task's canonical-context or log attachment (you link the task, not its body). The Telescope picker now sources from it. Tests: a socket test (5 nodes → 2 linkable: task + standalone doc). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/mod.rs | 4 ++++ crates/heph-core/src/sqlite/nodes.rs | 17 +++++++++++++++++ crates/heph-core/src/store.rs | 5 +++++ crates/hephd/src/remote.rs | 4 ++++ crates/hephd/src/rpc.rs | 1 + crates/hephd/tests/rpc_socket.rs | 22 ++++++++++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/tech-spec.md | 2 +- heph.nvim/lua/heph/link.lua | 3 ++- 9 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 577f49c..c4ec1b1 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -289,6 +289,10 @@ impl Store for LocalStore { nodes::list(&self.conn, &self.owner_id, kind) } + fn list_linkable_nodes(&self) -> Result<Vec<Node>> { + nodes::list_linkable(&self.conn, &self.owner_id) + } + fn journal_open_or_create(&mut self, date: &str) -> Result<Node> { let now = self.clock.now_ms(); nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 19e098b..515be5d 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -407,6 +407,23 @@ pub(super) fn list(conn: &Connection, owner: &str, kind: Option<NodeKind>) -> Re Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) } +/// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes, minus +/// the internal noise — `tag` nodes, and the `doc` nodes that are a task's +/// **canonical-context** or **log** attachment (you link the task, not its +/// auto-created body/log). Title-sorted, for the `[[` picker. +pub(super) fn list_linkable(conn: &Connection, owner: &str) -> Result<Vec<Node>> { + let sql = format!( + "SELECT {COLUMNS} FROM nodes + WHERE owner_id = ?1 AND tombstoned = 0 AND kind != 'tag' + AND id NOT IN (SELECT dst_id FROM links + WHERE type IN ('canonical-context', 'log-of') AND tombstoned = 0) + ORDER BY title COLLATE NOCASE" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([owner], from_row)?; + Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) +} + /// A node's aliases (wiki-link names), sorted. Empty until aliases are written. pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> { let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 52d459f..1ef715c 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -46,6 +46,11 @@ pub trait Store { /// (tech-spec §6 `node.list`). fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>>; + /// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes + /// minus the internal noise — `tag` nodes and the `doc`s that are a task's + /// canonical-context or log attachment. Backs the `[[` picker. + fn list_linkable_nodes(&self) -> Result<Vec<Node>>; + /// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an /// alias match first, then an exact, owner-scoped, non-tombstoned title /// match; `None` if nothing matches (an unresolved link is allowed, §5). diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 2d997cf..be74c2a 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -216,6 +216,10 @@ impl Store for RemoteStore { self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) })) } + fn list_linkable_nodes(&self) -> Result<Vec<Node>> { + self.call_as("node.linkable", json!({})) + } + fn journal_open_or_create(&mut self, date: &str) -> Result<Node> { self.call_as("journal.open_or_create", json!({ "date": date })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 8f0a77a..7a91008 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -324,6 +324,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: NewNode = parse(params)?; json!(store.create_node(p)?) } + "node.linkable" => json!(store.list_linkable_nodes()?), "node.update" => { let p: UpdateParams = parse(params)?; json!(store.update_node(&p.id, p.title, p.body)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 9426258..1e751cd 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -274,6 +274,28 @@ fn tag_add_list_remove_over_socket() { ); } +#[test] +fn node_linkable_excludes_context_docs_logs_and_tags() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + // task.create → a task node + a same-titled canonical-context doc. + let task = c.call("task.create", json!({ "title": "Fix roof" })).unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + // a standalone doc, a tag node, and a log doc (first append). + c.call("node.create", json!({ "kind": "doc", "title": "Notes" })).unwrap(); + c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })).unwrap(); + c.call("log.append", json!({ "task_id": task_id, "text": "started" })).unwrap(); + + // Only first-class targets: the task and the standalone doc — not the + // context doc, the log doc, or the tag (5 nodes total ⇒ 2 linkable). + let nodes = c.call("node.linkable", json!({})).unwrap(); + let arr = nodes.as_array().unwrap(); + assert_eq!(arr.len(), 2, "expected just the task + standalone doc:\n{arr:#?}"); + assert!(arr.iter().any(|n| n["title"] == "Fix roof" && n["kind"] == "task")); + assert!(arr.iter().any(|n| n["title"] == "Notes" && n["kind"] == "doc")); +} + #[test] fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 25ac84c..8e433a1 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over all your nodes with a preview pane** (the node's body, or a task's context doc) — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over your linkable nodes with a preview pane** (the node's body, or a task's context doc) — the list shows first-class targets only (a task appears once; its internal context/log docs and tag nodes are hidden, via the new `node.linkable` query) — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 2dcc357..26e7576 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,7 +334,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, > **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). -- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over every node** (`node.list`) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (`<CR>`) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over the linkable nodes** (`node.linkable` — non-tombstoned nodes minus `tag`s and the `doc`s that are a task's canonical-context/log attachment, so a task appears once, not twice) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `<CR>` inserts, **`<C-x>` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (`<CR>`) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 059ec09..3ae89e0 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -117,7 +117,8 @@ local function telescope_insert() local action_state = require("telescope.actions.state") local previewers = require("telescope.previewers") - local nodes = rpc.call("node.list", vim.empty_dict()) or {} + -- First-class targets only (excludes tags + tasks' context/log docs, §8.4). + local nodes = rpc.call("node.linkable", vim.empty_dict()) or {} pickers .new({}, { prompt_title = "Link to node (<C-x> = create from prompt)", -- 2.50.1 (Apple Git-155) From 44d6847fae83d52bcc3214de8a31a3b54de9067b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 14:42:08 -0700 Subject: [PATCH 80/91] =?UTF-8?q?feat(tui):=20<Enter>=20opens=20the=20cont?= =?UTF-8?q?ext=20editor;=20reorder=20views=20(=C2=A78.1/=C2=A78.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `<Enter>` now opens the selected task's context doc in nvim (App::enter: from the sidebar it drills into the task list first); the `o` binding is retired. Hint line updated. - BUILTIN_VIEWS reordered to the owner's preference — Top of Mind, Tasks, Work Tasks, Chores, On Deck — which drives the TUI sidebar and `heph view`. Tests that walked to On Deck by a fixed offset now seek it by title. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/filter.rs | 66 ++++++++++++------------ crates/heph-core/tests/wikilinks.rs | 6 ++- crates/heph-tui/src/app.rs | 11 ++++ crates/heph-tui/src/main.rs | 6 +-- crates/heph-tui/src/ui.rs | 2 +- crates/heph-tui/tests/agenda.rs | 7 +-- crates/heph-tui/tests/navigation.rs | 6 ++- crates/hephd/tests/rpc_socket.rs | 44 ++++++++++++---- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 8 +-- 10 files changed, 100 insertions(+), 57 deletions(-) diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs index 745be6b..0f16f9d 100644 --- a/crates/heph-core/src/filter.rs +++ b/crates/heph-core/src/filter.rs @@ -3,8 +3,8 @@ //! A view is a **predicate expressed as data** (mirroring §7's "order as //! data"): the engine [`Store::list`](crate::store::Store::list) takes a //! [`ListFilter`] and returns the matching outstanding tasks as -//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / On Deck -//! / Chores / Work Tasks / Tasks) are derived from the owner's Todoist filter +//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / Tasks / +//! Work Tasks / Chores / On Deck) are derived from the owner's Todoist filter //! queries (see `docs/explanation/design.md` §6.2.1) and realized in heph terms //! (attention: p1→red, p2→orange, p4→white, p3→blue). @@ -98,6 +98,8 @@ pub struct ViewSpec { /// The five built-in views (tech-spec §8.2), each realized from the verbatim /// Todoist query in design §6.2.1. +// Sidebar / `heph view` order (owner's preference): Top of Mind, Tasks, +// Work Tasks, Chores, On Deck. pub const BUILTIN_VIEWS: &[ViewSpec] = &[ // (p1|p2) & (no date|today|overdue) ViewSpec { @@ -109,36 +111,6 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ exclude_names: &[], actionable: true, }, - // p3 & (no date|overdue|today) - ViewSpec { - name: "ondeck", - title: "On Deck", - attention_in: &[Attention::Blue], - attention_not: &[], - scope_names: &[], - exclude_names: &[], - actionable: true, - }, - // (today|overdue|no date) & (#Chores|#Camano Chores) - ViewSpec { - name: "chores", - title: "Chores", - attention_in: &[], - attention_not: &[], - scope_names: &["Chores", "Camano Chores"], - exclude_names: &[], - actionable: true, - }, - // #Work & !p3 & (…) & !subtask - ViewSpec { - name: "work", - title: "Work Tasks", - attention_in: &[], - attention_not: &[Attention::Blue], - scope_names: &["Work"], - exclude_names: &[], - actionable: true, - }, // !p3 & (…) & !(#Daily Routine|#Work Routine|#Chores|#Camano Chores|#Work|…) & !subtask ViewSpec { name: "tasks", @@ -155,6 +127,36 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ ], actionable: true, }, + // #Work & !p3 & (…) & !subtask + ViewSpec { + name: "work", + title: "Work Tasks", + attention_in: &[], + attention_not: &[Attention::Blue], + scope_names: &["Work"], + exclude_names: &[], + actionable: true, + }, + // (today|overdue|no date) & (#Chores|#Camano Chores) + ViewSpec { + name: "chores", + title: "Chores", + attention_in: &[], + attention_not: &[], + scope_names: &["Chores", "Camano Chores"], + exclude_names: &[], + actionable: true, + }, + // p3 & (no date|overdue|today) + ViewSpec { + name: "ondeck", + title: "On Deck", + attention_in: &[Attention::Blue], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: true, + }, ]; /// Look up a built-in view by its short name (`tom|ondeck|chores|work|tasks`). diff --git a/crates/heph-core/tests/wikilinks.rs b/crates/heph-core/tests/wikilinks.rs index 7ab1915..b5d4a2d 100644 --- a/crates/heph-core/tests/wikilinks.rs +++ b/crates/heph-core/tests/wikilinks.rs @@ -18,7 +18,11 @@ fn update_collapses_name_matching_labels_and_materializes_by_id() { // The buffer the user saves carries the expanded label `[[id|Roof]]`. let updated = s - .update_node(&src.id, None, Some(format!("see [[{}|Roof]] here", target.id))) + .update_node( + &src.id, + None, + Some(format!("see [[{}|Roof]] here", target.id)), + ) .unwrap(); // Stored body collapsed the auto-label back to the canonical bare id. assert_eq!( diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 9b1af11..6e50a0c 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -429,6 +429,17 @@ impl<B: Backend> App<B> { }; } + /// The `<Enter>` gesture: from the sidebar, drill into the task list; on a + /// task, open its context doc in the editor (returns the node id to open). + pub fn enter(&mut self) -> Option<String> { + if self.focus == Focus::Sidebar { + self.focus_tasks(); + None + } else { + self.selected_context_id() + } + } + // --- triage mutations (T2a: single-keypress, no input) --- /// Run `f` against the backend; on success set `ok` as the status and reload, diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 89ee6e6..b0da4e9 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -155,7 +155,9 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A KeyCode::Char('j') | KeyCode::Down => move_down(app), KeyCode::Char('k') | KeyCode::Up => move_up(app), KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), - KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(), + // Enter: drill sidebar→tasks, or open the selected task's context in nvim. + KeyCode::Enter => return app.enter().map(Action::EditContext), // capture + reschedule + search (open an input prompt) KeyCode::Char('a') => app.begin_add(), KeyCode::Char('e') => app.begin_reschedule(), @@ -169,8 +171,6 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A KeyCode::Char('b') => app.push_to_blue_selected(), KeyCode::Char('m') => app.begin_move(), KeyCode::Char('D') => app.begin_delete(), - // open the task's context doc in nvim (handled by the event loop) - KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext), _ => {} } None diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index acf5f35..09dd1c8 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -18,7 +18,7 @@ use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; const HINTS: &str = - " j/k move a add x done S skip e date A attn b→blue m move D del s sort o edit / search q quit"; + " j/k move ⏎ edit a add x done S skip e date A attn b→blue m move D del s sort / search q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 5625c8a..89399e4 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -296,8 +296,9 @@ fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { app.push_to_blue_selected(); assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); - // It now appears under On Deck (sidebar row 2). - app.move_sidebar(1); - assert_eq!(app.task_pane_title(), "On Deck"); + // It now appears under On Deck (the last of the five views). + while app.task_pane_title() != "On Deck" { + app.move_sidebar(1); + } assert_eq!(app.selected_task().unwrap().title, "Cool it down"); } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index b056f90..2355a0c 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -168,8 +168,10 @@ fn starts_on_the_first_view_with_its_tasks() { #[test] fn moving_the_sidebar_switches_the_task_list() { let mut app = App::new(fixture()).unwrap(); - app.move_sidebar(1); // Top of Mind -> On Deck - assert_eq!(app.task_pane_title(), "On Deck"); + // Step to On Deck (the last of the five views) and confirm the list switched. + while app.task_pane_title() != "On Deck" { + app.move_sidebar(1); + } assert_eq!(app.tasks.len(), 1); assert_eq!(app.selected_task().unwrap().title, "blue one"); } diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 1e751cd..9debf4a 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -280,20 +280,36 @@ fn node_linkable_excludes_context_docs_logs_and_tags() { let mut c = client(&socket); // task.create → a task node + a same-titled canonical-context doc. - let task = c.call("task.create", json!({ "title": "Fix roof" })).unwrap(); + let task = c + .call("task.create", json!({ "title": "Fix roof" })) + .unwrap(); let task_id = task["node_id"].as_str().unwrap().to_string(); // a standalone doc, a tag node, and a log doc (first append). - c.call("node.create", json!({ "kind": "doc", "title": "Notes" })).unwrap(); - c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })).unwrap(); - c.call("log.append", json!({ "task_id": task_id, "text": "started" })).unwrap(); + c.call("node.create", json!({ "kind": "doc", "title": "Notes" })) + .unwrap(); + c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })) + .unwrap(); + c.call( + "log.append", + json!({ "task_id": task_id, "text": "started" }), + ) + .unwrap(); // Only first-class targets: the task and the standalone doc — not the // context doc, the log doc, or the tag (5 nodes total ⇒ 2 linkable). let nodes = c.call("node.linkable", json!({})).unwrap(); let arr = nodes.as_array().unwrap(); - assert_eq!(arr.len(), 2, "expected just the task + standalone doc:\n{arr:#?}"); - assert!(arr.iter().any(|n| n["title"] == "Fix roof" && n["kind"] == "task")); - assert!(arr.iter().any(|n| n["title"] == "Notes" && n["kind"] == "doc")); + assert_eq!( + arr.len(), + 2, + "expected just the task + standalone doc:\n{arr:#?}" + ); + assert!(arr + .iter() + .any(|n| n["title"] == "Fix roof" && n["kind"] == "task")); + assert!(arr + .iter() + .any(|n| n["title"] == "Notes" && n["kind"] == "doc")); } #[test] @@ -311,8 +327,11 @@ fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { let sid = src["id"].as_str().unwrap().to_string(); // Store a canonical bare link (as the `[[` picker inserts it). - c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}]]") })) - .unwrap(); + c.call( + "node.update", + json!({ "id": sid, "body": format!("see [[{tid}]]") }), + ) + .unwrap(); // On read, the bare id is expanded to a readable, current-name label. let got = c.call("node.get", json!({ "id": sid })).unwrap(); @@ -320,8 +339,11 @@ fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { // Saving that expanded buffer back collapses it to the bare id again — a // no-op round-trip — and the wiki link is materialized by id. - c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}|Roof]]") })) - .unwrap(); + c.call( + "node.update", + json!({ "id": sid, "body": format!("see [[{tid}|Roof]]") }), + ) + .unwrap(); let again = c.call("node.get", json!({ "id": sid })).unwrap(); assert_eq!(again["body"], json!(format!("see [[{tid}|Roof]]"))); let links = c.call("links.outgoing", json!({ "id": sid })).unwrap(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 8e433a1..fb2854f 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -26,6 +26,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. - Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit <task> --project <name>` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. +- `heph-tui`: **`<Enter>`** opens the selected task's context editor in nvim (from the sidebar it first drills into the task list); the old `o` binding is retired. The view sidebar / `heph view` order is now **Top of Mind, Tasks, Work Tasks, Chores, On Deck**. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc. - Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over your linkable nodes with a preview pane** (the node's body, or a task's context doc) — the list shows first-class targets only (a task appears once; its internal context/log docs and tag nodes are hidden, via the new `node.linkable` query) — type to narrow, Enter to insert, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 26e7576..4a2c8fa 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,7 +254,7 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `S` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `s` **sort toggle** (default ↔ project-grouped) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `S` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `s` **sort toggle** (default ↔ project-grouped) · `⏎` **edit context in nvim** (from a task; from the sidebar it drills into the list) · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. @@ -274,10 +274,10 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba | View | Todoist query (origin) | heph realization | |---|---|---| | **Top of Mind** | `(p1\|p2) & (no date\|today\|overdue)` | `attention ∈ {red,orange}` ∧ actionable | -| **On Deck** | `p3 & (no date\|overdue\|today)` | `attention = blue` ∧ actionable | -| **Chores** | `(today\|overdue\|no date) & (#Chores\|#Camano Chores)` | scope ∈ {Chores, Camano Chores} ∧ actionable | -| **Work Tasks** | `#Work & !p3 & (…) & !subtask` | scope = Work subtree ∧ `attention ≠ blue` ∧ actionable | | **Tasks** | `!p3 & (…) & !(#Daily Routine\|#Work Routine\|#Chores\|#Camano Chores\|#Work\|##Culture\|#Camano Info) & !subtask` | `attention ≠ blue` ∧ actionable ∧ **not in** the routine/work/chore projects | +| **Work Tasks** | `#Work & !p3 & (…) & !subtask` | scope = Work subtree ∧ `attention ≠ blue` ∧ actionable | +| **Chores** | `(today\|overdue\|no date) & (#Chores\|#Camano Chores)` | scope ∈ {Chores, Camano Chores} ∧ actionable | +| **On Deck** | `p3 & (no date\|overdue\|today)` | `attention = blue` ∧ actionable | **Engine work — extend `list` (§6) so a view is a *predicate expressed as data*** (mirroring §7's "order as data"): -- 2.50.1 (Apple Git-155) From 02f98355e7cb7a4f34129db83a3adb2483e529d7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 18:12:09 -0700 Subject: [PATCH 81/91] =?UTF-8?q?feat(tui):=20pad=20the=20attention=20flag?= =?UTF-8?q?=20off=20the=20project=20dot=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-cell gap between the ⚑ attention flag and the project-colored ● bullet; the blank-flag case already reserves the same width, so rows stay aligned whether or not a flag is present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 09dd1c8..18c6027 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -267,7 +267,7 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { // Pad the title so the right side (↻ + date chip) aligns right. let chip_w = chip.len(); let recur_w = if recur { 2 } else { 0 }; // "↻ " - let fixed = 1 + 1 + 1 + 1 + 1; // cursor + flag + bullet + space + trailing space + let fixed = 1 + 1 + 1 + 1 + 1 + 1; // cursor + flag + gap + bullet + space + trailing space let avail = width.saturating_sub(fixed + recur_w + chip_w); let mut title: String = t.title.chars().take(avail).collect(); let pad = avail.saturating_sub(title.chars().count()); @@ -276,6 +276,7 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { let mut header = vec![ Span::styled(cursor, Style::default().fg(Color::Cyan)), Span::styled(flag, flag_st), + Span::raw(" "), Span::styled("●", bullet_st), Span::raw(" "), Span::styled(title, title_style), -- 2.50.1 (Apple Git-155) From 0c45bbb5f91e385c2209251dd666c6c961516c22 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 18:12:24 -0700 Subject: [PATCH 82/91] =?UTF-8?q?feat:=20heph-quickadd=20=E2=80=94=20globa?= =?UTF-8?q?l=20=E2=8C=98'=20quick-capture=20popover=20(=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A macOS global quick-capture popover to retire Todoist. New `heph-quickadd` crate: an always-warm eframe/egui agent that registers ⌘' (global-hotkey, Carbon — no Accessibility permission) and toggles a hidden, pre-created window visible + focused on press — never spawning on the keypress, so it's a muscle reflex. A single field live-parses Todoist-style inline syntax via the shared parser; chips show ⚑ attention · 📁 project · ⏰ do-date · ↻ recurrence as you type. Enter saves optimistically (hide now, task.create on a bg thread; a failed RPC re-shows with the text restored). #project autocomplete (Tab/↑↓/click; focus-locked so Tab completes instead of traversing). Example hints rotate, fading in only after ~2s idle. Parser lifted from heph-tui into hephd's lib (hephd::quickadd) so the TUI and the popover share one parser. hephd supervises the helper as a child in local mode on macOS (opt-in HEPH_QUICKADD=1, set by the installed launchd plist) — one service to manage, no second launch agent; the helper self-exits when orphaned so killing hephd leaves nothing behind. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- Cargo.lock | 2606 +++++++++++++++++++- Cargo.toml | 8 +- crates/heph-quickadd/Cargo.toml | 25 + crates/heph-quickadd/src/app.rs | 699 ++++++ crates/heph-quickadd/src/main.rs | 79 + crates/heph-tui/src/app.rs | 2 +- crates/heph-tui/src/backend.rs | 9 +- crates/heph-tui/src/lib.rs | 1 - crates/heph/src/service.rs | 8 + crates/hephd/src/lib.rs | 1 + crates/hephd/src/main.rs | 74 + crates/{heph-tui => hephd}/src/quickadd.rs | 20 +- docs/changelog.d/v1-quickadd.feature.md | 2 + 13 files changed, 3482 insertions(+), 52 deletions(-) create mode 100644 crates/heph-quickadd/Cargo.toml create mode 100644 crates/heph-quickadd/src/app.rs create mode 100644 crates/heph-quickadd/src/main.rs rename crates/{heph-tui => hephd}/src/quickadd.rs (90%) create mode 100644 docs/changelog.d/v1-quickadd.feature.md diff --git a/Cargo.lock b/Cargo.lock index ee18d03..274fd62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,112 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" + +[[package]] +name = "accesskit_atspi_common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bd41de2e54451a8ca0dd95ebf45b54d349d29ebceb7f20be264eee14e3d477" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant 5.12.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692dd318ff8a7a0ffda67271c4bd10cf32249656f4e49390db0b26ca92b095f2" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f7474c36606d0fe4f438291d667bae7042ea2760f506650ad2366926358fc8" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 5.16.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a042b62c9c05bf7b616f015515c17d2813f3ba89978d6f4fc369735d60700a" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1f0d3d13113d8857542a4f8d1a1c24d1dc1527b77aee8426127f4901588708" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -26,6 +132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -46,6 +153,31 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -111,6 +243,26 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -120,6 +272,33 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -144,6 +323,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -243,6 +436,56 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 5.16.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 4.3.2", + "zvariant 5.12.0", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus 5.16.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus 5.16.0", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -334,11 +577,26 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] name = "block-buffer" @@ -358,6 +616,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -377,12 +644,89 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -414,6 +758,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -429,6 +775,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chrono" version = "0.4.44" @@ -439,7 +794,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -514,12 +869,42 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.0", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.2" @@ -604,6 +989,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -622,6 +1031,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -634,7 +1052,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "mio", "parking_lot", @@ -653,6 +1071,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -675,6 +1099,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -812,6 +1242,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -823,6 +1269,15 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "document-features" version = "0.2.12" @@ -832,6 +1287,18 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "ecdsa" version = "0.16.9" @@ -846,6 +1313,16 @@ dependencies = [ "spki", ] +[[package]] +name = "ecolor" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bdf37f8d5bd9aa7f753573fdda9cf7343afa73dd28d7bfe9593bd9798fc07e" +dependencies = [ + "bytemuck", + "emath", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -870,6 +1347,118 @@ dependencies = [ "zeroize", ] +[[package]] +name = "eframe" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d1c15e7bd136b309bd3487e6ffe5f668b354cd9768636a836dd738ac90eb0b" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.59.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.11.1", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12eca13293f8eba27a32aaaa1c765bfbf31acd43e8d30d5881dcbe5e99ca0c7" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" +dependencies = [ + "accesskit_winit", + "ahash", + "arboard", + "bytemuck", + "egui", + "log", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7037813341727937f9e22f78d912f3e29bc3c46e2f40a9e82bb51cbf5e4cfb" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + [[package]] name = "either" version = "1.16.0" @@ -897,6 +1486,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "emath" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45fd7bc25f769a3c198fe1cf183124bf4de3bd62ef7b4f1eaf6b08711a3af8db" +dependencies = [ + "bytemuck", +] + [[package]] name = "endi" version = "1.1.1" @@ -924,6 +1522,30 @@ dependencies = [ "syn", ] +[[package]] +name = "epaint" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63adcea970b7a13094fe97a36ab9307c35a750f9e24bf00bb7ef3de573e0fddb" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2" + [[package]] name = "equivalent" version = "1.0.2" @@ -940,6 +1562,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -982,6 +1610,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -1032,7 +1675,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1041,6 +1705,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1142,6 +1812,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1180,6 +1860,163 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "once_cell", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.11.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "group" version = "0.13.0" @@ -1191,6 +2028,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1263,6 +2112,22 @@ dependencies = [ "yrs", ] +[[package]] +name = "heph-quickadd" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "eframe", + "global-hotkey", + "heph-core", + "hephd", + "libc", + "serde_json", + "winit", +] + [[package]] name = "heph-tui" version = "0.0.0" @@ -1317,6 +2182,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hkdf" version = "0.12.4" @@ -1436,7 +2307,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1563,6 +2434,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1634,6 +2519,74 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1670,6 +2623,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyring" version = "3.6.3" @@ -1685,6 +2649,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1716,12 +2697,34 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1781,6 +2784,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1802,6 +2814,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1811,6 +2832,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -1839,19 +2875,99 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "strum", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", "memoffset", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1957,6 +3073,308 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1975,9 +3393,9 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "openssl-macros", "openssl-sys", @@ -2016,6 +3434,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2026,6 +3463,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "p256" version = "0.13.2" @@ -2074,9 +3520,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2157,6 +3603,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2201,6 +3667,25 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2215,6 +3700,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2239,6 +3730,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2276,6 +3773,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "proptest" version = "1.11.0" @@ -2284,7 +3787,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -2301,17 +3804,39 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "memchr", "unicase", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.45" @@ -2401,13 +3926,19 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -2422,13 +3953,37 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.11.1", ] [[package]] @@ -2460,6 +4015,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.13.4" @@ -2556,7 +4117,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2564,6 +4125,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2579,7 +4152,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2592,7 +4165,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2647,7 +4220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -2658,12 +4231,40 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "sec1" version = "0.7.3" @@ -2694,7 +4295,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -2703,7 +4304,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2716,7 +4317,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2900,6 +4501,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -2924,6 +4541,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallstr" version = "0.3.1" @@ -2939,6 +4565,78 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.6.4" @@ -2955,6 +4653,15 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -2977,6 +4684,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -3055,6 +4768,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3104,6 +4826,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -3135,6 +4871,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3223,7 +4984,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -3315,6 +5076,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3463,6 +5239,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3490,6 +5277,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3606,12 +5403,147 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.99" @@ -3632,6 +5564,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -3641,6 +5589,159 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" +dependencies = [ + "arrayvec", + "bitflags 2.11.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "ordered-float", + "parking_lot", + "portable-atomic", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3657,23 +5758,112 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -3687,6 +5877,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3698,19 +5899,72 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3719,7 +5973,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3746,7 +6000,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3765,6 +6019,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3813,6 +6076,58 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "1.0.3" @@ -3886,7 +6201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3922,6 +6237,44 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + [[package]] name = "xdg-home" version = "1.3.0" @@ -3932,6 +6285,31 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.2" @@ -4000,9 +6378,68 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant 5.12.0", ] [[package]] @@ -4015,7 +6452,22 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -4026,7 +6478,30 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant 5.12.0", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml", + "serde", + "zbus_names 4.3.2", + "zvariant 5.12.0", ] [[package]] @@ -4129,6 +6604,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" @@ -4139,7 +6629,21 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -4152,7 +6656,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.4.0", ] [[package]] @@ -4165,3 +6682,16 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index be3bb8a..07bcb2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "2" -members = ["crates/heph-core", "crates/hephd", "crates/heph", "crates/heph-tui"] +members = [ + "crates/heph-core", + "crates/hephd", + "crates/heph", + "crates/heph-tui", + "crates/heph-quickadd", +] [workspace.package] edition = "2021" diff --git a/crates/heph-quickadd/Cargo.toml b/crates/heph-quickadd/Cargo.toml new file mode 100644 index 0000000..5b1889b --- /dev/null +++ b/crates/heph-quickadd/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "heph-quickadd" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[dependencies] +heph-core = { path = "../heph-core" } +hephd = { path = "../hephd" } +anyhow.workspace = true +serde_json.workspace = true +chrono.workspace = true +clap.workspace = true +eframe = "0.32" +global-hotkey = "0.8" + +# macOS-only: winit for the accessory-mode activation policy (no Dock icon), +# pinned to the same minor eframe carries so cargo unifies to one winit; libc +# for getppid() (orphan detection — self-exit when the supervising daemon dies). +[target.'cfg(target_os = "macos")'.dependencies] +winit = "0.30" +libc = "0.2" diff --git a/crates/heph-quickadd/src/app.rs b/crates/heph-quickadd/src/app.rs new file mode 100644 index 0000000..7bae5d5 --- /dev/null +++ b/crates/heph-quickadd/src/app.rs @@ -0,0 +1,699 @@ +//! The warm quick-capture popover (tech-spec §8 — global capture surface). +//! +//! Snappiness is the whole point ([[design]] §6.2.1 "save state and walk away in +//! milliseconds"): the process stays **always running and warm**, its window +//! pre-created and merely hidden, so the global hotkey only *toggles it visible +//! and focuses the field* — never spawns anything. Saving is **optimistic**: on +//! Enter the window hides immediately and `task.create` runs on a background +//! thread, so perceived latency is just the keystroke. A failed save re-shows the +//! window with the text restored, so a capture is never silently lost. + +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; + +use chrono::NaiveDate; +use eframe::egui; +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use hephd::quickadd::{self, Parsed, Project}; +use hephd::Client; +use serde_json::json; + +/// Font size for the chrome around the field (header, chips, hints). The capture +/// field itself stays `Heading`-sized — only everything *else* is bumped up here. +const LABEL_SIZE: f32 = 15.0; +/// Dim text color for the chip row / hints. Brighter than egui's default muted +/// grey so it reads clearly against the dark HUD background. +const DIM: egui::Color32 = egui::Color32::from_gray(190); + +/// Window width and the base (collapsed) height, logical points. The window grows +/// downward to fit the `#project` autocomplete list, then shrinks back. +const WIN_W: f32 = 620.0; +const BASE_H: f32 = 150.0; +/// One autocomplete row's height, and the cap on how many show at once. +const AC_ROW_H: f32 = 26.0; +const AC_MAX_ROWS: usize = 6; + +/// Seconds of idle (no typing) before the example hint fades in — so it never +/// distracts while you're on a roll, only nudges if you pause on an empty field. +const HINT_DELAY: f64 = 2.0; + +/// Example capture lines, one chosen at random each time the popover opens — a +/// rotating cheat-sheet for the inline syntax (priorities, dates, recurrence, +/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even +/// though they reference projects a given store may not have. +const HINTS: &[&str] = &[ + "Water plants tomorrow p2 #Chores every 3 days", + "Call the dentist fri p1", + "Email Sarah the report today", + "Buy milk #Errands", + "Renew passport +30d p2", + "Review pull requests p3 #Work", + "Take out recycling every other wed", + "Pay rent every 1st p1", + "Stretch every day", + "Submit timesheet every friday #Work", + "Water the garden every 2 days", + "Back up the laptop every week p3", + "Book flights +1w p2 #Travel", + "Doctor appointment 2026-07-15 p1", + "Read a chapter today #Reading", + "Standup notes every weekday #Work", + "Change the air filter every 3 months", + "File taxes every April 15 p1", + "Clean the gutters every 6 months #Home", + "Wish Mom happy birthday every May 4 p1", + "Vacuum the house every saturday #Chores", + "Replace toothbrush every 3 months", + "Prep slides for monday p2 #Work", + "Walk the dog every day", + "Refill prescription every 30 days p2 #Health", + "Grocery run +2d #Errands", + "Mow the lawn every week #Home", + "Schedule a 1:1 with Alex thu p3 #Work", + "Send the invoice every 15th p2", + "Defrost the freezer every 6 months", + "Update the resume +14d p3", + "Check smoke detectors every 6 months #Home", + "Plan the sprint every other monday #Work", + "Order coffee beans every 2 weeks", + "Call grandma every sunday p2", + "Rotate the car tires every 6 months #Car", + "Weekly review every friday p2", + "Pick up dry cleaning tomorrow #Errands", + "Pay the credit card every 28th p1", + "Tidy the inbox every day p4", +]; + +/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand` +/// dep: the sub-second nanos of the wall clock are plenty of entropy for +/// "different one each time I open it". +fn random_hint(prev: &str) -> &'static str { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) as usize; + let mut idx = nanos % HINTS.len(); + if HINTS[idx] == prev && HINTS.len() > 1 { + idx = (idx + 1) % HINTS.len(); + } + HINTS[idx] +} + +/// Local today, for `datespec` resolution. This is a UI surface, not `heph-core`, +/// so reading the wall clock here is fine (the TUI does the same). +fn today_local() -> NaiveDate { + chrono::Local::now().date_naive() +} + +/// A finished background save, surfaced back to the UI thread. +enum SaveOutcome { + Ok, + /// The RPC failed — re-show with this text restored and the error shown. + Err { text: String, message: String }, +} + +pub struct QuickAdd { + socket: PathBuf, + /// The single capture field. + text: String, + /// Projects cached for `#name` resolution; refreshed in the background on show. + projects: Vec<Project>, + /// Whether the popover is currently shown. + visible: bool, + /// Request `request_focus()` on the field on the next frame (after a show). + focus_pending: bool, + /// An inline error from the last failed save, cleared on the next keystroke. + error: Option<String>, + /// The example hint shown this open (rotated on each show). + current_hint: &'static str, + /// egui time (seconds) at/after which the idle hint may fade in. Pushed back + /// on every keystroke so it only shows when you pause on an empty field. + hint_at: f64, + /// Highlighted row in the `#project` autocomplete list. + ac_selected: usize, + /// The `#…` query the autocomplete list last reflected, so we reset the + /// selection only when it actually changes. + ac_last_query: Option<String>, + /// The window inner-height we last applied, so we resize only on change. + win_h_applied: f32, + /// Keep the manager alive for the process lifetime (drop = unregister). + _hotkey_manager: GlobalHotKeyManager, + hotkey_id: u32, + /// When hephd supervises us, self-exit once orphaned (the daemon died) so we + /// never leak past it. Holds the parent pid we were spawned under, if any. + orphan_parent: Option<i32>, + projects_rx: Receiver<Vec<Project>>, + projects_tx: Sender<Vec<Project>>, + save_rx: Receiver<SaveOutcome>, + save_tx: Sender<SaveOutcome>, +} + +impl QuickAdd { + pub fn new(cc: &eframe::CreationContext<'_>, socket: PathBuf, start_visible: bool) -> Self { + let supervised = std::env::var("HEPH_QUICKADD_SUPERVISED").ok().as_deref() == Some("1"); + + // Register ⌘' globally. On macOS, SUPER maps to the Command key. + let manager = GlobalHotKeyManager::new().expect("global hotkey manager"); + let hotkey = HotKey::new(Some(Modifiers::SUPER), Code::Quote); + let hotkey_id = hotkey.id(); + if let Err(e) = manager.register(hotkey) { + // Most commonly: another heph-quickadd already holds ⌘'. When + // supervised, that means a previous instance is still alive — exit so + // we don't pile up duplicates (the supervisor will stop retrying once + // the original is healthy). When run by hand, keep going (the window + // still works via launch/`once`), just without the hotkey. + eprintln!("heph-quickadd: could not register ⌘' global hotkey: {e}"); + if supervised { + std::process::exit(0); + } + } + + // Baseline parent pid for orphan detection (macOS supervision only). + let orphan_parent = if supervised { current_parent_pid() } else { None }; + + let (projects_tx, projects_rx) = std::sync::mpsc::channel(); + let (save_tx, save_rx) = std::sync::mpsc::channel(); + + let mut app = Self { + socket, + text: String::new(), + projects: Vec::new(), + visible: false, + focus_pending: false, + error: None, + current_hint: HINTS[0], + hint_at: 0.0, + ac_selected: 0, + ac_last_query: None, + win_h_applied: BASE_H, + _hotkey_manager: manager, + hotkey_id, + orphan_parent, + projects_rx, + projects_tx, + save_rx, + save_tx, + }; + + // Warm the project cache without blocking startup. + app.refresh_projects(&cc.egui_ctx); + if start_visible { + app.show(&cc.egui_ctx); + } + app + } + + /// Fetch projects on a background thread; the result lands via `projects_rx`. + fn refresh_projects(&self, ctx: &egui::Context) { + let socket = self.socket.clone(); + let tx = self.projects_tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + if let Ok(projects) = fetch_projects(&socket) { + let _ = tx.send(projects); + ctx.request_repaint(); + } + }); + } + + fn show(&mut self, ctx: &egui::Context) { + self.visible = true; + self.focus_pending = true; + self.current_hint = random_hint(self.current_hint); + // Hold the hint back ~2s — show it only if you pause on the empty field. + self.hint_at = ctx.input(|i| i.time) + HINT_DELAY; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + // Center on the active monitor when we know its size. + if let Some(monitor) = ctx.input(|i| i.viewport().monitor_size) { + let size = ctx.input(|i| i.viewport().outer_rect.map(|r| r.size())); + let win = size.unwrap_or(egui::vec2(560.0, 120.0)); + let pos = egui::pos2( + ((monitor.x - win.x) * 0.5).max(0.0), + ((monitor.y - win.y) * 0.35).max(0.0), + ); + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + self.refresh_projects(ctx); + } + + fn hide(&mut self, ctx: &egui::Context) { + self.visible = false; + self.ac_last_query = None; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + // Collapse back to the base height so the next open never flashes at a + // stale (expanded) size. + if (self.win_h_applied - BASE_H).abs() > 0.5 { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H))); + self.win_h_applied = BASE_H; + } + } + + /// Optimistic submit: hide now, create in the background. + fn submit(&mut self, ctx: &egui::Context) { + let parsed = quickadd::parse(&self.text, today_local(), &self.projects); + if parsed.title.is_empty() { + return; // nothing to capture — leave the field as-is + } + let text = std::mem::take(&mut self.text); + self.error = None; + self.hide(ctx); + + let socket = self.socket.clone(); + let tx = self.save_tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let outcome = match create_task(&socket, &parsed) { + Ok(()) => SaveOutcome::Ok, + Err(e) => SaveOutcome::Err { + text, + message: e.to_string(), + }, + }; + let _ = tx.send(outcome); + ctx.request_repaint(); + }); + } + + /// Drain the global-hotkey channel; show on a fresh ⌘' press. + fn poll_hotkey(&mut self, ctx: &egui::Context) { + while let Ok(ev) = GlobalHotKeyEvent::receiver().try_recv() { + if ev.id == self.hotkey_id && ev.state == HotKeyState::Pressed { + if self.visible { + self.hide(ctx); + } else { + self.show(ctx); + } + } + } + } + + fn drain_background(&mut self, ctx: &egui::Context) { + while let Ok(projects) = self.projects_rx.try_recv() { + self.projects = projects; + } + while let Ok(outcome) = self.save_rx.try_recv() { + if let SaveOutcome::Err { text, message } = outcome { + self.text = text; + self.error = Some(message); + self.show(ctx); + } + } + } +} + +impl eframe::App for QuickAdd { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_hotkey(ctx); + self.drain_background(ctx); + + if self.visible { + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.text.clear(); + self.error = None; + self.hide(ctx); + } else { + self.draw(ctx); + } + // Smooth while interacting. + ctx.request_repaint(); + } else { + // Supervised + orphaned (the daemon that spawned us is gone) → exit so + // we never outlive hephd. Checked on the idle path only, so it costs + // nothing while the popover is open. + if let Some(orig) = self.orphan_parent { + if current_parent_pid() != Some(orig) { + std::process::exit(0); + } + } + // Idle: poll the hotkey channel ~33×/s so show latency stays well under + // a frame, with negligible cost (the hidden window presents nothing). + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + } + + /// Transparent background so the rounded panel reads as a floating HUD. + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + [0.0, 0.0, 0.0, 0.0] + } +} + +impl QuickAdd { + fn draw(&mut self, ctx: &egui::Context) { + let parsed = quickadd::parse(&self.text, today_local(), &self.projects); + + // The closure returns how many autocomplete rows it drew, so we can size + // the window to fit them. + let ac_rows = egui::CentralPanel::default() + .frame( + egui::Frame::new() + .fill(egui::Color32::from_rgb(0x1e, 0x1e, 0x24)) + .corner_radius(10.0) + .inner_margin(egui::Margin::same(14)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60))), + ) + .show(ctx, |ui| { + ui.label( + egui::RichText::new("heph · new task") + .color(egui::Color32::from_gray(170)) + .size(LABEL_SIZE), + ); + ui.add_space(6.0); + + // The hint only fades in after ~2s idle on an empty field. + let now = ui.input(|i| i.time); + let hint = if self.text.is_empty() && now >= self.hint_at { + self.current_hint + } else { + "" + }; + + let out = egui::TextEdit::singleline(&mut self.text) + .hint_text(hint) + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Heading) + .show(ui); + let field_id = out.response.id; + let cursor_idx = out.cursor_range.map(|r| r.primary.index); + let resp = out.response; + if self.focus_pending { + resp.request_focus(); + self.focus_pending = false; + } + if resp.changed() { + self.error = None; + // Typing (or clearing) pushes the hint back, so it reappears + // only after another idle pause. + self.hint_at = now + HINT_DELAY; + } + + let rows = self.draw_autocomplete(ui, field_id, cursor_idx, resp.has_focus()); + + // Enter always submits — the autocomplete (Tab/↑/↓/click) never + // hijacks it, so the muscle-reflex save stays sacred. + let submitted = + resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + + // When not autocompleting, the lower area is the live chip preview. + if rows == 0 { + ui.add_space(8.0); + self.draw_preview(ui, &parsed); + if let Some(err) = &self.error { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("⚠ not saved: {err}")) + .color(egui::Color32::from_rgb(0xe0, 0x6c, 0x60)) + .size(LABEL_SIZE), + ); + } + } + + if submitted { + self.submit(ctx); + } + rows + }) + .inner; + + // Grow/shrink the window to fit the autocomplete list — only on change, so + // we don't spam the windowing system with resize commands every frame. + let target_h = if ac_rows > 0 { + BASE_H + 6.0 + ac_rows as f32 * AC_ROW_H + } else { + BASE_H + }; + if (target_h - self.win_h_applied).abs() > 0.5 { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, target_h))); + self.win_h_applied = target_h; + } + } + + /// The `#project` autocomplete: when the cursor sits in a `#…` token, list the + /// matching projects (all of them right after a bare `#`). ↑/↓ move the + /// highlight, **Tab** or a click accepts, inserting the full `#Project Name `. + /// Returns the number of rows drawn (0 = inactive), for window sizing. + fn draw_autocomplete( + &mut self, + ui: &mut egui::Ui, + field_id: egui::Id, + cursor_idx: Option<usize>, + focused: bool, + ) -> usize { + let active = if focused { + cursor_idx.and_then(|ci| active_project_query(&self.text, ci)) + } else { + None + }; + let Some((hash_idx, query)) = active else { + self.ac_last_query = None; + return 0; + }; + let matches = project_matches(&self.projects, &query); + if matches.is_empty() { + self.ac_last_query = None; + return 0; + } + + // Reset the highlight only when the query text actually changes. + if self.ac_last_query.as_deref() != Some(query.as_str()) { + self.ac_selected = 0; + self.ac_last_query = Some(query.clone()); + } + self.ac_selected = self.ac_selected.min(matches.len() - 1); + + // Keep Tab and ↑/↓ on the field instead of letting egui's focus system + // spend them on traversal — otherwise Tab just moved focus to a row and + // the list flickered without ever accepting. (egui resolves focus at the + // start of the next frame, so the filter must be set a frame ahead; we set + // it every frame the popup is open.) + ui.memory_mut(|m| { + m.set_focus_lock_filter( + field_id, + egui::EventFilter { + tab: true, + vertical_arrows: true, + horizontal_arrows: false, + escape: false, + }, + ); + }); + + // Grab navigation/accept keys before egui spends them on focus traversal. + let (mut up, mut down, mut tab) = (false, false, false); + ui.input_mut(|i| { + down = i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown); + up = i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp); + tab = i.consume_key(egui::Modifiers::NONE, egui::Key::Tab); + }); + let n = matches.len(); + if down { + self.ac_selected = (self.ac_selected + 1) % n; + } + if up { + self.ac_selected = (self.ac_selected + n - 1) % n; + } + + ui.add_space(6.0); + let mut accept: Option<String> = tab.then(|| matches[self.ac_selected].clone()); + for (i, title) in matches.iter().enumerate().take(AC_MAX_ROWS) { + let selected = i == self.ac_selected; + let text = egui::RichText::new(format!("📁 {title}")) + .size(LABEL_SIZE) + .color(if selected { + egui::Color32::WHITE + } else { + DIM + }); + if ui.selectable_label(selected, text).clicked() { + accept = Some(title.clone()); + } + } + + if let Some(title) = accept { + apply_completion(&mut self.text, hash_idx, query.chars().count(), &title); + let new_idx = hash_idx + 1 + title.chars().count() + 1; + if let Some(mut st) = egui::widgets::text_edit::TextEditState::load(ui.ctx(), field_id) { + let cc = egui::text::CCursor::new(new_idx); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(cc))); + st.store(ui.ctx(), field_id); + } + ui.ctx().memory_mut(|m| m.request_focus(field_id)); + self.ac_last_query = None; + ui.ctx().request_repaint(); + } + + n.min(AC_MAX_ROWS) + } + + /// The live-parsed chip row: ⚑ attention · 📁 project · ⏰ do-date · ↻ recurrence. + fn draw_preview(&self, ui: &mut egui::Ui, parsed: &Parsed) { + ui.horizontal(|ui| { + let dim = DIM; + let mut any = false; + + if let Some(att) = parsed.attention { + let (label, color) = match att { + heph_core::Attention::Red => ("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60)), + heph_core::Attention::Orange => { + ("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b)) + } + heph_core::Attention::Blue => { + ("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef)) + } + heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)), + }; + ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE)); + any = true; + } + + if let Some(id) = &parsed.project_id { + let title = self + .projects + .iter() + .find(|p| &p.id == id) + .map(|p| p.title.as_str()) + .unwrap_or("project"); + ui.label(egui::RichText::new(format!("📁 {title}")).color(dim).size(LABEL_SIZE)); + any = true; + } + + if let Some(do_ms) = parsed.do_date { + ui.label( + egui::RichText::new(format!("⏰ {}", fmt_day(do_ms))) + .color(dim) + .size(LABEL_SIZE), + ); + any = true; + } + + if parsed.recurrence.is_some() { + ui.label(egui::RichText::new("↻ recurs").color(dim).size(LABEL_SIZE)); + any = true; + } + + if !any { + ui.label( + egui::RichText::new("type p1–p4 · #project · a date · every …") + .color(egui::Color32::from_gray(140)) + .size(LABEL_SIZE), + ); + } + }); + } +} + +/// The current parent process id, for orphan detection. `None` off macOS (where +/// hephd does not supervise a helper — there is no Aqua session to inherit). +fn current_parent_pid() -> Option<i32> { + #[cfg(target_os = "macos")] + { + Some(unsafe { libc::getppid() }) + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +/// If the cursor sits inside a `#…` project token, return `(hash_char_index, +/// query)` — the chars between the `#` and the cursor. `None` when the cursor is +/// past a token (preceded by whitespace) or the nearest `#` is glued to a word +/// (matching the parser's whitespace tokenization). Empty query (just typed `#`) +/// is valid and means "list everything". +fn active_project_query(text: &str, cursor: usize) -> Option<(usize, String)> { + let chars: Vec<char> = text.chars().collect(); + let cursor = cursor.min(chars.len()); + if cursor == 0 || chars[cursor - 1].is_whitespace() { + return None; + } + let mut i = cursor; + while i > 0 { + i -= 1; + if chars[i] == '#' { + if i == 0 || chars[i - 1].is_whitespace() { + let query: String = chars[i + 1..cursor].iter().collect(); + if query.contains('#') { + return None; + } + return Some((i, query)); + } + return None; // '#' glued to a preceding word → not a tag + } + } + None +} + +/// Project titles matching `query` (case-insensitive): prefix matches first, then +/// substring matches. An empty query lists every project (sorted as fetched). +fn project_matches(projects: &[Project], query: &str) -> Vec<String> { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return projects.iter().map(|p| p.title.clone()).collect(); + } + let (mut prefix, mut contains) = (Vec::new(), Vec::new()); + for p in projects { + let t = p.title.to_lowercase(); + if t.starts_with(&q) { + prefix.push(p.title.clone()); + } else if t.contains(&q) { + contains.push(p.title.clone()); + } + } + prefix.extend(contains); + prefix +} + +/// Replace the `query` chars after the `#` with the chosen `title` plus a trailing +/// space, so `#Cam│ …` becomes `#Camano Chores │…`. +fn apply_completion(text: &mut String, hash_idx: usize, query_len: usize, title: &str) { + let chars: Vec<char> = text.chars().collect(); + let start = (hash_idx + 1).min(chars.len()); + let end = (start + query_len).min(chars.len()); + let mut out: String = chars[..start].iter().collect(); + out.push_str(title); + out.push(' '); + out.extend(chars[end..].iter()); + *text = out; +} + +/// Format an epoch-ms (local midnight) do-date as a compact `Mon D`. +fn fmt_day(ms: i64) -> String { + use chrono::TimeZone; + chrono::Local + .timestamp_millis_opt(ms) + .single() + .map(|dt| dt.format("%b %-d").to_string()) + .unwrap_or_default() +} + +fn fetch_projects(socket: &std::path::Path) -> anyhow::Result<Vec<Project>> { + let mut client = Client::connect(socket)?; + let v = client.call("node.list", json!({ "kind": "project" }))?; + let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?; + let mut projects: Vec<Project> = nodes + .into_iter() + .map(|n| Project { + id: n.id, + title: n.title, + }) + .collect(); + projects.sort_by(|a, b| a.title.cmp(&b.title)); + Ok(projects) +} + +fn create_task(socket: &std::path::Path, parsed: &Parsed) -> anyhow::Result<()> { + let mut client = Client::connect(socket)?; + client.call( + "task.create", + json!({ + "title": parsed.title, + "attention": parsed.attention, + "do_date": parsed.do_date, + "recurrence": parsed.recurrence, + "project_id": parsed.project_id, + }), + )?; + Ok(()) +} diff --git a/crates/heph-quickadd/src/main.rs b/crates/heph-quickadd/src/main.rs new file mode 100644 index 0000000..0c76f98 --- /dev/null +++ b/crates/heph-quickadd/src/main.rs @@ -0,0 +1,79 @@ +//! `heph-quickadd` — the global quick-capture popover (tech-spec §8). +//! +//! A tiny always-warm egui agent: ⌘' shows a single-line capture field that +//! parses Todoist-style inline syntax (`p2 #Chores tomorrow every 3 days`) and +//! creates a task over the `hephd` unix socket. It is **supervised by hephd** +//! (spawned in local mode on macOS), so the user installs/manages exactly one +//! service — there is no separate launch agent. +//! +//! See [`app`] for the snappiness design (warm window, optimistic save). + +mod app; + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +// Re-export egui through eframe so we can name `egui::ViewportBuilder` here. +use eframe::egui; + +use app::QuickAdd; + +#[derive(Parser, Debug)] +#[command(name = "heph-quickadd", about = "Global quick-capture popover for hephaestus")] +struct Cli { + /// hephd socket to talk to (defaults to $HEPH_SOCKET or the standard path). + #[arg(long, global = true)] + socket: Option<PathBuf>, + + #[command(subcommand)] + cmd: Option<Cmd>, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Run the warm agent (the default; this is what hephd spawns). + Run, + /// Show the popover immediately on launch — handy for iterating without ⌘'. + Once, +} + +fn resolve_socket(flag: Option<PathBuf>) -> PathBuf { + flag.or_else(|| std::env::var_os("HEPH_SOCKET").map(PathBuf::from)) + .unwrap_or_else(hephd::default_socket_path) +} + +fn main() -> eframe::Result { + let cli = Cli::parse(); + let socket = resolve_socket(cli.socket); + let start_visible = matches!(cli.cmd, Some(Cmd::Once)); + + let viewport = egui::ViewportBuilder::default() + .with_title("heph quick add") + .with_inner_size([620.0, 150.0]) + .with_min_inner_size([620.0, 150.0]) + .with_decorations(false) + .with_transparent(true) + .with_resizable(false) + .with_always_on_top() + // Start hidden + warm; ⌘' toggles it visible with zero re-init cost. + .with_visible(start_visible); + + let native_options = eframe::NativeOptions { + viewport, + // macOS: run as an accessory app — no Dock icon, no menu bar. + event_loop_builder: Some(Box::new(|_builder| { + #[cfg(target_os = "macos")] + { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + _builder.with_activation_policy(ActivationPolicy::Accessory); + } + })), + ..Default::default() + }; + + eframe::run_native( + "heph-quickadd", + native_options, + Box::new(move |cc| Ok(Box::new(QuickAdd::new(cc, socket, start_visible)))), + ) +} diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 6e50a0c..d5549aa 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -727,7 +727,7 @@ impl<B: Backend> App<B> { return; } let projects = self.project_list(); - let parsed = crate::quickadd::parse(&buf, crate::fmt::today_local(), &projects); + let parsed = hephd::quickadd::parse(&buf, crate::fmt::today_local(), &projects); if parsed.title.is_empty() { self.status = "add cancelled (no title)".into(); return; diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 9db073b..03bf5e3 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -10,12 +10,9 @@ use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch}; use hephd::Client; use serde_json::{json, Value}; -/// A project node, as the sidebar lists it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Project { - pub id: String, - pub title: String, -} +/// A project node, as the sidebar lists it. Re-exported from `hephd::quickadd` +/// so the sidebar and the shared quick-add parser speak the same type. +pub use hephd::quickadd::Project; /// A full-text search result row. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs index c49e995..83efc5c 100644 --- a/crates/heph-tui/src/lib.rs +++ b/crates/heph-tui/src/lib.rs @@ -10,7 +10,6 @@ pub mod app; pub mod backend; pub mod editor; pub mod fmt; -pub mod quickadd; pub mod ui; pub use app::{App, Focus}; diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs index 3e8f96c..6015a3d 100644 --- a/crates/heph/src/service.rs +++ b/crates/heph/src/service.rs @@ -137,6 +137,14 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String { <true/> <key>KeepAlive</key> <true/> + <key>EnvironmentVariables</key> + <dict> + <!-- Supervise the global quick-capture popover (⌘'). hephd runs in the + Aqua session as a LaunchAgent, so its child gets the GUI/hotkey it + needs. Opt-in here (not in dev/test runs, which never set it). --> + <key>HEPH_QUICKADD</key> + <string>1</string> + </dict> <key>StandardOutPath</key> <string>{log}</string> <key>StandardErrorPath</key> diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 7408103..09f8714 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -14,6 +14,7 @@ pub mod datespec; pub mod frontmatter; pub mod lock; pub mod oauth; +pub mod quickadd; pub mod remote; pub mod rpc; pub mod server; diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index e8d9cd1..e6275fd 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -199,5 +199,79 @@ async fn main() -> Result<()> { .with_context(|| format!("binding socket {}", socket.display()))?; tracing::info!(socket = %socket.display(), mode = ?cli.mode, "hephd listening"); + + // macOS local mode: supervise the global quick-capture popover (⌘'). hephd + // already runs as a `gui/$uid` LaunchAgent, so its child inherits the Aqua + // session the hotkey/GUI need — no separate launch agent. Opt-in via + // HEPH_QUICKADD=1 (the installed plist sets it) so dev/test runs that spawn a + // local daemon never pop a window. The helper self-exits when this daemon + // goes away, so killing hephd (even `kill -9`) leaves nothing behind. + #[cfg(target_os = "macos")] + if cli.mode == Mode::Local && quickadd_enabled() { + spawn_quickadd_supervisor(socket.clone()); + } + daemon.serve(listener).await } + +/// True when the quick-capture popover should be supervised (`HEPH_QUICKADD=1`). +#[cfg(target_os = "macos")] +fn quickadd_enabled() -> bool { + std::env::var("HEPH_QUICKADD").ok().as_deref() == Some("1") +} + +/// Spawn + supervise `heph-quickadd` in a background thread: (re)launch it, +/// restart on exit with capped backoff. The thread lives only as long as this +/// process, so when hephd exits the supervision simply stops; the helper notices +/// it has been orphaned and exits on its own. +#[cfg(target_os = "macos")] +fn spawn_quickadd_supervisor(socket: PathBuf) { + use std::process::Command; + + let Some(exe) = locate_quickadd_binary() else { + tracing::warn!("HEPH_QUICKADD=1 but `heph-quickadd` was not found next to hephd or on PATH"); + return; + }; + + std::thread::spawn(move || { + let mut backoff = Duration::from_millis(500); + loop { + match Command::new(&exe) + .arg("run") + .arg("--socket") + .arg(&socket) + // Tell the helper it is supervised, so it self-exits if orphaned. + .env("HEPH_QUICKADD_SUPERVISED", "1") + .spawn() + { + Ok(mut child) => { + tracing::info!(pid = child.id(), "heph-quickadd started"); + backoff = Duration::from_millis(500); // healthy start resets backoff + let _ = child.wait(); + tracing::warn!("heph-quickadd exited; restarting"); + } + Err(e) => { + tracing::error!("failed to spawn heph-quickadd: {e}"); + } + } + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_secs(30)); + } + }); +} + +/// Locate the `heph-quickadd` binary: prefer the one installed beside this +/// `hephd` (the usual `cargo install` / release layout), then fall back to PATH. +#[cfg(target_os = "macos")] +fn locate_quickadd_binary() -> Option<PathBuf> { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let sibling = dir.join("heph-quickadd"); + if sibling.is_file() { + return Some(sibling); + } + } + } + // PATH fallback: trust the name and let the OS resolve it on spawn. + Some(PathBuf::from("heph-quickadd")) +} diff --git a/crates/heph-tui/src/quickadd.rs b/crates/hephd/src/quickadd.rs similarity index 90% rename from crates/heph-tui/src/quickadd.rs rename to crates/hephd/src/quickadd.rs index 5153746..a6fccf6 100644 --- a/crates/heph-tui/src/quickadd.rs +++ b/crates/hephd/src/quickadd.rs @@ -13,11 +13,21 @@ //! - **Do-date** a `datespec` token: `today`/`tomorrow`/`+3d`/`fri`/ISO. //! - **Recurrence** an `every …` phrase (the longest suffix that parses), e.g. //! `every 3 days`, `every workday`, `every other wed`. +//! +//! This lives in `hephd` (next to [`crate::datespec`]) so every capture surface +//! shares one parser: the [`crate::Client`]-backed TUI and the `heph-quickadd` +//! global popover both call [`parse`]. use chrono::NaiveDate; use heph_core::Attention; -use crate::backend::Project; +/// A project node the parser resolves `#Name` against. Surfaces fetch these from +/// the daemon (`node.list {kind:"project"}`) and pass them in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + pub id: String, + pub title: String, +} /// The structured result of parsing a quick-add line. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -68,8 +78,8 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed { } if out.do_date.is_none() { - if let Ok(date) = hephd::datespec::parse_date(tok, today) { - out.do_date = Some(hephd::datespec::to_epoch_ms(date)); + if let Ok(date) = crate::datespec::parse_date(tok, today) { + out.do_date = Some(crate::datespec::to_epoch_ms(date)); i += 1; continue; } @@ -91,7 +101,7 @@ fn extract_recurrence(tokens: &mut Vec<String>, out: &mut Parsed) { }; for end in (start + 1..=tokens.len()).rev() { let phrase = tokens[start..end].join(" "); - if let Ok(rrule) = hephd::datespec::parse_recurrence(&phrase) { + if let Ok(rrule) = crate::datespec::parse_recurrence(&phrase) { out.recurrence = Some(rrule); tokens.drain(start..end); return; @@ -146,7 +156,7 @@ mod tests { } fn ms(y: i32, m: u32, d: u32) -> i64 { - hephd::datespec::to_epoch_ms(NaiveDate::from_ymd_opt(y, m, d).unwrap()) + crate::datespec::to_epoch_ms(NaiveDate::from_ymd_opt(y, m, d).unwrap()) } #[test] diff --git a/docs/changelog.d/v1-quickadd.feature.md b/docs/changelog.d/v1-quickadd.feature.md new file mode 100644 index 0000000..06ad4f9 --- /dev/null +++ b/docs/changelog.d/v1-quickadd.feature.md @@ -0,0 +1,2 @@ +- `heph-quickadd` (§8) — a **global quick-capture popover**, the last piece needed to retire Todoist. A tiny always-warm `eframe`/`egui` agent registers **⌘'** system-wide (`global-hotkey`, via Carbon — no Accessibility permission) and, on press, *toggles an already-created hidden window visible and focuses a single capture field* — never spawning on the keypress, so it's a muscle-reflex ([[design]] §6.2.1). The field reuses the shared `quickadd::parse` (lifted from `heph-tui` into `hephd`'s lib so both surfaces share one parser): one line like `Call dentist fri p1 #Health` parses live into title + attention (`p1`–`p4`) + do-date + recurrence + project, shown as a chip row (⚑ attention · 📁 project · ⏰ do-date · ↻ recurrence) as you type. **Enter** saves **optimistically** — the window hides instantly and `task.create` runs on a background thread, so perceived latency is just the keystroke; a failed RPC re-shows the window with the text restored, so a capture is never lost. **Esc** dismisses. +- Supervision (no second launch agent): **hephd supervises `heph-quickadd`** as a child in local mode on macOS (opt-in via `HEPH_QUICKADD=1`, which the installed launchd plist now sets — dev/test runs that spawn a local daemon never pop a window). hephd already runs as a `gui/$uid` LaunchAgent, so the child inherits the Aqua session the hotkey/GUI need, and the user still installs/manages exactly one service (`heph daemon`). The helper **self-exits when orphaned** (its parent daemon gone) and exits if ⌘' is already held, so killing hephd — even `kill -9` — leaves nothing behind and duplicates never stack. Runs as a macOS **accessory** app (no Dock icon). macOS-only for v1; `global-hotkey` + `egui` also run on Linux/X11, so the Linux port is mostly window-behavior polish. -- 2.50.1 (Apple Git-155) From 01ae561a74379f75900bae0d2ec1e26442e04617 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 18:12:33 -0700 Subject: [PATCH 83/91] =?UTF-8?q?feat:=20Inbox=20view=20=E2=80=94=20outsta?= =?UTF-8?q?nding=20tasks=20with=20no=20project=20(=C2=A78.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sixth built-in filter view (listed below On Deck) showing every outstanding task filed under no project — the capture inbox to triage. New ListFilter.unfiled predicate kept purely in matches(); the inbox ViewSpec is un-gated (no attention/do-date filter) so nothing hides from triage. Surfaces automatically in the heph-tui sidebar and `heph view`. Tests cover the predicate and the view spec; navigation tests updated for the 6th view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/filter.rs | 59 ++++++++++++++++++++--- crates/heph-core/src/sqlite/tasks.rs | 1 + crates/heph-tui/tests/navigation.rs | 6 +-- docs/changelog.d/v1-inbox-view.feature.md | 1 + 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/v1-inbox-view.feature.md diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs index 0f16f9d..a04ed49 100644 --- a/crates/heph-core/src/filter.rs +++ b/crates/heph-core/src/filter.rs @@ -3,10 +3,11 @@ //! A view is a **predicate expressed as data** (mirroring §7's "order as //! data"): the engine [`Store::list`](crate::store::Store::list) takes a //! [`ListFilter`] and returns the matching outstanding tasks as -//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / Tasks / +//! [`RankedTask`] rows. The built-in [`ViewSpec`]s (Top of Mind / Tasks / //! Work Tasks / Chores / On Deck) are derived from the owner's Todoist filter //! queries (see `docs/explanation/design.md` §6.2.1) and realized in heph terms -//! (attention: p1→red, p2→orange, p4→white, p3→blue). +//! (attention: p1→red, p2→orange, p4→white, p3→blue), plus an **Inbox** of +//! project-less tasks to triage. use serde::{Deserialize, Serialize}; @@ -36,6 +37,9 @@ pub struct ListFilter { pub exclude_projects: Vec<String>, /// Apply the §7 do-date candidacy gate: `do_date` is `None` or `<= now`. pub actionable: bool, + /// Keep only tasks with **no project** — the capture "Inbox" to triage and + /// file. (Pairs naturally with an empty `scope`; a scoped Inbox is empty.) + pub unfiled: bool, } impl ListFilter { @@ -70,6 +74,9 @@ impl ListFilter { if self.actionable && task.do_date.is_some_and(|d| d > now) { return false; } + if self.unfiled && task.project_id.is_some() { + return false; + } true } } @@ -94,12 +101,14 @@ pub struct ViewSpec { pub exclude_names: &'static [&'static str], /// Whether the §7 do-date candidacy gate applies. pub actionable: bool, + /// Whether to keep only project-less tasks (see [`ListFilter::unfiled`]). + pub unfiled: bool, } -/// The five built-in views (tech-spec §8.2), each realized from the verbatim -/// Todoist query in design §6.2.1. +/// The built-in views (tech-spec §8.2): five realized from the verbatim +/// Todoist query in design §6.2.1, plus an Inbox of project-less tasks. // Sidebar / `heph view` order (owner's preference): Top of Mind, Tasks, -// Work Tasks, Chores, On Deck. +// Work Tasks, Chores, On Deck, Inbox. pub const BUILTIN_VIEWS: &[ViewSpec] = &[ // (p1|p2) & (no date|today|overdue) ViewSpec { @@ -110,6 +119,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &[], exclude_names: &[], actionable: true, + unfiled: false, }, // !p3 & (…) & !(#Daily Routine|#Work Routine|#Chores|#Camano Chores|#Work|…) & !subtask ViewSpec { @@ -126,6 +136,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ "Daily Routine", ], actionable: true, + unfiled: false, }, // #Work & !p3 & (…) & !subtask ViewSpec { @@ -136,6 +147,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &["Work"], exclude_names: &[], actionable: true, + unfiled: false, }, // (today|overdue|no date) & (#Chores|#Camano Chores) ViewSpec { @@ -146,6 +158,7 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &["Chores", "Camano Chores"], exclude_names: &[], actionable: true, + unfiled: false, }, // p3 & (no date|overdue|today) ViewSpec { @@ -156,6 +169,19 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ scope_names: &[], exclude_names: &[], actionable: true, + unfiled: false, + }, + // Tasks filed under no project — the capture inbox to triage and file. Shows + // everything unfiled (no attention/do-date gate) so nothing hides from triage. + ViewSpec { + name: "inbox", + title: "Inbox", + attention_in: &[], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: false, + unfiled: true, }, ]; @@ -241,6 +267,26 @@ mod tests { assert!(!f.matches(&none, NOW)); } + #[test] + fn unfiled_keeps_only_projectless_tasks() { + let f = ListFilter { + unfiled: true, + ..Default::default() + }; + let none = task("none"); + let mut filed = task("filed"); + filed.project_id = Some("work".into()); + assert!(f.matches(&none, NOW)); + assert!(!f.matches(&filed, NOW)); + } + + #[test] + fn inbox_view_is_unfiled_and_ungated() { + let spec = builtin("inbox").expect("inbox view exists"); + assert!(spec.unfiled); + assert!(!spec.actionable); + } + #[test] fn exclude_drops_listed_projects_but_keeps_projectless() { let f = ListFilter { @@ -276,6 +322,7 @@ mod tests { assert_eq!(builtin("tom").unwrap().title, "Top of Mind"); assert_eq!(builtin("ondeck").unwrap().attention_in, &[Attention::Blue]); assert!(builtin("nope").is_none()); - assert_eq!(BUILTIN_VIEWS.len(), 5); + assert_eq!(builtin("inbox").unwrap().title, "Inbox"); + assert_eq!(BUILTIN_VIEWS.len(), 6); } } diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 197fc45..22bfdaa 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -416,6 +416,7 @@ pub(super) fn view( scope, exclude_projects, actionable: spec.actionable, + unfiled: spec.unfiled, }; list(conn, owner, now, &filter) } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 2355a0c..c79570f 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -179,8 +179,8 @@ fn moving_the_sidebar_switches_the_task_list() { #[test] fn sidebar_skips_headers_into_the_projects_section() { let mut app = App::new(fixture()).unwrap(); - // 5 built-in views: step down 5 times crosses the "Projects" header to p1. - for _ in 0..5 { + // 6 built-in views: step down 6 times crosses the "Projects" header to p1. + for _ in 0..6 { app.move_sidebar(1); } assert_eq!(app.task_pane_title(), "Camano"); @@ -231,7 +231,7 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { let mut app = App::new(fake).unwrap(); // Select the project so the new task is filed there. - for _ in 0..5 { + for _ in 0..6 { app.move_sidebar(1); } assert_eq!(app.task_pane_title(), "Camano"); diff --git a/docs/changelog.d/v1-inbox-view.feature.md b/docs/changelog.d/v1-inbox-view.feature.md new file mode 100644 index 0000000..3683314 --- /dev/null +++ b/docs/changelog.d/v1-inbox-view.feature.md @@ -0,0 +1 @@ +- **Inbox view** (§8.2): a sixth built-in filter view, listed below On Deck, showing every outstanding task with **no project** — the capture inbox to triage and file. A new `ListFilter.unfiled` predicate (kept purely in `matches()`) drives it; the `inbox` `ViewSpec` is deliberately un-gated (no attention or do-date filter) so nothing hides from triage. Appears automatically in the `heph-tui` sidebar and `heph view`; `heph view inbox` from the CLI. -- 2.50.1 (Apple Git-155) From 9c932c8d9a898750275accbcdfcff2f86ccbecf2 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 18:20:10 -0700 Subject: [PATCH 84/91] =?UTF-8?q?feat(tui):=20fzf-style=20move-to-project?= =?UTF-8?q?=20picker=20+=20create-project=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `m` move-to-project overlay is now a filterable picker: a prompt line narrows the project list by fuzzy subsequence match as you type (↑/↓ or Ctrl-n/p move, Enter selects, Esc cancels), so there's no scrolling a long list. When the filter names no existing project, a "+ New project" row creates it and files the task there in one step (Backend::create_project → node.create). Tests cover fuzzy narrowing and the create-then-file flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 168 ++++++++++++++---- crates/heph-tui/src/backend.rs | 8 + crates/heph-tui/src/main.rs | 12 +- crates/heph-tui/src/ui.rs | 48 +++-- crates/heph-tui/tests/navigation.rs | 68 ++++++- .../changelog.d/v1-tui-move-picker.feature.md | 1 + 6 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 docs/changelog.d/v1-tui-move-picker.feature.md diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index d5549aa..d07d1b4 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -125,23 +125,93 @@ pub struct PendingDelete { pub title: String, } -/// One choice in the move-to-project picker: a project (or `None` = unfile). +/// One choice in the move-to-project picker. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct MoveOption { - pub project_id: Option<String>, - pub label: String, +pub enum MoveOption { + /// Remove the task from any project. + Unfile, + /// File under an existing project. + Project { id: String, title: String }, + /// Create a new project named after the filter text, then file under it. + Create { name: String }, } -/// The move-to-project picker state: which task is being re-filed, the choices -/// (an "(Unfile)" entry then every project), and the highlighted row. +impl MoveOption { + /// The row label as shown in the picker. + pub fn label(&self) -> String { + match self { + MoveOption::Unfile => "(Unfile)".to_string(), + MoveOption::Project { title, .. } => title.clone(), + MoveOption::Create { name } => format!("+ New project \"{name}\""), + } + } +} + +/// The move-to-project picker state: which task is being re-filed, a live filter +/// over the projects, the matching choices, and the highlighted row. The picker +/// is fzf-style — typing narrows the list; a non-matching name offers to create. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MoveState { pub task_id: String, pub task_title: String, + /// All projects, title-sorted — the source the `filter` narrows. + projects: Vec<Project>, + /// The live filter query (fzf-style subsequence match). + pub filter: String, + /// The currently visible choices (`(Unfile)` + matching projects + an + /// optional "create" row), recomputed whenever `filter` changes. pub options: Vec<MoveOption>, pub cursor: usize, } +impl MoveState { + /// Rebuild `options` from `projects` + `filter`: `(Unfile)` (when it matches + /// or the filter is empty), the fuzzy-matching projects, and — when the text + /// names no existing project — a "create" row. Clamps the cursor. + fn recompute(&mut self) { + let f = self.filter.trim(); + let mut opts = Vec::new(); + if f.is_empty() || fuzzy_match(f, "Unfile") { + opts.push(MoveOption::Unfile); + } + for p in &self.projects { + if f.is_empty() || fuzzy_match(f, &p.title) { + opts.push(MoveOption::Project { + id: p.id.clone(), + title: p.title.clone(), + }); + } + } + if !f.is_empty() && !self.projects.iter().any(|p| p.title.eq_ignore_ascii_case(f)) { + opts.push(MoveOption::Create { name: f.to_string() }); + } + self.options = opts; + self.cursor = self.cursor.min(self.options.len().saturating_sub(1)); + } +} + +/// fzf-style match: every char of `query` appears in `cand` in order, +/// case-insensitively, ignoring whitespace in the query. Empty query matches all. +fn fuzzy_match(query: &str, cand: &str) -> bool { + let cand: Vec<char> = cand.chars().flat_map(char::to_lowercase).collect(); + let mut ci = 0; + 'next: for qc in query + .chars() + .filter(|c| !c.is_whitespace()) + .flat_map(char::to_lowercase) + { + while ci < cand.len() { + let matched = cand[ci] == qc; + ci += 1; + if matched { + continue 'next; + } + } + return false; + } + true +} + /// The attention cycle for the `A` gesture: default → top-of-mind → consequence /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. pub fn next_attention(current: Option<Attention>) -> Attention { @@ -537,42 +607,55 @@ impl<B: Backend> App<B> { let Some(t) = self.selected_task().cloned() else { return; }; - let mut options = vec![MoveOption { - project_id: None, - label: "(Unfile)".into(), - }]; - for p in self.project_list() { - options.push(MoveOption { - project_id: Some(p.id), - label: p.title, - }); - } - let cursor = t - .project_id - .as_deref() - .and_then(|pid| { - options - .iter() - .position(|o| o.project_id.as_deref() == Some(pid)) - }) - .unwrap_or(0); - self.mode = Mode::MoveToProject(MoveState { + let mut state = MoveState { task_id: t.node_id, task_title: t.title, - options, - cursor, - }); + projects: self.project_list(), + filter: String::new(), + options: Vec::new(), + cursor: 0, + }; + state.recompute(); + // Start on the task's current project, if it has one. + if let Some(pid) = t.project_id.as_deref() { + if let Some(i) = state.options.iter().position(|o| match o { + MoveOption::Project { id, .. } => id.as_str() == pid, + _ => false, + }) { + state.cursor = i; + } + } + self.mode = Mode::MoveToProject(state); } /// Move the picker cursor by `delta` (clamped). pub fn move_picker_move(&mut self, delta: isize) { if let Mode::MoveToProject(m) = &mut self.mode { let max = m.options.len() as isize - 1; - m.cursor = (m.cursor as isize + delta).clamp(0, max) as usize; + m.cursor = (m.cursor as isize + delta).clamp(0, max.max(0)) as usize; } } - /// Apply the highlighted choice: re-file (or unfile) the task and reload. + /// Append to the picker filter (resets the cursor to the top match). + pub fn move_filter_push(&mut self, c: char) { + if let Mode::MoveToProject(m) = &mut self.mode { + m.filter.push(c); + m.cursor = 0; + m.recompute(); + } + } + + /// Delete the last filter char. + pub fn move_filter_backspace(&mut self) { + if let Mode::MoveToProject(m) = &mut self.mode { + m.filter.pop(); + m.cursor = 0; + m.recompute(); + } + } + + /// Apply the highlighted choice: unfile, re-file, or create-then-file, and + /// reload. pub fn move_picker_submit(&mut self) { let Mode::MoveToProject(m) = &self.mode else { return; @@ -581,11 +664,26 @@ impl<B: Backend> App<B> { return; }; let task_id = m.task_id.clone(); - let ok = format!("→ {}: {}", choice.label, m.task_title); + let title = m.task_title.clone(); self.mode = Mode::Normal; - self.mutate(ok, |b| { - b.set_project(&task_id, choice.project_id.as_deref()) - }); + match choice { + MoveOption::Unfile => { + self.mutate(format!("→ (Unfile): {title}"), |b| { + b.set_project(&task_id, None) + }); + } + MoveOption::Project { id, title: pt } => { + self.mutate(format!("→ {pt}: {title}"), move |b| { + b.set_project(&task_id, Some(&id)) + }); + } + MoveOption::Create { name } => { + self.mutate(format!("→ new project \"{name}\": {title}"), move |b| { + let id = b.create_project(&name)?; + b.set_project(&task_id, Some(&id)) + }); + } + } } /// Dismiss the picker without re-filing. diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 03bf5e3..974fa12 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -65,6 +65,8 @@ pub trait Backend { recurrence: Option<&str>, project_id: Option<&str>, ) -> Result<String>; + /// Create a new project node; returns its node id. + fn create_project(&mut self, name: &str) -> Result<String>; } /// The real backend: a thin client of the `hephd` unix socket. @@ -209,4 +211,10 @@ impl Backend for ClientBackend { let task: heph_core::Task = serde_json::from_value(v)?; Ok(task.node_id) } + + fn create_project(&mut self, name: &str) -> Result<String> { + let v = self.call("node.create", json!({ "kind": "project", "title": name }))?; + let node: heph_core::Node = serde_json::from_value(v)?; + Ok(node.id) + } } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b0da4e9..930b9ac 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,13 +119,19 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A return None; } - // The move-to-project picker captures navigation/select/cancel. + // The move-to-project picker is an fzf-style filter: typing narrows the list, + // arrows / Ctrl-n,p move, Enter selects (or creates), Esc cancels. if matches!(app.mode, Mode::MoveToProject(_)) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Esc => app.move_picker_cancel(), KeyCode::Enter => app.move_picker_submit(), - KeyCode::Char('j') | KeyCode::Down => app.move_picker_move(1), - KeyCode::Char('k') | KeyCode::Up => app.move_picker_move(-1), + KeyCode::Down => app.move_picker_move(1), + KeyCode::Up => app.move_picker_move(-1), + KeyCode::Char('n') if ctrl => app.move_picker_move(1), + KeyCode::Char('p') if ctrl => app.move_picker_move(-1), + KeyCode::Backspace => app.move_filter_backspace(), + KeyCode::Char(c) if !ctrl => app.move_filter_push(c), _ => {} } return None; diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 18c6027..dde3ee4 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -13,7 +13,7 @@ use ratatui::{ Frame, }; -use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry, SortMode}; +use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode}; use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; @@ -54,12 +54,15 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) { } } -/// A centered list popup for re-filing the highlighted task to a project. +/// A centered fzf-style popup for re-filing the highlighted task: a filter line +/// on top, the matching projects (+ an optional "create") below. fn render_move(frame: &mut Frame, state: &MoveState) { let area = frame.area(); - let width = area.width.saturating_sub(8).clamp(24, 50); + let width = area.width.saturating_sub(8).clamp(28, 56); let rows = state.options.len() as u16; - let height = (rows + 2).min(area.height.saturating_sub(2)).max(3); + // input box (3) + list box (rows + 2 borders), bounded to the screen. + let list_h = (rows + 2).clamp(3, area.height.saturating_sub(5).max(3)); + let height = (3 + list_h).min(area.height.saturating_sub(2)); let popup = Rect { x: area.x + (area.width.saturating_sub(width)) / 2, y: area.y + area.height.saturating_sub(height) / 3, @@ -67,25 +70,48 @@ fn render_move(frame: &mut Frame, state: &MoveState) { height, }; frame.render_widget(Clear, popup); + let chunks = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(popup); + + // Filter input (with the task being moved in the title). + let input = Paragraph::new(Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::raw(&state.filter), + Span::styled("▏", Style::default().fg(Color::Cyan)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" Move \"{}\" to ", state.task_title)), + ); + frame.render_widget(input, chunks[0]); + + // Matching choices. let items: Vec<ListItem> = state .options .iter() .enumerate() .map(|(i, o)| { - let mut style = Style::default(); - if i == state.cursor { - style = style.fg(Color::Black).bg(Color::Cyan); - } - ListItem::new(Line::from(Span::styled(o.label.clone(), style))) + let base = match o { + MoveOption::Create { .. } => Style::default().fg(Color::Green), + _ => Style::default(), + }; + let style = if i == state.cursor { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + base + }; + ListItem::new(Line::from(Span::styled(o.label(), style))) }) .collect(); let list = List::new(items).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)) - .title(format!(" Move \"{}\" to ", state.task_title)), + .title_bottom(" ↑↓ move · ⏎ select · esc cancel "), ); - frame.render_widget(list, popup); + frame.render_widget(list, chunks[1]); } /// A centered single-line input popup (guided add / reschedule). diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index c79570f..bde0a9b 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -29,6 +29,7 @@ struct Recorder { scheduled: Vec<(String, SchedulePatch)>, tombstoned: Vec<String>, refiled: Vec<(String, Option<String>)>, + created_projects: Vec<String>, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -127,6 +128,10 @@ impl Backend for Fake { )); Ok("new".into()) } + fn create_project(&mut self, name: &str) -> Result<String> { + self.rec.borrow_mut().created_projects.push(name.into()); + Ok(format!("proj:{name}")) + } } fn fixture() -> Fake { @@ -347,10 +352,7 @@ fn move_to_project_picker_refiles_the_selected_task() { Mode::MoveToProject(m) => { assert_eq!(m.task_id, "t1"); assert_eq!( - m.options - .iter() - .map(|o| o.label.as_str()) - .collect::<Vec<_>>(), + m.options.iter().map(|o| o.label()).collect::<Vec<_>>(), vec!["(Unfile)", "Camano"] ); assert_eq!(m.cursor, 0, "t1 has no project → cursor starts at (Unfile)"); @@ -368,6 +370,64 @@ fn move_to_project_picker_refiles_the_selected_task() { assert_eq!(refiled[0], ("t1".into(), Some("p1".into()))); } +#[test] +fn move_picker_fuzzy_filter_narrows_to_matching_projects() { + use heph_tui::app::{Mode, MoveOption}; + let mut app = App::new(fixture()).unwrap(); + app.begin_move(); + // "cm" is a subsequence of "Camano" but not of "Unfile". + app.move_filter_push('c'); + app.move_filter_push('m'); + match &app.mode { + Mode::MoveToProject(m) => { + let projects: Vec<_> = m + .options + .iter() + .filter_map(|o| match o { + MoveOption::Project { title, .. } => Some(title.as_str()), + _ => None, + }) + .collect(); + assert_eq!(projects, vec!["Camano"]); + assert_eq!(m.cursor, 0, "typing resets the cursor to the top match"); + } + _ => panic!("expected the move picker"), + } +} + +#[test] +fn move_picker_creates_a_project_from_the_filter_text() { + use heph_tui::app::{Mode, MoveOption}; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_move(); // on t1 + for c in "Garden".chars() { + app.move_filter_push(c); + } + // No project named "Garden" → a Create row is offered (last). + let create_idx = match &app.mode { + Mode::MoveToProject(m) => { + let i = m + .options + .iter() + .position(|o| matches!(o, MoveOption::Create { name } if name == "Garden")); + i.expect("a create row is offered") + } + _ => panic!("expected the move picker"), + }; + // Highlight the create row and submit. + app.move_picker_move(create_idx as isize); + app.move_picker_submit(); + + assert_eq!(rec.borrow().created_projects, vec!["Garden".to_string()]); + let refiled = &rec.borrow().refiled; + assert_eq!(refiled.last().unwrap().0, "t1"); + assert_eq!(refiled.last().unwrap().1.as_deref(), Some("proj:Garden")); +} + #[test] fn toggle_sort_switches_mode_and_regroups_by_project() { use heph_tui::app::SortMode; diff --git a/docs/changelog.d/v1-tui-move-picker.feature.md b/docs/changelog.d/v1-tui-move-picker.feature.md new file mode 100644 index 0000000..9d75e0a --- /dev/null +++ b/docs/changelog.d/v1-tui-move-picker.feature.md @@ -0,0 +1 @@ +- `heph-tui` move-to-project picker (`m`) is now an **fzf-style filter** (§8.1): a prompt line on top narrows the project list by fuzzy subsequence match as you type (↑/↓ or Ctrl-n/p move, Enter selects, Esc cancels) — no more scrolling the full list. When the typed text names no existing project, a **"+ New project"** row is offered: selecting it **creates the project and files the task under it** in one step (via `node.create`), so projects can be created without leaving the TUI. -- 2.50.1 (Apple Git-155) From 9511f6a00945e6fc0358848393a97a7f1f9e09bf Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 18:37:16 -0700 Subject: [PATCH 85/91] =?UTF-8?q?feat(tui):=20pane-specific=20keys,=20undo?= =?UTF-8?q?/redo,=20project=20delete,=20sidebar=20refresh=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage gestures (x/d/S/A/b/e/m/D) now fire only when the task pane is focused, so a stray key while in the sidebar can't drop or delete a task; hints are focus-aware. `u` undoes the last triage action (drop/done/skip/ attention/move) and Ctrl-z redoes it, restoring from a pre-action snapshot (multi-level, cap 200; tombstone-delete excluded — no restore path yet). `D` in the sidebar deletes the highlighted project (y/N), unfiling its tasks to the Inbox. The sidebar's Projects section now rebuilds after create/delete, so a new project appears without a restart. Tests cover undo/redo, the empty-undo no-op, and sidebar project delete. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-tui/src/app.rs | 236 ++++++++++++++++-- crates/heph-tui/src/main.rs | 35 ++- crates/heph-tui/src/ui.rs | 11 +- crates/heph-tui/tests/navigation.rs | 61 ++++- .../changelog.d/v1-tui-undo-panels.feature.md | 5 + 5 files changed, 320 insertions(+), 28 deletions(-) create mode 100644 docs/changelog.d/v1-tui-undo-panels.feature.md diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index d07d1b4..2325278 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use anyhow::Result; use chrono::NaiveDate; -use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; use crate::backend::{Backend, Project, SearchHit}; use crate::fmt::{days_overdue, today_local}; @@ -118,13 +118,81 @@ pub struct SearchView { pub cursor: usize, } -/// A pending delete awaiting y/N confirmation (the most destructive gesture). +/// A pending delete awaiting y/N confirmation (the most destructive gesture) — +/// either a task (from the task pane) or a project (from the sidebar). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PendingDelete { - pub task_id: String, - pub title: String, +pub enum PendingDelete { + Task { task_id: String, title: String }, + Project { project_id: String, title: String }, } +impl PendingDelete { + /// The name shown in the confirmation prompt. + pub fn title(&self) -> &str { + match self { + PendingDelete::Task { title, .. } | PendingDelete::Project { title, .. } => title, + } + } + /// "task" or "project", for the prompt. + pub fn noun(&self) -> &str { + match self { + PendingDelete::Task { .. } => "task", + PendingDelete::Project { .. } => "project", + } + } +} + +/// A snapshot of a task's reversible fields, captured before a triage action so +/// `u`/Ctrl-z can undo/redo it. Taken from the visible [`RankedTask`], so no +/// extra daemon read is needed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskSnapshot { + task_id: String, + title: String, + state: TaskState, + attention: Option<Attention>, + do_date: Option<i64>, + late_on: Option<i64>, + recurrence: Option<String>, + project_id: Option<String>, +} + +impl From<&RankedTask> for TaskSnapshot { + fn from(t: &RankedTask) -> Self { + Self { + task_id: t.node_id.clone(), + title: t.title.clone(), + state: t.state, + attention: t.attention, + do_date: t.do_date, + late_on: t.late_on, + recurrence: t.recurrence.clone(), + project_id: t.project_id.clone(), + } + } +} + +/// The original triage action, kept alongside its before-snapshot so redo can +/// re-apply it without re-reading state. (Delete/tombstone is *not* here — it has +/// no restore path yet, so it is excluded from undo and guarded by its y/N prompt.) +#[derive(Debug, Clone, PartialEq, Eq)] +enum TriageAction { + State(&'static str), // "done" | "dropped" + Skip, + Attention(Attention), + Move(Option<String>), // re-file (or unfile) to a project id +} + +/// One reversible step: the task state before it + the action that changed it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct UndoEntry { + before: TaskSnapshot, + action: TriageAction, +} + +/// Cap on the undo history, so a long session can't grow it unbounded. +const UNDO_CAP: usize = 200; + /// One choice in the move-to-project picker. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MoveOption { @@ -154,6 +222,8 @@ impl MoveOption { pub struct MoveState { pub task_id: String, pub task_title: String, + /// The task's state before the move, for undo. + before: TaskSnapshot, /// All projects, title-sorted — the source the `filter` narrows. projects: Vec<Project>, /// The live filter query (fzf-style subsequence match). @@ -278,6 +348,9 @@ pub struct App<B: Backend> { pub search: Option<SearchView>, /// When `Some`, a delete is awaiting y/N confirmation. pub pending_delete: Option<PendingDelete>, + /// Reversible triage history (`u` undoes, Ctrl-z redoes). + undo_stack: Vec<UndoEntry>, + redo_stack: Vec<UndoEntry>, pub status: String, pub should_quit: bool, } @@ -317,6 +390,8 @@ impl<B: Backend> App<B> { sort_mode: SortMode::Default, search: None, pending_delete: None, + undo_stack: Vec::new(), + redo_stack: Vec::new(), status: String::new(), should_quit: false, }; @@ -529,6 +604,7 @@ impl<B: Backend> App<B> { let Some(t) = self.selected_task().cloned() else { return; }; + self.push_undo((&t).into(), TriageAction::State("done")); self.mutate(format!("done: {}", t.title), |b| { b.set_state(&t.node_id, "done") }); @@ -539,7 +615,8 @@ impl<B: Backend> App<B> { let Some(t) = self.selected_task().cloned() else { return; }; - self.mutate(format!("dropped: {}", t.title), |b| { + self.push_undo((&t).into(), TriageAction::State("dropped")); + self.mutate(format!("dropped: {} (u to undo)", t.title), |b| { b.set_state(&t.node_id, "dropped") }); } @@ -549,6 +626,7 @@ impl<B: Backend> App<B> { let Some(t) = self.selected_task().cloned() else { return; }; + self.push_undo((&t).into(), TriageAction::Skip); self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id)); } @@ -558,6 +636,7 @@ impl<B: Backend> App<B> { return; }; let next = next_attention(t.attention); + self.push_undo((&t).into(), TriageAction::Attention(next)); self.mutate(format!("{}: {}", next.as_str(), t.title), |b| { b.set_attention(&t.node_id, next) }); @@ -568,27 +647,118 @@ impl<B: Backend> App<B> { let Some(t) = self.selected_task().cloned() else { return; }; + self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue)); self.mutate(format!("→ on deck: {}", t.title), |b| { b.set_attention(&t.node_id, Attention::Blue) }); } + // --- undo / redo (`u` / Ctrl-z) --- + + /// Record a reversible step and invalidate the redo stack. + fn push_undo(&mut self, before: TaskSnapshot, action: TriageAction) { + self.undo_stack.push(UndoEntry { before, action }); + if self.undo_stack.len() > UNDO_CAP { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + + /// Undo the last triage action (restores the task's prior state). + pub fn undo(&mut self) { + let Some(entry) = self.undo_stack.pop() else { + self.status = "nothing to undo".into(); + return; + }; + self.restore(&entry.before, format!("undo: {}", entry.before.title)); + self.redo_stack.push(entry); + } + + /// Redo the last undone action (re-applies it). + pub fn redo(&mut self) { + let Some(entry) = self.redo_stack.pop() else { + self.status = "nothing to redo".into(); + return; + }; + let status = format!("redo: {}", entry.before.title); + self.apply_action(entry.before.task_id.clone(), entry.action.clone(), status); + self.undo_stack.push(entry); + } + + /// Restore a task to a captured snapshot (state + schedule + attention + + /// project). Note: an attention of `None` can't be re-cleared (no backend + /// path), so it's left as-is in that rare case. + fn restore(&mut self, snap: &TaskSnapshot, status: String) { + let id = snap.task_id.clone(); + let state = snap.state.as_str().to_string(); + let (do_date, late_on, recurrence) = (snap.do_date, snap.late_on, snap.recurrence.clone()); + let attention = snap.attention; + let project = snap.project_id.clone(); + self.mutate(status, move |b| { + b.set_state(&id, &state)?; + b.set_schedule( + &id, + SchedulePatch { + do_date: Some(do_date), + late_on: Some(late_on), + recurrence: Some(recurrence), + }, + )?; + if let Some(a) = attention { + b.set_attention(&id, a)?; + } + b.set_project(&id, project.as_deref()) + }); + } + + /// Re-apply a triage action to `id` (used by redo). + fn apply_action(&mut self, id: String, action: TriageAction, status: String) { + match action { + TriageAction::State(s) => self.mutate(status, move |b| b.set_state(&id, s)), + TriageAction::Skip => self.mutate(status, move |b| b.skip(&id)), + TriageAction::Attention(a) => self.mutate(status, move |b| b.set_attention(&id, a)), + TriageAction::Move(p) => self.mutate(status, move |b| b.set_project(&id, p.as_deref())), + } + } + /// Arm a delete on the highlighted task (awaits y/N confirmation). pub fn begin_delete(&mut self) { if let Some(t) = self.selected_task() { - self.pending_delete = Some(PendingDelete { + self.pending_delete = Some(PendingDelete::Task { task_id: t.node_id.clone(), title: t.title.clone(), }); } } - /// Confirm the armed delete: tombstone the task and reload. + /// Arm a delete on the highlighted **project** (sidebar). Tasks filed under it + /// become unfiled (they move to the Inbox), not deleted. + pub fn begin_delete_project(&mut self) { + match self.sidebar.get(self.sidebar_cursor) { + Some(SidebarEntry::Project { id, title }) => { + self.pending_delete = Some(PendingDelete::Project { + project_id: id.clone(), + title: title.clone(), + }); + } + _ => self.status = "select a project in the sidebar to delete".into(), + } + } + + /// Confirm the armed delete: tombstone the task or project and reload. pub fn confirm_delete(&mut self) { - if let Some(pd) = self.pending_delete.take() { - self.mutate(format!("deleted: {}", pd.title), |b| { - b.tombstone(&pd.task_id) - }); + match self.pending_delete.take() { + Some(PendingDelete::Task { task_id, title }) => { + self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id)); + } + Some(PendingDelete::Project { project_id, title }) => { + self.mutate(format!("deleted project: {title}"), |b| { + b.tombstone(&project_id) + }); + self.rebuild_projects(); + self.reload(); + } + None => {} } } @@ -608,8 +778,9 @@ impl<B: Backend> App<B> { return; }; let mut state = MoveState { - task_id: t.node_id, - task_title: t.title, + task_id: t.node_id.clone(), + task_title: t.title.clone(), + before: TaskSnapshot::from(&t), projects: self.project_list(), filter: String::new(), options: Vec::new(), @@ -665,27 +836,64 @@ impl<B: Backend> App<B> { }; let task_id = m.task_id.clone(); let title = m.task_title.clone(); + let before = m.before.clone(); self.mode = Mode::Normal; match choice { MoveOption::Unfile => { + self.push_undo(before, TriageAction::Move(None)); self.mutate(format!("→ (Unfile): {title}"), |b| { b.set_project(&task_id, None) }); } MoveOption::Project { id, title: pt } => { + self.push_undo(before, TriageAction::Move(Some(id.clone()))); self.mutate(format!("→ {pt}: {title}"), move |b| { b.set_project(&task_id, Some(&id)) }); } MoveOption::Create { name } => { + // Creating + filing is constructive; not added to the undo history + // (we'd have to track the new project's id to replay it). self.mutate(format!("→ new project \"{name}\": {title}"), move |b| { let id = b.create_project(&name)?; b.set_project(&task_id, Some(&id)) }); + self.rebuild_projects(); } } } + /// Refetch the project list and rebuild the sidebar's Projects section, + /// keeping the cursor on the same entry when it still exists. Call after any + /// project create/delete so the sidebar reflects reality without a restart. + pub fn rebuild_projects(&mut self) { + let selected = self.sidebar.get(self.sidebar_cursor).cloned(); + let mut rebuilt: Vec<SidebarEntry> = self + .sidebar + .iter() + .filter(|e| !matches!(e, SidebarEntry::Project { .. })) + .cloned() + .collect(); + if let Ok(projects) = self.backend.projects() { + for Project { id, title } in projects { + rebuilt.push(SidebarEntry::Project { id, title }); + } + } + self.sidebar = rebuilt; + // Restore the cursor: same entry if present, else the nearest selectable + // at/above the old index (a deleted project lands you on its header/prev). + self.sidebar_cursor = selected + .as_ref() + .and_then(|sel| self.sidebar.iter().position(|e| e == sel)) + .unwrap_or_else(|| { + let idx = self.sidebar_cursor.min(self.sidebar.len().saturating_sub(1)); + (0..=idx) + .rev() + .find(|&i| self.sidebar[i].selectable()) + .unwrap_or(0) + }); + } + /// Dismiss the picker without re-filing. pub fn move_picker_cancel(&mut self) { self.mode = Mode::Normal; diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 930b9ac..6d8b85a 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -154,6 +154,9 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A // Any other keypress clears a stale status message. app.status.clear(); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + // Keys that work regardless of which pane has focus. match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('r') => app.reload(), @@ -164,20 +167,30 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(), // Enter: drill sidebar→tasks, or open the selected task's context in nvim. KeyCode::Enter => return app.enter().map(Action::EditContext), - // capture + reschedule + search (open an input prompt) KeyCode::Char('a') => app.begin_add(), - KeyCode::Char('e') => app.begin_reschedule(), KeyCode::Char('/') => app.begin_search(), - // triage mutations (act on the highlighted task) - KeyCode::Char('x') => app.complete_selected(), - KeyCode::Char('d') => app.drop_selected(), - KeyCode::Char('S') => app.skip_selected(), KeyCode::Char('s') => app.toggle_sort(), - KeyCode::Char('A') => app.cycle_attention_selected(), - KeyCode::Char('b') => app.push_to_blue_selected(), - KeyCode::Char('m') => app.begin_move(), - KeyCode::Char('D') => app.begin_delete(), - _ => {} + KeyCode::Char('u') => app.undo(), + KeyCode::Char('z') if ctrl => app.redo(), + // Pane-specific keys: triage acts on the task pane; the sidebar gets + // project actions — so a stray `d`/`D` in the sidebar can't touch a task. + _ => match app.focus { + Focus::Tasks => match key.code { + KeyCode::Char('x') => app.complete_selected(), + KeyCode::Char('d') => app.drop_selected(), + KeyCode::Char('S') => app.skip_selected(), + KeyCode::Char('A') => app.cycle_attention_selected(), + KeyCode::Char('b') => app.push_to_blue_selected(), + KeyCode::Char('e') => app.begin_reschedule(), + KeyCode::Char('m') => app.begin_move(), + KeyCode::Char('D') => app.begin_delete(), + _ => {} + }, + Focus::Sidebar => match key.code { + KeyCode::Char('D') => app.begin_delete_project(), + _ => {} + }, + }, } None } diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index dde3ee4..75f669b 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -17,8 +17,13 @@ use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEnt use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; +// Task-pane gestures (the focused pane shows its own hints, §8.1). const HINTS: &str = - " j/k move ⏎ edit a add x done S skip e date A attn b→blue m move D del s sort / search q quit"; + " j/k move ⏎ edit x done d drop S skip e date A attn b→blue m move D del u undo / search q quit"; + +// Sidebar gestures: navigation + per-project actions (no task triage here). +const SIDEBAR_HINTS: &str = + " j/k move ⏎ open a add D del-project u undo s sort / search Tab tasks q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; @@ -450,7 +455,7 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { if let Some(pd) = &app.pending_delete { frame.render_widget( Paragraph::new(Line::from(Span::styled( - format!(" Delete \"{}\"? (y / N)", pd.title), + format!(" Delete {} \"{}\"? (y / N)", pd.noun(), pd.title()), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ))), area, @@ -459,6 +464,8 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) { } let hints = if app.search.is_some() { SEARCH_HINTS + } else if app.focus == Focus::Sidebar { + SIDEBAR_HINTS } else { HINTS }; diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index bde0a9b..a6f3842 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -30,6 +30,7 @@ struct Recorder { tombstoned: Vec<String>, refiled: Vec<(String, Option<String>)>, created_projects: Vec<String>, + states: Vec<(String, String)>, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -87,7 +88,8 @@ impl Backend for Fake { fn context_of(&mut self, task_id: &str) -> Result<Option<String>> { Ok(self.contexts.get(task_id).cloned()) } - fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> { + fn set_state(&mut self, t: &str, s: &str) -> Result<()> { + self.rec.borrow_mut().states.push((t.into(), s.into())); Ok(()) } fn skip(&mut self, _t: &str) -> Result<()> { @@ -428,6 +430,63 @@ fn move_picker_creates_a_project_from_the_filter_text() { assert_eq!(refiled.last().unwrap().1.as_deref(), Some("proj:Garden")); } +#[test] +fn undo_restores_a_dropped_task_and_redo_redrops_it() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + app.focus_tasks(); + + app.drop_selected(); // drops t1 + app.undo(); // restores it to outstanding + { + let states = rec.borrow(); + assert_eq!(states.states.first().unwrap(), &("t1".into(), "dropped".into())); + assert_eq!( + states.states.last().unwrap(), + &("t1".into(), "outstanding".into()), + "undo restores the prior (outstanding) state" + ); + } + + app.redo(); // re-drops it + assert_eq!( + rec.borrow().states.last().unwrap(), + &("t1".into(), "dropped".into()) + ); +} + +#[test] +fn undo_with_empty_history_is_a_noop() { + let mut app = App::new(fixture()).unwrap(); + app.undo(); + assert_eq!(app.status, "nothing to undo"); +} + +#[test] +fn delete_project_from_sidebar_tombstones_the_project_node() { + use heph_tui::app::PendingDelete; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + // Step the sidebar to the Camano project (6 views + header). + for _ in 0..6 { + app.move_sidebar(1); + } + assert_eq!(app.task_pane_title(), "Camano"); + + app.begin_delete_project(); + assert!(matches!( + app.pending_delete, + Some(PendingDelete::Project { .. }) + )); + app.confirm_delete(); + assert_eq!(rec.borrow().tombstoned, vec!["p1".to_string()]); +} + #[test] fn toggle_sort_switches_mode_and_regroups_by_project() { use heph_tui::app::SortMode; diff --git a/docs/changelog.d/v1-tui-undo-panels.feature.md b/docs/changelog.d/v1-tui-undo-panels.feature.md new file mode 100644 index 0000000..0d6b0e6 --- /dev/null +++ b/docs/changelog.d/v1-tui-undo-panels.feature.md @@ -0,0 +1,5 @@ +- `heph-tui` safety + undo wave (§8.1): + - **Pane-specific keys.** Task-triage gestures (`x`/`d`/`S`/`A`/`b`/`e`/`m`/`D`) now fire **only when the task pane is focused**, so a stray keypress while navigating the sidebar can no longer drop or delete a task. The sidebar gets its own actions; the status-line hints are now focus-aware. + - **Undo / redo.** **`u`** undoes the last triage action (drop, done, skip, attention, move) and **Ctrl-z** redoes it, restoring the task's prior state from a snapshot — multi-level, capped at 200 steps. (Tombstone-delete stays excluded — it keeps its y/N prompt — and an attention of "none" can't be re-cleared.) + - **Delete a project** from the sidebar with **`D`** (y/N confirm); its tasks become unfiled (they move to the Inbox), not deleted. + - **Sidebar auto-refreshes** after creating or deleting a project, so a new project shows up immediately (no more quit-and-reload). -- 2.50.1 (Apple Git-155) From dd5ef7dc637e078eb9d283feea47fbf73e8fe133 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 19:15:54 -0700 Subject: [PATCH 86/91] =?UTF-8?q?fix:=20deleting=20a=20project=20unfiles?= =?UTF-8?q?=20its=20tasks=20to=20the=20Inbox=20(=C2=A78.1/=C2=A78.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project delete previously tombstoned only the project node, leaving its tasks with a live in-project link to a dead project — orphaned (not in the Inbox, unbrowsable, blank project) rather than unfiled as intended. New atomic Store::delete_project tombstones every in-project link to the project (tasks fall to the Inbox), then tombstones the project node; tasks are never deleted. Exposed as the project.delete RPC (LocalStore + RemoteStore); the heph-tui sidebar `D` now routes through it. Core test asserts the task survives and becomes unfiled; the project node is tombstoned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-core/src/sqlite/mod.rs | 61 ++++++++++++++++++++++++++++ crates/heph-core/src/sqlite/tasks.rs | 28 +++++++++++++ crates/heph-core/src/store.rs | 4 ++ crates/heph-tui/src/app.rs | 4 +- crates/heph-tui/src/backend.rs | 7 ++++ crates/heph-tui/tests/navigation.rs | 4 ++ crates/hephd/src/remote.rs | 5 +++ crates/hephd/src/rpc.rs | 5 +++ 8 files changed, 116 insertions(+), 2 deletions(-) diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index c4ec1b1..d4b93f1 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -243,6 +243,11 @@ impl Store for LocalStore { tasks::set_project(&mut self.conn, &self.owner_id, now, node_id, project_id) } + fn delete_project(&mut self, project_id: &str) -> Result<()> { + let now = self.clock.now_ms(); + tasks::delete_project(&mut self.conn, &self.owner_id, now, project_id) + } + fn promote( &mut self, container_id: &str, @@ -465,6 +470,62 @@ mod tests { assert_eq!(v, latest_version()); } + #[test] + fn delete_project_unfiles_its_tasks_then_tombstones_it() { + use crate::filter::ListFilter; + use crate::model::{NewNode, NewTask, NodeKind}; + let mut store = store_at(1); + + let proj = store + .create_node(NewNode { + kind: NodeKind::Project, + title: "Garden".into(), + body: None, + }) + .unwrap(); + let task = store + .create_task(NewTask { + title: "Weed the beds".into(), + attention: None, + do_date: None, + late_on: None, + recurrence: None, + project_id: None, + }) + .unwrap(); + store + .set_task_project(&task.node_id, Some(&proj.id)) + .unwrap(); + + // Filed under the project before deletion. + let filed = store + .list(&ListFilter { + scope: vec![proj.id.clone()], + ..Default::default() + }) + .unwrap(); + assert_eq!(filed.len(), 1); + + store.delete_project(&proj.id).unwrap(); + + // The project node is tombstoned… + let ts: i64 = store + .conn + .query_row("SELECT tombstoned FROM nodes WHERE id=?1", [&proj.id], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(ts, 1); + // …and the task survives, now unfiled (it shows in the Inbox), not orphaned. + let inbox = store + .list(&ListFilter { + unfiled: true, + ..Default::default() + }) + .unwrap(); + assert!(inbox.iter().any(|t| t.node_id == task.node_id)); + } + #[test] fn resolve_node_matches_exact_title_not_fuzzy() { use crate::model::NewNode; diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 22bfdaa..1d5e9c7 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -570,6 +570,34 @@ pub(super) fn set_project( require(conn, node_id) } +/// Delete a project: **unfile every task currently filed under it** (tombstone +/// the `in-project` links, so those tasks fall to the Inbox), then tombstone the +/// project node itself — atomically. Tasks are preserved, never deleted. +pub(super) fn delete_project( + conn: &mut Connection, + owner: &str, + now: i64, + project_id: &str, +) -> Result<()> { + let project = + nodes::get(conn, project_id)?.ok_or_else(|| Error::NodeNotFound(project_id.into()))?; + if project.kind != NodeKind::Project { + return Err(Error::InvalidArg(format!( + "{project_id} is not a project node" + ))); + } + + let tx = conn.transaction()?; + for link in links::backlinks(&tx, project_id)? { + if link.link_type == LinkType::InProject { + links::tombstone(&tx, owner, now, &link.id)?; + } + } + nodes::tombstone(&tx, owner, now, project_id)?; + tx.commit()?; + Ok(()) +} + /// Apply a partial schedule update (do-date / late-on / recurrence) — the /// "reschedule" path (tech-spec §6). Reads the current row, overlays the /// present `patch` fields (a double-option per field: absent = leave, `null` = diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 1ef715c..a582abd 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -94,6 +94,10 @@ pub trait Store { /// A given `project_id` must name a live `project`-kind node. fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task>; + /// Delete a project: unfile its tasks (they fall to the Inbox) and tombstone + /// the project node. Tasks are preserved, never deleted. + fn delete_project(&mut self, project_id: &str) -> Result<()>; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a /// committed task, rewriting that source line into a `[[link]]` to the new /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 2325278..38bd38d 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -752,8 +752,8 @@ impl<B: Backend> App<B> { self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id)); } Some(PendingDelete::Project { project_id, title }) => { - self.mutate(format!("deleted project: {title}"), |b| { - b.tombstone(&project_id) + self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| { + b.delete_project(&project_id) }); self.rebuild_projects(); self.reload(); diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 974fa12..784c520 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -67,6 +67,8 @@ pub trait Backend { ) -> Result<String>; /// Create a new project node; returns its node id. fn create_project(&mut self, name: &str) -> Result<String>; + /// Delete a project: unfile its tasks (→ Inbox), then tombstone the project. + fn delete_project(&mut self, project_id: &str) -> Result<()>; } /// The real backend: a thin client of the `hephd` unix socket. @@ -217,4 +219,9 @@ impl Backend for ClientBackend { let node: heph_core::Node = serde_json::from_value(v)?; Ok(node.id) } + + fn delete_project(&mut self, project_id: &str) -> Result<()> { + self.call("project.delete", json!({ "id": project_id }))?; + Ok(()) + } } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index a6f3842..6b79fd2 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -134,6 +134,10 @@ impl Backend for Fake { self.rec.borrow_mut().created_projects.push(name.into()); Ok(format!("proj:{name}")) } + fn delete_project(&mut self, project_id: &str) -> Result<()> { + self.rec.borrow_mut().tombstoned.push(project_id.into()); + Ok(()) + } } fn fixture() -> Fake { diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index be74c2a..55b12b8 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -133,6 +133,11 @@ impl Store for RemoteStore { self.call("node.tombstone", json!({ "id": id })).map(|_| ()) } + fn delete_project(&mut self, project_id: &str) -> Result<()> { + self.call("project.delete", json!({ "id": project_id })) + .map(|_| ()) + } + fn resolve_node(&self, title: &str) -> Result<Option<Node>> { self.call_as("node.resolve", json!({ "title": title })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 7a91008..1b7a947 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -369,6 +369,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va let p: SetProjectParams = parse(params)?; json!(store.set_task_project(&p.id, p.project_id.as_deref())?) } + "project.delete" => { + let p: IdParam = parse(params)?; + store.delete_project(&p.id)?; + Value::Null + } "task.skip" => { let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?) -- 2.50.1 (Apple Git-155) From 6514296b870e33241fb72d3187a2d32f37a65124 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 20:19:35 -0700 Subject: [PATCH 87/91] docs: reframe tech-spec as historical; heph self-hosts its roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 reached Todoist feature-parity, so remaining/future work is now tracked in heph itself — tasks in the Hephaestus project (heph view ondeck) — not in a doc. Renamed docs/reference/tech-spec.md -> v1-prototype-tech-spec.md and rewrote all 27 [[tech-spec]] wiki-links + README/changelog path refs (docs checks green). Retitled + bannered the spec as a historical v1 build record and froze its §14 tracker. AGENTS.md gains a "Planning future work" section (capture via `heph task --project Hephaestus`, triage in heph-tui On Deck); README status reflects parity + the three daily-driver surfaces. The design doc remains the living rationale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- AGENTS.md | 15 ++++++-- README.md | 8 +++-- docs/changelog.d/+project-design.doc.md | 2 +- docs/changelog.d/v1-selfhost-roadmap.doc.md | 1 + docs/explanation/design.md | 34 +++++++++---------- docs/explanation/task-lifecycle.md | 8 ++--- docs/how-to/install-heph.md | 2 +- docs/reference/heph-nvim.md | 2 +- docs/reference/reference.md | 2 +- ...tech-spec.md => v1-prototype-tech-spec.md} | 10 ++++-- 10 files changed, 50 insertions(+), 34 deletions(-) create mode 100644 docs/changelog.d/v1-selfhost-roadmap.doc.md rename docs/reference/{tech-spec.md => v1-prototype-tech-spec.md} (98%) diff --git a/AGENTS.md b/AGENTS.md index 6841801..724eb15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ See [[agent-change-process]] for the full methodology. ## Project Structure -A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. The build follows the tech-spec §11.1 slice order; the **Rust backend is feature-complete** (all three runtime modes + sync + OIDC auth) and **`heph.nvim` is built and installed** (knowledge base + tasks + a plug-and-play managed daemon). Remaining v1 work is task-scheduling UX + polish (the live tracker is **[[tech-spec]] §14**). +A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. **v1 reached Todoist feature-parity on 2026-06-03** — the **Rust backend is feature-complete** (all three runtime modes + sync + OIDC auth) and all three surfaces (`heph` CLI, **`heph-tui`**, **`heph.nvim`**) are installed daily-drivers. **Remaining/future work is now tracked in heph itself** (see *Planning* below), not in a doc; the **[[v1-prototype-tech-spec]]** is the historical build record. ``` ./Cargo.toml # workspace manifest (shared deps + members) @@ -50,7 +50,7 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too ./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc + OIDC auth ./crates/heph/ # CLI (thin client of hephd): next/task/doc/get/export/search/journal/auth ./heph.nvim/ # Neovim plugin: primary surface; replaces obsidian.nvim (Lua + headless e2e) -./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content +./docs/ # Diataxis docs (incl. [[design]] + [[v1-prototype-tech-spec]]), Quartz config, release content ./docs/changelog.d/ # towncrier fragments for noteworthy changes ./.dagger/ # Dagger module (src/hephaestus_ci/) backing docs builds and releases ./.forgejo/workflows/ # build + release workflows @@ -58,6 +58,15 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too ./mise-tasks/ # repo automation via `mise run` ``` -**Development is TDD** (tech-spec §2, §9): failing test first, implement to green, commit on green. `heph-core` is clock-injected — no ambient wall-clock reads; time is always passed in. Canonical spec is [[tech-spec]]; rationale is [[design]]. +**Development is TDD** (v1-prototype-tech-spec §2, §9): failing test first, implement to green, commit on green. `heph-core` is clock-injected — no ambient wall-clock reads; time is always passed in. The **historical v1 build spec** is [[v1-prototype-tech-spec]]; the **living rationale/decisions** are [[design]]. Other doc paths are listed via `mise run ai-docs`. Wiki-links (`[[like-this]]`) refer to `docs/` cards. + +## Planning future work (heph self-hosts its roadmap) + +Since v1 parity, **heph tracks its own remaining/future work as tasks in the `Hephaestus` project inside the live store** (the "bootstrap lift" — heph plans heph). This replaces the old [[v1-prototype-tech-spec]] §14 tracker, which is now a historical build record. + +- **See the roadmap:** `heph view ondeck` (CLI) or `heph-tui` → the **On Deck** sidebar view (the backlog lives as blue/on-deck tasks); open the **Hephaestus** project in the sidebar to see all of it. +- **Capture new work:** `heph task "<title>" --project Hephaestus -a blue` (blue = on-deck backlog, kept out of `next`/ToM until pulled up). Add detail in the task's canonical-context doc via `heph.nvim`. +- **Don't reopen the §14 tracker** for new work — add a task instead. Update the spec only to correct historical record. +- Note the [[design]] doc remains the **living** design/decision log; the tech-spec is frozen as the v1 build description. diff --git a/README.md b/README.md index 3357640..9fcd95a 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ It is **offline-first**: fully useful on a laptop with no network, and (when the > **Why "what is next?" is the flagship.** heph is organized around concise, honest answers to recurring questions — above all *"what do I do right now?"* — built from ~10 years of the owner's lived prioritization discipline: projects-as-contexts; attention colors (white/orange/red/blue, where **red = a consequence exists if late**, not importance); **do-dates, not due-dates** (earliest-actionable, never a deadline alarm); and working-set tensions surfaced honestly (no "fake happy", no overwhelm). -See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision and rationale, and **[docs/reference/tech-spec.md](docs/reference/tech-spec.md)** for the implementation-facing specification. +See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision and rationale, and **[docs/reference/v1-prototype-tech-spec.md](docs/reference/v1-prototype-tech-spec.md)** for the implementation-facing specification. ## Status -**Phase 1 (v1 prototype) — nearly feature-complete** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and op exchange is authenticated end-to-end with OIDC** (Authentik): the hub verifies bearer tokens (JWKS/RS256) and enforces single-tenant ownership, and `heph auth login` runs the device-code flow, caching tokens in the OS keyring. The offline-first everyday config (`local` + `hub_url`) converges with a `yrs` text-CRDT merging bodies. The **`heph.nvim` plugin is built and installed on the dev machine** — knowledge base (journals, wiki-links with follow-or-create, a home/index page, a dailies picker), tasks (capture, the "what is next?" view, interactive list, promotion), and a plug-and-play self-healing daemon. CI is green, fully through Dagger. Built test-first (117 Rust + 17 nvim e2e). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — at Todoist feature-parity (2026-06-03)** on branch `feature/v1-prototype`. The Rust backend is feature-complete and **all three surfaces are installed daily-drivers**: the **`heph` CLI** (capture/scripting + the complete daemon API), **`heph-tui`** (the agenda/triage surface — attention-colored views, fzf move-to-project, undo/redo, an Inbox), and **`heph.nvim`** (the context/knowledge base — journals, wiki-links by id with conceal, a `[[` picker, YAML-frontmatter task editing). A global **⌘' quick-capture** popover (`heph-quickadd`) rounds out capture. Under the hood: **all three runtime modes work, replicas sync through a hub over HTTP, authenticated end-to-end with OIDC** (Authentik; JWKS/RS256, single-tenant), the offline-first config (`local` + `hub_url`) converges with a `yrs` text-CRDT, and the daemon runs as an OS service. CI is green, fully through Dagger; built test-first. + +**heph now self-hosts its roadmap.** With parity reached, remaining and future work is tracked **in heph itself** — as tasks in the **`Hephaestus` project** (`heph view ondeck`, or the *On Deck* view in `heph-tui`) — rather than in a document. The [v1-prototype tech-spec](docs/reference/v1-prototype-tech-spec.md) is now the historical build record (its §14 tracker); the [design doc](docs/explanation/design.md) remains the living rationale. | Area | State | |---|---| @@ -90,7 +92,7 @@ mise run ai-docs # docs AI agents read firs ./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc ./crates/heph/ # CLI: thin client of the daemon ./heph.nvim/ # Neovim plugin (primary surface) — Lua, headless e2e harness -./docs/ # Diataxis docs (design, tech-spec, how-to), Quartz config +./docs/ # Diataxis docs (design, v1-prototype-tech-spec, how-to), Quartz config ./.forgejo/ # CI build + release workflows and hooks ./.dagger/ # Dagger module backing docs builds/releases ./mise-tasks/ # repo automation via `mise run` diff --git a/docs/changelog.d/+project-design.doc.md b/docs/changelog.d/+project-design.doc.md index 75cb3fe..b790809 100644 --- a/docs/changelog.d/+project-design.doc.md +++ b/docs/changelog.d/+project-design.doc.md @@ -1 +1 @@ -Add the project design document (`docs/explanation/design.md`, rationale + decision history) and the distilled technical specification (`docs/reference/tech-spec.md`, the build artifact) defining hephaestus as a unified, self-hosted, **client/server + offline-first** task + knowledge-base system: typed node graph, the lived priority discipline ("what is next?"), recurrence with fresh-per-occurrence checklists, op-log/CRDT sync with conflict resolution, OIDC/Authentik auth, the heph.nvim surface, and a test-driven development strategy. +Add the project design document (`docs/explanation/design.md`, rationale + decision history) and the distilled technical specification (`docs/reference/v1-prototype-tech-spec.md`, the build artifact) defining hephaestus as a unified, self-hosted, **client/server + offline-first** task + knowledge-base system: typed node graph, the lived priority discipline ("what is next?"), recurrence with fresh-per-occurrence checklists, op-log/CRDT sync with conflict resolution, OIDC/Authentik auth, the heph.nvim surface, and a test-driven development strategy. diff --git a/docs/changelog.d/v1-selfhost-roadmap.doc.md b/docs/changelog.d/v1-selfhost-roadmap.doc.md new file mode 100644 index 0000000..01d006b --- /dev/null +++ b/docs/changelog.d/v1-selfhost-roadmap.doc.md @@ -0,0 +1 @@ +- **heph self-hosts its roadmap (the "bootstrap lift").** With v1 at Todoist feature-parity, remaining/future work is now tracked **in heph itself** — as tasks in the `Hephaestus` project (`heph view ondeck`) — rather than in a document. The tech-spec is reframed accordingly: `tech-spec.md` → **`v1-prototype-tech-spec.md`** (all wiki-links + paths updated), retitled and bannered as a **historical** record of the v1 build, with its §14 tracker frozen. `AGENTS.md` gains a *Planning future work* section (capture via `heph task "…" --project Hephaestus`, triage in `heph-tui` On Deck); `README.md`'s status reflects parity + the three daily-driver surfaces. The living **[[design]]** doc remains the rationale of record. diff --git a/docs/explanation/design.md b/docs/explanation/design.md index 54cb8a9..b685e32 100644 --- a/docs/explanation/design.md +++ b/docs/explanation/design.md @@ -8,7 +8,7 @@ tags: # Hephaestus — Design Document -> **Status:** Living design record. This is the **rationale + decision-history** document. For the clean, implementation-facing spec the next session builds from, see **[[tech-spec]]**. Sections marked **❓ OPEN** are unresolved; **🔒 DECIDED** are settled. +> **Status:** Living design record. This is the **rationale + decision-history** document. For the clean, implementation-facing spec the next session builds from, see **[[v1-prototype-tech-spec]]**. Sections marked **❓ OPEN** are unresolved; **🔒 DECIDED** are settled. ## 1. Purpose & Vision @@ -63,7 +63,7 @@ Today these are loosely coupled by fragile cross-links (`todoist://<task-id>` in ### 3.1 Identity - 🔒 **DECIDED — ULID for content nodes** (`doc`/`task`/`project`), sortable and sync-safe, with a human-facing `slug`/`alias` layer so `[[wiki-links]]` can be written by name. The ZK's timestamp-IDs (`1722897441-MWFE`) are ULID-like already; a future migration can map them. -- 🔒 **DECIDED — deterministic ids for key-unique kinds.** Random ULIDs *diverge* when two offline replicas independently create the *same logical singleton* (today's `journal`, a `tag` by name) — duplicates that never merge. So `journal` ids derive deterministically from `(owner, ISO-date)` and `tag` ids from `(owner, normalized-name)`; independent creation then yields the *same* id and the CRDT/op-log coalesce them (two partial daily notes even merge). `project` stays a random ULID (deliberately created, renameable, tiny stable set). Tag rename = retag (no in-place key change); the normalization function is a fixed, versioned constant. See [[tech-spec]] §4.5. +- 🔒 **DECIDED — deterministic ids for key-unique kinds.** Random ULIDs *diverge* when two offline replicas independently create the *same logical singleton* (today's `journal`, a `tag` by name) — duplicates that never merge. So `journal` ids derive deterministically from `(owner, ISO-date)` and `tag` ids from `(owner, normalized-name)`; independent creation then yields the *same* id and the CRDT/op-log coalesce them (two partial daily notes even merge). `project` stays a random ULID (deliberately created, renameable, tiny stable set). Tag rename = retag (no in-place key change); the normalization function is a fixed, versioned constant. See [[v1-prototype-tech-spec]] §4.5. ### 3.2 Scalar task attributes vs. links @@ -81,7 +81,7 @@ A **recurring task** carries an **RFC-5545 RRULE** and acts as a recurring **def - A recurring task is a **single node**. On completion: (1) append the finished occurrence to the per-task **log** (§6.4), (2) reset the body's checkboxes to unchecked (a body-CRDT edit), (3) advance the do-date to **the next RRULE instance strictly after `now`**, *skipping* missed occurrences. So a fresh, all-`outstanding` checklist each occurrence; **completion never carries forward**; a missed daily routine is **one** gently-overdue item, not a pile. - History is narrative (the log), matching "narrative > list" (§6.1). RRULE expansion stays **lazy** — only "next instance after now" is ever computed, never the series (§6.6). -**Why not occurrence-instances?** The rejected alternative (a fresh node per occurrence) was attractive for queryable per-occurrence history, but under Fork A each occurrence would need its own body — hence its own node — turning every daily recurrence into a new node-pair: exactly the explosion §6.6 forbids. No v1 query needs occurrence rows (the ranking reads the single node's do-date), and adherence/streak stats can be reconstructed from the log later. See [[tech-spec]] §4.4. +**Why not occurrence-instances?** The rejected alternative (a fresh node per occurrence) was attractive for queryable per-occurrence history, but under Fork A each occurrence would need its own body — hence its own node — turning every daily recurrence into a new node-pair: exactly the explosion §6.6 forbids. No v1 query needs occurrence rows (the ranking reads the single node's do-date), and adherence/streak stats can be reconstructed from the log later. See [[v1-prototype-tech-spec]] §4.4. ## 4. Architecture @@ -92,9 +92,9 @@ Layers, top to bottom: - **Surfaces (thin clients) — a three-surface model (revised 2026-06 after the §6.2.1 Todoist study; supersedes the earlier "nvim is *the* primary surface" framing).** Tasks and knowledge pull in different interaction directions, so each surface plays to its strength rather than one trying to do everything: - **`heph.nvim` — the primary *context / knowledge-base* surface.** The full "org-mode"-style experience (markdown buffers backed by `doc` nodes; wiki-links, journaling, the canonical-context doc, per-task log, checklists). The explicit **successor to obsidian.nvim** (telescope picker, follow `[[wiki-links]]` on `<Enter>`, dailies, multi-state checkboxes); must reach that parity (§6.5). It surfaces tasks for **navigation/reading and context**, not as the primary place to *edit* structured task fields. - **`heph` CLI — capture/scripting + the complete daemon API.** Every structured task field is a flag (`-a red --do tomorrow --late fri --recur weekly --project Maintenance`), which is exactly what command-line flags are good at and dissolves the "how do I edit N structured fields" problem. The CLI implements the *entire* API (admin, sync, conflicts, export) so it is also the scripting/automation surface. - - **`heph-tui` — the primary *task agenda / triage* surface (planned, [[tech-spec]] §8.1).** The §6.2.1 study shows the dominant task activity is *interactive triage of a large set* (daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns that, and **launches into nvim** for a task's context (and nvim launches back). Not yet built. + - **`heph-tui` — the primary *task agenda / triage* surface (planned, [[v1-prototype-tech-spec]] §8.1).** The §6.2.1 study shows the dominant task activity is *interactive triage of a large set* (daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns that, and **launches into nvim** for a task's context (and nvim launches back). Not yet built. - 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. + - 🔒 **Single binary, three modes.** One Rust binary runs as `local` / `server` / `client` ([[v1-prototype-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. @@ -252,7 +252,7 @@ The owner's old rule — "avoid ncurses and interactive UIs; write atomic code a **heph's default "what is next?" (Tactical blank-slate)** therefore ≈ Top of Mind (Red first — by *consequence*, then Orange) **+** White items whose do-date has arrived, scoped to the current project/context, **with Blue hidden**. Concise, honest, light. -> 🔒 **DECIDED ranking mechanics ([[tech-spec]] §7):** `do_date` is a *boolean candidacy filter only* (null ⇒ "now"), **never** an urgency input; **`late_on` is the sole urgency signal** (a global "now a problem" top tier); within a band, FIFO by `created_at` — **age never becomes urgency**. The order is expressed as a reorderable named-dimension list so it can be retuned without touching the engine. +> 🔒 **DECIDED ranking mechanics ([[v1-prototype-tech-spec]] §7):** `do_date` is a *boolean candidacy filter only* (null ⇒ "now"), **never** an urgency input; **`late_on` is the sole urgency signal** (a global "now a problem" top tier); within a band, FIFO by `created_at` — **age never becomes urgency**. The order is expressed as a reorderable named-dimension list so it can be retuned without touching the engine. ### 6.2.1 Todoist study (2026-06): empirical confirmation + new requirements @@ -261,7 +261,7 @@ The owner's old rule — "avoid ncurses and interactive UIs; write atomic code a **Confirms the model:** - **Projects = contexts, used heavily and hierarchically.** 34 projects organized into top-level life-areas (`Life`, `Work`, `Coding`, `Camano`, `Culture`) → sub-projects (`Child`, `Blumeops`, `Daily Routine`, `Maintenance`, `Movies`…). The *hierarchy* is new information (§6.2 listed projects flat). -- **Huge backlog, tiny hot set.** Priority split p1=2, p2=8, p4(default)=229, p3=148 — i.e. the genuinely "hot" set is ~10, the rest is white/blue backlog. The dominant *activity* is therefore **triage of a large set**, not editing single tasks — a direct argument for an interactive agenda surface (§4, [[tech-spec]] §8.1). +- **Huge backlog, tiny hot set.** Priority split p1=2, p2=8, p4(default)=229, p3=148 — i.e. the genuinely "hot" set is ~10, the rest is white/blue backlog. The dominant *activity* is therefore **triage of a large set**, not editing single tasks — a direct argument for an interactive agenda surface (§4, [[v1-prototype-tech-spec]] §8.1). - **Do-date, not due-date — validated to the hilt.** **Zero** Todoist *deadlines* are used; only 107/387 carry a due (do) date. `late_on` will be genuinely rare. - **Recurring checklists are real** ("Prep For Day", every day @ 07:00, 6 children) — exactly the §3.3 reset-each-occurrence mechanism. - **Daily rituals exist as tasks**: "Excess Tasks to On Deck" (= the blue keep/drop review), "Coordinate with Allison", "Check Personal Email" (= orange reconfirm). The §6.2 working-set rituals are alive in the data; the TUI should make them first-class filters. @@ -274,7 +274,7 @@ The owner's old rule — "avoid ncurses and interactive UIs; write atomic code a - **Natural-language recurrence** — 95/107 dated tasks recur, expressed as `every 3 days`, `every 6 months`, `every workday`, `every April 15`, `every other wed`. heph stores RFC-5545 RRULE; capture should accept the common NL forms and compile them (the easy subset; time-of-day like "at 08:00" deferred — heph's `do_date` is date-grained for ranking). - **Tags are noise** — 7 labels, **5 task-uses across 387**. Confidently **defer** a tag surface; it is not load-bearing. -**The saved filters, verbatim (the basis for heph's filter views, [[tech-spec]] §8.2):** +**The saved filters, verbatim (the basis for heph's filter views, [[v1-prototype-tech-spec]] §8.2):** | Filter | Todoist query | |---|---| @@ -285,13 +285,13 @@ The owner's old rule — "avoid ncurses and interactive UIs; write atomic code a | Chores | `(today \| overdue \| no date) & (#Chores \| #Camano Chores)` | | On Deck | `p3 & (no date \| overdue \| today)` | -These define both the **agenda slices** heph must offer ([[tech-spec]] §8.2) and, by what "Tasks" *excludes* (`##Culture` = Movies/Books/Theater; `#Camano Info`), which contexts are **not tasks at all**. +These define both the **agenda slices** heph must offer ([[v1-prototype-tech-spec]] §8.2) and, by what "Tasks" *excludes* (`##Culture` = Movies/Books/Theater; `#Camano Info`), which contexts are **not tasks at all**. **Reference contexts → wiki, not tasks (decided 2026-06).** The `##Culture` subtree (Movies/Books/Theater) and `#Camano Info` are reference lists (films to watch, books to read, contractor phone numbers), deliberately excluded from every task filter. In heph they belong as **`doc` pages**, not committed tasks — otherwise they flood "what is next?". On the initial Todoist import these 5 projects (~59 items) were reclassified into one wiki list-doc each and the task/project nodes tombstoned. (A useful general principle: a "task" that never appears in any of your filters is probably a note.) **Future direction (noted 2026-06, not scheduled):** -- **Chores as a first-class feature.** Chores want **different do-date / recurrence semantics** from regular tasks (the every-N-days "do it again sometime after" rhythm, tuned-down urgency). Rather than model them as a `#Chores` *project* you scope to, make "chore" a **first-class task property** (kind/flag) with its own scheduling rules — which retires the `#Chores` / `#Camano Chores` projects (and the Camano split) entirely. The interim `Chores` filter view ([[tech-spec]] §8.2) is project-scoped until then. +- **Chores as a first-class feature.** Chores want **different do-date / recurrence semantics** from regular tasks (the every-N-days "do it again sometime after" rhythm, tuned-down urgency). Rather than model them as a `#Chores` *project* you scope to, make "chore" a **first-class task property** (kind/flag) with its own scheduling rules — which retires the `#Chores` / `#Camano Chores` projects (and the Camano split) entirely. The interim `Chores` filter view ([[v1-prototype-tech-spec]] §8.2) is project-scoped until then. - **Drop the `Schedule` filter.** Schedule was Todoist's timed-routine view (`!no time`); heph's `do_date` is date-grained, so it is **omitted** (not approximated). It's entangled with the chores rework (timed routines), so reconsider both together if/when time-of-day lands on tasks. ### 6.3 Two kinds of task: commitments vs. context items @@ -306,7 +306,7 @@ These define both the **agenda slices** heph must offer ([[tech-spec]] §8.2) an #### Editing surface for context items — *deliberately deferred to the prototype* -> 🔑 **DECIDED — Fork A "index" backend (the affordance is still trialed both ways).** The stored source of truth is the `doc` **body markdown**: context items are `- [ ]` lines in it, and a context item's checked state *is* the `[ ]`/`[x]`. They are **not** synced nodes — each replica derives a **local index** from its converged CRDT body, so they converge for free (no divergent ULIDs across offline edits — the original sync trap). Identity is pinned only at **promotion**, which mints a real committed `task` node and rewrites the source line into a link to it. This collapses the hardest sync problem into "the body CRDT already converges" and matches this section's "ephemeral, low-stakes identity" intent. **What's still trialed both ways is purely the nvim *affordance*** (Option A checkboxes-in-body vs. Option B command-driven capture) — the backend is shared, so ergonomics can be judged in the prototype. See [[tech-spec]] §4.3, §5. +> 🔑 **DECIDED — Fork A "index" backend (the affordance is still trialed both ways).** The stored source of truth is the `doc` **body markdown**: context items are `- [ ]` lines in it, and a context item's checked state *is* the `[ ]`/`[x]`. They are **not** synced nodes — each replica derives a **local index** from its converged CRDT body, so they converge for free (no divergent ULIDs across offline edits — the original sync trap). Identity is pinned only at **promotion**, which mints a real committed `task` node and rewrites the source line into a link to it. This collapses the hardest sync problem into "the body CRDT already converges" and matches this section's "ephemeral, low-stakes identity" intent. **What's still trialed both ways is purely the nvim *affordance*** (Option A checkboxes-in-body vs. Option B command-driven capture) — the backend is shared, so ergonomics can be judged in the prototype. See [[v1-prototype-tech-spec]] §4.3, §5. - **Option A — checkboxes in the body, derived on save.** Edit the task's canonical context doc as plain markdown; `- [ ]` lines are materialized into context-item state on `:w` (`BufWritePost`), reusing the wiki-link extraction machinery. **No real-time scanning needed** (debounced live updates are optional polish). Remote CRDT edits are *pushed* from the daemon to reconcile an open buffer. Item identity is low-stakes (reword = tombstone-old + add-new) since items are ephemeral; identity is pinned only at **promotion**. Most "personal org-mode"-native; millisecond capture = "type a line." - **Option B — command-driven capture, rendered.** Capture via a leader chord + `vim.ui.input` (no raw checkbox typing); the new item is still written as a `- [ ]` line into the body-backed store (Fork A), then **rendered** inline via virtual text/extmarks or a transient **floating window** (avoids the disliked persistent cutaway). Heavier capture; more UI to build; promotion identical. @@ -410,7 +410,7 @@ Reuse the established blumeops patterns (🔒 confirmed by repo conventions): 1. **CRDT library** for body merge: `yrs` (leaning) vs `automerge`; bespoke op-log/HLC for scalar fields either way. 2. **Hub network transport** (tech-spec §6.1): `axum` HTTP/JSON (leaning) vs gRPC; sync propagation cadence (push vs periodic pull); **device-id provisioning** (how a spoke mints its `origin` id and registers with the hub). -*Resolved in the second-pass review (2026-05-31), now folded into [[tech-spec]]:* +*Resolved in the second-pass review (2026-05-31), now folded into [[v1-prototype-tech-spec]]:* 3. ✅ **Recurrence → roll-forward in place** (§3.3) — occurrence-instances rejected (would explode into a node-per-occurrence under Fork A); advance to the next RRULE instance after `now`, skipping misses. 4. ✅ **Context items → Fork A "index" backend** (§6.3) — body markdown is the source of truth; context items are a local derived index, not synced nodes; identity at promotion. The nvim *affordance* (A vs B) is still trialed in-prototype. @@ -430,10 +430,10 @@ Reuse the established blumeops patterns (🔒 confirmed by repo conventions): ## 10. Roadmap (provisional) -- **Phase 0 — Design** (this document + [[tech-spec]]): done enough to build. +- **Phase 0 — Design** (this document + [[v1-prototype-tech-spec]]): done enough to build. - **Phase 1 — v1 prototype (a single C1 effort, deliberately — a one-shot test of delivering a high-complexity prototype from the spec; built in TDD slices):** `heph-core` (model, schema, extraction, recurrence, "what is next", `Store` trait, op-log/HLC/CRDT merge) → `hephd` **local mode** → **server + client modes (+ lock handoff)** → **offline sync + conflict queue** → **OIDC/Authentik auth + per-user isolation** → `heph.nvim` + `heph` CLI. Local-only works standalone; runnable client/server + offline sync on the tailnet. The build order doubles as the cross-session resume tracker (next un-green slice = where to resume). C2/Mikado is *not* used: it sequences prerequisites against existing code under test, and this is greenfield delivery from a complete spec; follow-up C1s or a C2 refactor come later as needed. - **Phase 1 progress** (branch `feature/v1-prototype`, PR #1; 102 tests green as of 2026-06-01 — see [[tech-spec]] §14 for the per-area tracker): + **Phase 1 progress** (branch `feature/v1-prototype`, PR #1; 102 tests green as of 2026-06-01 — see [[v1-prototype-tech-spec]] §14 for the per-area tracker): - [x] `heph-core` library — schema/`Store`/`LocalStore`, extraction, tasks/links/canonical-context + wiki-link materialization, "what is next?" ranking, recurrence roll-forward + per-task logs. - [x] `hephd` **local mode** — file lock + JSON-RPC over a unix socket (tokio blocking pool); `heph` CLI + `export`. - [x] Local query surface — `list`, `health`, `journal`, `search` (FTS5). @@ -447,7 +447,7 @@ Reuse the established blumeops patterns (🔒 confirmed by repo conventions): > The handoff target for the next context session. Items here are **proposals to ratify**; once ratified, a fresh session can build directly from this. -> **The canonical, implementation-ready scope/stack/schema/API now live in [[tech-spec]].** This section keeps the *intent* and the kickoff process; defer to the spec for details (and update the spec when decisions change). +> **The canonical, implementation-ready scope/stack/schema/API now live in [[v1-prototype-tech-spec]].** This section keeps the *intent* and the kickoff process; defer to the spec for details (and update the spec when decisions change). ### 11.1 Scope — *local-to-distributed via a targetable backend* (ratified) @@ -464,7 +464,7 @@ Core: `rusqlite` (bundled) + migrations · `tokio` + JSON-RPC/unix-socket (local ### 11.3 First-session kickoff checklist -1. `mise run ai-docs`; read **[[tech-spec]]** (the build spec) and this doc's §3/§6 for rationale. +1. `mise run ai-docs`; read **[[v1-prototype-tech-spec]]** (the build spec) and this doc's §3/§6 for rationale. 2. **Classify as a single C1** (deliberate — a one-shot test of delivering this high-complexity prototype from the spec; C2/Mikado is for sequencing prerequisites against *existing* code, not greenfield delivery). Single long-lived feature branch + early draft PR, docs-first, push as you go ([[agent-change-process]]). The §11.1 build order is the resume tracker; the hairy slices (sync/CRDT) may spin off follow-up C1s rather than blocking the prototype. 3. Scaffold the cargo workspace + `heph.nvim` skeleton; **fill the AGENTS.md Project Structure section** (last template TODO). 4. Build outward in testable slices (TDD, tech-spec §2/§9): `heph-core` (schema, model, extraction, recurrence, "what is next", `Store` trait, **op-log/HLC/CRDT merge**) → `hephd` **local mode** (LocalStore + lock + local RPC) → **server + client modes** (network endpoint, RemoteStore, lock handoff) → **sync + conflict queue** → **OIDC auth** → `heph.nvim` + `heph` CLI. @@ -472,6 +472,6 @@ Core: `rusqlite` (bundled) + migrations · `tokio` + JSON-RPC/unix-socket (local ## Related -- [[tech-spec]] — clean implementation-facing technical specification distilled from this document +- [[v1-prototype-tech-spec]] — clean implementation-facing technical specification distilled from this document - [[ai-assistance-guide]] — conventions for AI agents in this repo - [[agent-change-process]] — C0/C1/C2 change methodology diff --git a/docs/explanation/task-lifecycle.md b/docs/explanation/task-lifecycle.md index f299c02..69e9c22 100644 --- a/docs/explanation/task-lifecycle.md +++ b/docs/explanation/task-lifecycle.md @@ -34,7 +34,7 @@ doing something separately from *how loud* it should be. is next"). - **`done`** — you did it. Terminal. For a **recurring** task, completing rolls it *forward* to its next occurrence with a fresh checklist (it reappears as a - new outstanding instance — see [[design]] §3.3 and [[tech-spec]] §4.4), rather + new outstanding instance — see [[design]] §3.3 and [[v1-prototype-tech-spec]] §4.4), rather than ending. - **`dropped`** — you've decided **not** to do it ("let it go"). Terminal, and the sibling of `done`: "didn't do" vs "did". The distinction from `done` is @@ -67,7 +67,7 @@ either keeping it blue ("later") or **dropping** it ("let go"). the task node `tombstoned` and removes it from *everything* — the agenda, full-text search, and export. It is a **soft delete** (the row is retained with a flag, recoverable at the database level, and CRDT-safe — heph never hard-deletes, -see [[tech-spec]] §12), but for all practical purposes the task is gone. +see [[v1-prototype-tech-spec]] §12), but for all practical purposes the task is gone. Deleting a task tombstones **only the task node** — its canonical-context doc (your notes/checklist for it) is **kept**, so deleting a task doesn't throw away @@ -90,7 +90,7 @@ without cluttering your working set. ## How you move a task between states The model is surface-agnostic; the gestures differ per surface. In the TUI -([[tech-spec]] §8.1): +([[v1-prototype-tech-spec]] §8.1): | Gesture | Effect | |---|---| @@ -109,4 +109,4 @@ committed task ([[design]] §6.3). ## Related - [[design]] §6.2 (the lived priority discipline), §6.3 (commitments vs context items) -- [[tech-spec]] §4.3 (task semantics), §7 (ranking), §8.1 (the TUI), §12 (tombstones) +- [[v1-prototype-tech-spec]] §4.3 (task semantics), §7 (ranking), §8.1 (the TUI), §12 (tombstones) diff --git a/docs/how-to/install-heph.md b/docs/how-to/install-heph.md index 0c034b6..8490a06 100644 --- a/docs/how-to/install-heph.md +++ b/docs/how-to/install-heph.md @@ -85,4 +85,4 @@ talks to the dev daemon. They never share a socket or DB. `.dev/` is gitignored. ## Related - [[heph-nvim]] — the plugin surface and its managed-daemon lifecycle -- [[tech-spec]] — §3.1 runtime modes; the daemon's exclusive DB lock +- [[v1-prototype-tech-spec]] — §3.1 runtime modes; the daemon's exclusive DB lock diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index cbfa0dd..9d79c68 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -119,5 +119,5 @@ identical to the native `mise run test-nvim` path. ## Related -- [[tech-spec]] — §8 surface spec, §6 RPC API, §9 testing strategy +- [[v1-prototype-tech-spec]] — §8 surface spec, §6 RPC API, §9 testing strategy - [[design]] — the mode model (Tactical/Strategic/Organizational) and rationale diff --git a/docs/reference/reference.md b/docs/reference/reference.md index 2e88d96..8e4743e 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -12,7 +12,7 @@ Technical reference material for the repository tooling that ships with this pro ## Project -- [[tech-spec]] — Hephaestus technical specification (data model, RPC API, "what is next?" ranking, recurrence, testing strategy, v1 scope) +- [[v1-prototype-tech-spec]] — Hephaestus technical specification (data model, RPC API, "what is next?" ranking, recurrence, testing strategy, v1 scope) - [[heph-nvim]] — The Neovim plugin surface: architecture, buffer-backed editing, RPC dependencies, commands, and the headless e2e harness ## Template Surface Area diff --git a/docs/reference/tech-spec.md b/docs/reference/v1-prototype-tech-spec.md similarity index 98% rename from docs/reference/tech-spec.md rename to docs/reference/v1-prototype-tech-spec.md index 4a2c8fa..b414523 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/v1-prototype-tech-spec.md @@ -1,14 +1,16 @@ --- -title: Technical Specification +title: Technical Specification — v1 Prototype (historical) modified: 2026-06-03 tags: - reference - design --- -# Hephaestus — Technical Specification +# Hephaestus — Technical Specification (v1 Prototype) -> Clean, implementation-facing spec for the v1 prototype. For the *why* behind every choice (history, prior art, decision trail), see [[design]]. Where this spec and the design doc disagree, the design doc's latest decision wins — file an update here. +> ⚠️ **Historical document.** This is the implementation spec for the **v1 prototype**, kept as the record of what was designed and built. **v1 reached Todoist feature-parity on 2026-06-03** — the Rust backend is feature-complete and all three surfaces (`heph` CLI, `heph-tui`, `heph.nvim`) are daily-drivers. **heph now self-hosts its own roadmap:** remaining and future work lives as tasks in the **`Hephaestus` project inside heph** — capture with `heph task "…" --project Hephaestus`, triage in `heph-tui` (the *On Deck* view) or `heph view ondeck`. The §14 tracker below is preserved as the **build history, not a live TODO**. See `AGENTS.md` for the working process. + +> Clean, implementation-facing spec for the v1 prototype. For the *why* behind every choice (history, prior art, decision trail), see [[design]] (the living design doc). Where this spec and the design doc disagree, the design doc's latest decision wins. ## 1. Overview @@ -424,6 +426,8 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) +> 📜 **Historical, as of v1 parity (2026-06-03).** This tracker recorded the Phase 1 build slice-by-slice. With parity reached, **ongoing/future work is no longer tracked here** — it lives in heph itself, in the **`Hephaestus` project** (`heph view ondeck` · `heph-tui` → *On Deck*). What remains below (the ⏳ items) was captured into that project as tasks; this section is kept as the build record. + > Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **186 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]). **Done** -- 2.50.1 (Apple Git-155) From 911255fece030ee1d7cc69da3f996457fb90797e Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 20:38:44 -0700 Subject: [PATCH 88/91] =?UTF-8?q?style:=20cargo=20fmt=20=E2=80=94=20normal?= =?UTF-8?q?ize=20earlier=20hand-committed=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run rustfmt over files landed in earlier commits this session that weren't fmt-checked (heph-quickadd, the heph-tui undo/move wave, the hephd quickadd supervisor). Pure formatting (struct/if-else expansion, line wrapping); no behavior change. Restores `cargo fmt --check` clean for CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/heph-quickadd/src/app.rs | 40 +++++++++++++++++++---------- crates/heph-quickadd/src/main.rs | 5 +++- crates/heph-tui/src/app.rs | 15 ++++++++--- crates/heph-tui/src/ui.rs | 3 +-- crates/heph-tui/tests/navigation.rs | 5 +++- crates/hephd/src/main.rs | 4 ++- 6 files changed, 50 insertions(+), 22 deletions(-) diff --git a/crates/heph-quickadd/src/app.rs b/crates/heph-quickadd/src/app.rs index 7bae5d5..b08bf03 100644 --- a/crates/heph-quickadd/src/app.rs +++ b/crates/heph-quickadd/src/app.rs @@ -111,7 +111,10 @@ fn today_local() -> NaiveDate { enum SaveOutcome { Ok, /// The RPC failed — re-show with this text restored and the error shown. - Err { text: String, message: String }, + Err { + text: String, + message: String, + }, } pub struct QuickAdd { @@ -171,7 +174,11 @@ impl QuickAdd { } // Baseline parent pid for orphan detection (macOS supervision only). - let orphan_parent = if supervised { current_parent_pid() } else { None }; + let orphan_parent = if supervised { + current_parent_pid() + } else { + None + }; let (projects_tx, projects_rx) = std::sync::mpsc::channel(); let (save_tx, save_rx) = std::sync::mpsc::channel(); @@ -393,8 +400,7 @@ impl QuickAdd { // Enter always submits — the autocomplete (Tab/↑/↓/click) never // hijacks it, so the muscle-reflex save stays sacred. - let submitted = - resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); // When not autocompleting, the lower area is the live chip preview. if rows == 0 { @@ -425,7 +431,9 @@ impl QuickAdd { BASE_H }; if (target_h - self.win_h_applied).abs() > 0.5 { - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, target_h))); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2( + WIN_W, target_h, + ))); self.win_h_applied = target_h; } } @@ -501,11 +509,7 @@ impl QuickAdd { let selected = i == self.ac_selected; let text = egui::RichText::new(format!("📁 {title}")) .size(LABEL_SIZE) - .color(if selected { - egui::Color32::WHITE - } else { - DIM - }); + .color(if selected { egui::Color32::WHITE } else { DIM }); if ui.selectable_label(selected, text).clicked() { accept = Some(title.clone()); } @@ -514,9 +518,11 @@ impl QuickAdd { if let Some(title) = accept { apply_completion(&mut self.text, hash_idx, query.chars().count(), &title); let new_idx = hash_idx + 1 + title.chars().count() + 1; - if let Some(mut st) = egui::widgets::text_edit::TextEditState::load(ui.ctx(), field_id) { + if let Some(mut st) = egui::widgets::text_edit::TextEditState::load(ui.ctx(), field_id) + { let cc = egui::text::CCursor::new(new_idx); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(cc))); + st.cursor + .set_char_range(Some(egui::text::CCursorRange::one(cc))); st.store(ui.ctx(), field_id); } ui.ctx().memory_mut(|m| m.request_focus(field_id)); @@ -535,7 +541,9 @@ impl QuickAdd { if let Some(att) = parsed.attention { let (label, color) = match att { - heph_core::Attention::Red => ("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60)), + heph_core::Attention::Red => { + ("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60)) + } heph_core::Attention::Orange => { ("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b)) } @@ -555,7 +563,11 @@ impl QuickAdd { .find(|p| &p.id == id) .map(|p| p.title.as_str()) .unwrap_or("project"); - ui.label(egui::RichText::new(format!("📁 {title}")).color(dim).size(LABEL_SIZE)); + ui.label( + egui::RichText::new(format!("📁 {title}")) + .color(dim) + .size(LABEL_SIZE), + ); any = true; } diff --git a/crates/heph-quickadd/src/main.rs b/crates/heph-quickadd/src/main.rs index 0c76f98..2e70b81 100644 --- a/crates/heph-quickadd/src/main.rs +++ b/crates/heph-quickadd/src/main.rs @@ -19,7 +19,10 @@ use eframe::egui; use app::QuickAdd; #[derive(Parser, Debug)] -#[command(name = "heph-quickadd", about = "Global quick-capture popover for hephaestus")] +#[command( + name = "heph-quickadd", + about = "Global quick-capture popover for hephaestus" +)] struct Cli { /// hephd socket to talk to (defaults to $HEPH_SOCKET or the standard path). #[arg(long, global = true)] diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 38bd38d..2a89cdd 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -252,8 +252,15 @@ impl MoveState { }); } } - if !f.is_empty() && !self.projects.iter().any(|p| p.title.eq_ignore_ascii_case(f)) { - opts.push(MoveOption::Create { name: f.to_string() }); + if !f.is_empty() + && !self + .projects + .iter() + .any(|p| p.title.eq_ignore_ascii_case(f)) + { + opts.push(MoveOption::Create { + name: f.to_string(), + }); } self.options = opts; self.cursor = self.cursor.min(self.options.len().saturating_sub(1)); @@ -886,7 +893,9 @@ impl<B: Backend> App<B> { .as_ref() .and_then(|sel| self.sidebar.iter().position(|e| e == sel)) .unwrap_or_else(|| { - let idx = self.sidebar_cursor.min(self.sidebar.len().saturating_sub(1)); + let idx = self + .sidebar_cursor + .min(self.sidebar.len().saturating_sub(1)); (0..=idx) .rev() .find(|&i| self.sidebar[i].selectable()) diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 75f669b..23305c6 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -75,8 +75,7 @@ fn render_move(frame: &mut Frame, state: &MoveState) { height, }; frame.render_widget(Clear, popup); - let chunks = - Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(popup); + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(popup); // Filter input (with the task being moved in the title). let input = Paragraph::new(Line::from(vec![ diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 6b79fd2..1d1e7b5 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -446,7 +446,10 @@ fn undo_restores_a_dropped_task_and_redo_redrops_it() { app.undo(); // restores it to outstanding { let states = rec.borrow(); - assert_eq!(states.states.first().unwrap(), &("t1".into(), "dropped".into())); + assert_eq!( + states.states.first().unwrap(), + &("t1".into(), "dropped".into()) + ); assert_eq!( states.states.last().unwrap(), &("t1".into(), "outstanding".into()), diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index e6275fd..ee07cf6 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -229,7 +229,9 @@ fn spawn_quickadd_supervisor(socket: PathBuf) { use std::process::Command; let Some(exe) = locate_quickadd_binary() else { - tracing::warn!("HEPH_QUICKADD=1 but `heph-quickadd` was not found next to hephd or on PATH"); + tracing::warn!( + "HEPH_QUICKADD=1 but `heph-quickadd` was not found next to hephd or on PATH" + ); return; }; -- 2.50.1 (Apple Git-155) From dce3519345b905d610f4747d8a090f1dac22b68e Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 20:38:57 -0700 Subject: [PATCH 89/91] feat: heph list --project <name> + --json; thin AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `heph list --project <name>` lists a project's outstanding tasks by name (subtree-expanded, resolved server-side via a new project.scope path that reuses the view machinery; errors on unknown names). `--json` prints raw rows — node_id, canonical_context_id, attention/state/do_date/late_on/ recurrence/project_id — for scripting and agents. Store::project_scope on the trait + LocalStore + RemoteStore; new project.scope RPC and a flattened ListParams so `list` accepts an optional project name. Test covers resolve-by-name + unknown-name error. AGENTS.md thinned to tight command/pattern sections: dropped the historical parity narrative and the verbose roadmap section; added a "Working state" section documenting `heph list --project Hephaestus [--json]` as the way to inspect heph's self-hosted roadmap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- AGENTS.md | 17 ++++++------- crates/heph-core/src/sqlite/mod.rs | 27 ++++++++++++++++++--- crates/heph-core/src/sqlite/tasks.rs | 11 +++++++++ crates/heph-core/src/store.rs | 4 +++ crates/heph/src/main.rs | 23 +++++++++++++++--- crates/hephd/src/remote.rs | 4 +++ crates/hephd/src/rpc.rs | 21 +++++++++++++++- docs/changelog.d/v1-list-project.feature.md | 1 + 8 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 docs/changelog.d/v1-list-project.feature.md diff --git a/AGENTS.md b/AGENTS.md index 724eb15..39227d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ See [[agent-change-process]] for the full methodology. ## Project Structure -A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo tooling. **v1 reached Todoist feature-parity on 2026-06-03** — the **Rust backend is feature-complete** (all three runtime modes + sync + OIDC auth) and all three surfaces (`heph` CLI, **`heph-tui`**, **`heph.nvim`**) are installed daily-drivers. **Remaining/future work is now tracked in heph itself** (see *Planning* below), not in a doc; the **[[v1-prototype-tech-spec]]** is the historical build record. +A Cargo workspace (`Cargo.toml` at root) plus the Neovim plugin and repo tooling. Backend feature-complete (all three runtime modes + sync + OIDC); three daily-driver surfaces — `heph` (CLI), `heph-tui` (agenda/triage), `heph.nvim` (context/KB). ``` ./Cargo.toml # workspace manifest (shared deps + members) @@ -58,15 +58,14 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too ./mise-tasks/ # repo automation via `mise run` ``` -**Development is TDD** (v1-prototype-tech-spec §2, §9): failing test first, implement to green, commit on green. `heph-core` is clock-injected — no ambient wall-clock reads; time is always passed in. The **historical v1 build spec** is [[v1-prototype-tech-spec]]; the **living rationale/decisions** are [[design]]. +**TDD:** failing test first → implement to green → commit on green. `heph-core` is clock-injected (no ambient wall-clock; time is passed in). Spec: [[v1-prototype-tech-spec]] (frozen v1 build record); rationale: [[design]] (living). Other doc paths via `mise run ai-docs`; `[[like-this]]` wiki-links refer to `docs/` cards. -Other doc paths are listed via `mise run ai-docs`. Wiki-links (`[[like-this]]`) refer to `docs/` cards. +## Working state (heph self-hosts its roadmap) -## Planning future work (heph self-hosts its roadmap) +Outstanding/future heph work lives as tasks in the **`Hephaestus` project** — inspect it before planning: -Since v1 parity, **heph tracks its own remaining/future work as tasks in the `Hephaestus` project inside the live store** (the "bootstrap lift" — heph plans heph). This replaces the old [[v1-prototype-tech-spec]] §14 tracker, which is now a historical build record. +- `heph list --project Hephaestus` — outstanding tasks (human-readable) +- `heph list --project Hephaestus --json` — JSON rows: `node_id`, `canonical_context_id`, attention/state/do_date/recurrence (for scripting/agents) +- `heph task "<title>" --project Hephaestus -a blue` — capture new work (blue = on-deck backlog) -- **See the roadmap:** `heph view ondeck` (CLI) or `heph-tui` → the **On Deck** sidebar view (the backlog lives as blue/on-deck tasks); open the **Hephaestus** project in the sidebar to see all of it. -- **Capture new work:** `heph task "<title>" --project Hephaestus -a blue` (blue = on-deck backlog, kept out of `next`/ToM until pulled up). Add detail in the task's canonical-context doc via `heph.nvim`. -- **Don't reopen the §14 tracker** for new work — add a task instead. Update the spec only to correct historical record. -- Note the [[design]] doc remains the **living** design/decision log; the tech-spec is frozen as the v1 build description. +Triage in `heph-tui` (*On Deck* view). Add a task for new work — don't reopen the frozen §14 tracker. diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index d4b93f1..2a11ef1 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -282,6 +282,10 @@ impl Store for LocalStore { tasks::view(&self.conn, &self.owner_id, now, name) } + fn project_scope(&self, name: &str) -> Result<Vec<String>> { + tasks::project_scope(&self.conn, &self.owner_id, name) + } + fn health(&self) -> Result<Health> { tasks::health(&self.conn, &self.owner_id) } @@ -470,6 +474,21 @@ mod tests { assert_eq!(v, latest_version()); } + #[test] + fn project_scope_resolves_by_name_and_errors_on_unknown() { + use crate::model::{NewNode, NodeKind}; + let mut store = store_at(1); + let p = store + .create_node(NewNode { + kind: NodeKind::Project, + title: "Garden".into(), + body: None, + }) + .unwrap(); + assert_eq!(store.project_scope("Garden").unwrap(), vec![p.id]); + assert!(store.project_scope("Nope").is_err()); + } + #[test] fn delete_project_unfiles_its_tasks_then_tombstones_it() { use crate::filter::ListFilter; @@ -511,9 +530,11 @@ mod tests { // The project node is tombstoned… let ts: i64 = store .conn - .query_row("SELECT tombstoned FROM nodes WHERE id=?1", [&proj.id], |r| { - r.get(0) - }) + .query_row( + "SELECT tombstoned FROM nodes WHERE id=?1", + [&proj.id], + |r| r.get(0), + ) .unwrap(); assert_eq!(ts, 1); // …and the task survives, now unfiled (it shows in the Inbox), not orphaned. diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 1d5e9c7..3df2a53 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -438,6 +438,17 @@ fn resolve_project_names(conn: &Connection, owner: &str, names: &[&str]) -> Resu Ok(ids) } +/// Resolve a single project NAME to its scope: the project id plus its subtree +/// (parent→child). Errors if the name names no project, so `--project Foo` fails +/// loudly rather than silently widening to "everything". +pub(super) fn project_scope(conn: &Connection, owner: &str, name: &str) -> Result<Vec<String>> { + let scope = resolve_project_names(conn, owner, &[name])?; + if scope.is_empty() { + return Err(Error::InvalidArg(format!("no project named {name:?}"))); + } + Ok(scope) +} + /// Working-set health counts (tech-spec §7) — surfaced honestly. pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> { let mut stmt = conn.prepare( diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index a582abd..cb0475d 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -128,6 +128,10 @@ pub trait Store { /// unknown view name. fn view(&self, name: &str) -> Result<Vec<RankedTask>>; + /// Resolve a project NAME to its scope ids (the project + its subtree), for + /// `heph list --project <name>`. Errors if the name names no project. + fn project_scope(&self, name: &str) -> Result<Vec<String>>; + /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result<Health>; diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 8c2f8b0..505aa93 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -68,12 +68,18 @@ enum Command { /// Restrict to a project node id. #[arg(long)] scope: Option<String>, + /// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus. + #[arg(long)] + project: Option<String>, /// Only this attention-state: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option<String>, /// Hide on-deck (blue) items. #[arg(long)] no_blue: bool, + /// Print raw JSON rows (node id, canonical-context id, scalars) for scripting/agents. + #[arg(long)] + json: bool, }, /// Run a built-in filter view (tech-spec §8.2); omit the name to list views. View { @@ -429,16 +435,21 @@ fn main() -> Result<()> { } Command::List { scope, + project, attention, no_blue, + json, } => { - // `list` takes a ListFilter (tech-spec §8.2). Map the legacy flags: - // a single `--scope` id, a single `--attention` whitelist, and - // `--no-blue` as an attention exclusion. + // `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single + // `--scope` id or `--project` NAME (resolved + subtree-expanded by the + // daemon), a single `--attention` whitelist, and `--no-blue`. let mut filter = json!({}); if let Some(s) = scope { filter["scope"] = json!([s]); } + if let Some(p) = project { + filter["project"] = json!(p); + } if let Some(a) = attention { filter["attention_in"] = json!([a]); } @@ -446,7 +457,11 @@ fn main() -> Result<()> { filter["attention_not"] = json!(["blue"]); } let result = client.call("list", filter)?; - print_rows(result)?; + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + print_rows(result)?; + } } Command::View { name } => match name { Some(name) => { diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 55b12b8..9f405a8 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -209,6 +209,10 @@ impl Store for RemoteStore { self.call_as("view", json!({ "name": name })) } + fn project_scope(&self, name: &str) -> Result<Vec<String>> { + self.call_as("project.scope", json!({ "name": name })) + } + fn health(&self) -> Result<Health> { self.call_as("health", json!({})) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 1b7a947..5f9a59c 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -112,6 +112,16 @@ struct IdParam { id: String, } +/// `list` params: a [`ListFilter`] plus an optional `project` NAME the daemon +/// resolves (subtree-expanded) into the filter's `scope`. +#[derive(Deserialize)] +struct ListParams { + #[serde(flatten)] + filter: ListFilter, + #[serde(default)] + project: Option<String>, +} + #[derive(Deserialize)] struct GetNodeParams { id: String, @@ -387,13 +397,22 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?) } "list" => { - let filter: ListFilter = parse(params)?; + let p: ListParams = parse(params)?; + let mut filter = p.filter; + // `--project <name>` resolves to its subtree scope server-side. + if let Some(name) = p.project { + filter.scope = store.project_scope(&name)?; + } json!(store.list(&filter)?) } "view" => { let p: ViewParams = parse(params)?; json!(store.view(&p.name)?) } + "project.scope" => { + let p: ViewParams = parse(params)?; + json!(store.project_scope(&p.name)?) + } "health" => json!(store.health()?), "search" => { let p: SearchParams = parse(params)?; diff --git a/docs/changelog.d/v1-list-project.feature.md b/docs/changelog.d/v1-list-project.feature.md new file mode 100644 index 0000000..a8d7f2a --- /dev/null +++ b/docs/changelog.d/v1-list-project.feature.md @@ -0,0 +1 @@ +- **`heph list --project <name>` + `--json`** (§8.2): list a project's outstanding tasks by **name** (subtree-expanded, resolved server-side via a new `project.scope` path that reuses the view machinery — errors loudly on an unknown name), and `--json` prints the raw rows (`node_id`, `canonical_context_id`, attention/state/do_date/late_on/recurrence/project_id) for scripting and agents. This is the canonical "show me a project's outstanding work" command — `AGENTS.md` documents it as how to inspect heph's own roadmap (the `Hephaestus` project), now that heph self-hosts it. -- 2.50.1 (Apple Git-155) From ec522f49eccf8794922dff08dfd068f083772c0d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 20:46:55 -0700 Subject: [PATCH 90/91] infra(prek): add cargo fmt as a pre-commit hook Run `cargo fmt --all` in place over the workspace on any staged .rs change, matching the repo's other in-place formatters (ruff-format, stylua, shfmt). Unformatted Rust now fails the commit locally (it reformats + reports "files were modified"), so the fmt-dirty commits that slipped through this session can't recur. CI still enforces `cargo fmt --check` via Dagger as the backstop. Verified: passes clean, catches + fixes a deviation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/changelog.d/v1-prek-cargo-fmt.infra.md | 1 + prek.toml | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 docs/changelog.d/v1-prek-cargo-fmt.infra.md diff --git a/docs/changelog.d/v1-prek-cargo-fmt.infra.md b/docs/changelog.d/v1-prek-cargo-fmt.infra.md new file mode 100644 index 0000000..bfe9c18 --- /dev/null +++ b/docs/changelog.d/v1-prek-cargo-fmt.infra.md @@ -0,0 +1 @@ +- **`cargo fmt` is now a prek hook** — `cargo fmt --all` runs in place over the workspace on any staged `.rs` change (grouped with the other in-place formatters), so unformatted Rust can't be committed locally. CI already enforced `cargo fmt --check` via `dagger call check`; this catches it at commit time instead. diff --git a/prek.toml b/prek.toml index 7de95ab..c6a3a8f 100644 --- a/prek.toml +++ b/prek.toml @@ -83,6 +83,20 @@ repo = "https://github.com/JohnnyMorganz/StyLua" rev = "v2.4.1" hooks = [{ id = "stylua-system" }] +# Rust formatting - cargo fmt over the whole workspace, in place (like the other +# formatters above). Uses the system toolchain; CI also enforces it via +# `dagger call check` (cargo fmt --check). Runs whenever a .rs file is staged. +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "cargo-fmt" +name = "cargo-fmt" +entry = "cargo fmt --all" +language = "system" +files = '\.rs$' +pass_filenames = false + # GitHub/Forgejo Actions workflow linting [[repos]] repo = "https://github.com/rhysd/actionlint" -- 2.50.1 (Apple Git-155) From 2ae1c098386321375fe5ff39d187345d901694b3 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 3 Jun 2026 20:47:28 -0700 Subject: [PATCH 91/91] Remove heph-tui note from AGENTS.md --- AGENTS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39227d6..b799c0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,5 +67,3 @@ Outstanding/future heph work lives as tasks in the **`Hephaestus` project** — - `heph list --project Hephaestus` — outstanding tasks (human-readable) - `heph list --project Hephaestus --json` — JSON rows: `node_id`, `canonical_context_id`, attention/state/do_date/recurrence (for scripting/agents) - `heph task "<title>" --project Hephaestus -a blue` — capture new work (blue = on-deck backlog) - -Triage in `heph-tui` (*On Deck* view). Add a task for new work — don't reopen the frozen §14 tracker. -- 2.50.1 (Apple Git-155)