Compare commits

..

No commits in common. "main" and "feature/attention-a1-a4" have entirely different histories.

57 changed files with 177 additions and 3161 deletions

View file

@ -12,38 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
<!-- towncrier release notes start -->
## [v1.4.3] - 2026-06-09
### Features
- Merge conflicts are now actionable: `heph conflicts list` / `heph conflicts resolve <id> --keep local|remote` in the CLI, and a conflicts view in heph-tui (open with `C`) to review and settle divergent task scalars. Resolving with `--keep remote` actually applies the remote value.
- `heph daemon status` now surfaces the daemon's runtime config — version, mode, hub URL, sync poll interval, OIDC issuer — and self-update state (enabled, interval, last check time and outcome).
- `heph export` now expands bare `[[NODEID]]` wiki-links to `[[NODEID|Current Name]]` in exported bodies, so exported markdown is readable outside heph.
- Path-A hub seeding: `hephd --owner-id <id>` establishes the owner on a fresh store, so a hub can be seeded from a device snapshot that shares its owner id; [[set-up-sync-hub]] documents the recipe.
- Projects can be re-parented after creation: `heph project move <project> --parent <new-parent>` (or `--root`), and `m` on a sidebar project in heph-tui opens the parent picker. Cycle-creating moves are rejected.
- `heph show` on a task now prints the canonical-context doc body alongside the task scalars, instead of a perpetually-null `body` field hiding the real content.
- Sync observability: cycles that move ops log pulled/applied/pushed counts at info level, recovery after consecutive failures is logged explicitly, and repeated identical failures are throttled instead of spamming the log.
- heph-tui exposes logs properly: `L` opens a scrollable full-history log view for the selected task (the preview pane previously showed only the last five lines).
- heph-tui can rename a task in place (`R`), keeping its canonical-context doc's title in step; rename is undoable with `u`.
- Undoable delete: a new `node.restore` op un-tombstones a node (last-writer-wins against tombstones by HLC, derived from the op-log — no schema migration). `heph node restore <id>` and `u` in heph-tui undo task and project deletes.
### Bug Fixes
- Deleting a task now also tombstones its canonical-context doc, so the orphaned doc no longer lingers in search (FTS) results; restoring the task brings the doc back.
- The hephd sync client can now reach an https hub URL (rustls TLS was not compiled into the HTTP client), and sync errors name the phase and URL instead of a bare "error sending request".
### Miscellaneous
- Manual creation of derived/internal link types is rejected: `links.add` (and so `heph link add`) errors on `wiki` (those rows are materialized from `[[…]]` in the body and were silently reconciled away on the next body write), `canonical-context`, and `log-of`. To make a durable wiki link, put `[[dst]]` in the body.
## [v1.4.2] - 2026-06-09
### Features
- Attention is now set directly instead of cycled, and surfaces it as `a1``a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1``4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1``a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1``p4` to `a1``a4` across every capture surface. The `heph` CLI's `-a/--attention` flag now accepts `a1``a4`, a bare `1``4`, or a colour word (`red`/`orange`/`white`/`blue`). The colour mappings are unchanged.
## [v1.4.1] - 2026-06-08
### Bug Fixes

313
Cargo.lock generated
View file

@ -162,7 +162,7 @@ dependencies = [
"android-properties",
"bitflags 2.12.1",
"cc",
"jni 0.22.4",
"jni",
"libc",
"log",
"ndk",
@ -512,28 +512,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.9"
@ -814,12 +792,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -923,15 +895,6 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codespan-reporting"
version = "0.12.0"
@ -1412,12 +1375,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecdsa"
version = "0.16.9"
@ -1887,12 +1844,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.32"
@ -1985,10 +1936,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -2324,7 +2273,6 @@ dependencies = [
"heph-core",
"jsonwebtoken",
"keyring-core",
"proptest",
"rand 0.8.6",
"reqwest",
"rsa",
@ -2441,22 +2389,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -2704,22 +2636,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@ -3005,12 +2921,6 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac_address"
version = "1.1.8"
@ -3691,12 +3601,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.0+3.6.2"
@ -4188,62 +4092,6 @@ dependencies = [
"serde",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.2",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash 2.1.2",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@ -4505,22 +4353,16 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@ -4665,7 +4507,6 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@ -4675,62 +4516,21 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.21.1",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -4769,15 +4569,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@ -5510,21 +5301,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
@ -5551,16 +5327,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@ -6211,7 +5977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
dependencies = [
"core-foundation 0.10.1",
"jni 0.22.4",
"jni",
"log",
"ndk-context",
"objc2 0.6.4",
@ -6220,15 +5986,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
@ -6697,15 +6454,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -6742,21 +6490,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@ -6799,12 +6532,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@ -6817,12 +6544,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@ -6835,12 +6556,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@ -6865,12 +6580,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@ -6883,12 +6592,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@ -6901,12 +6604,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@ -6919,12 +6616,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"

View file

@ -55,13 +55,9 @@ dbus-secret-service-keyring-store = { version = "1", features = [
"vendored",
] }
ureq = { version = "3", features = ["json"] }
# rustls: the spoke→hub sync client must reach an https hub url (e.g. a
# Caddy front); without a TLS backend compiled in, reqwest fails every https
# request with an opaque "error sending request".
reqwest = { version = "0.13", default-features = false, features = [
"json",
"query",
"rustls",
] }
semver = "1"

View file

@ -8,12 +8,6 @@ publish.workspace = true
authors.workspace = true
rust-version.workspace = true
[features]
# Exposes thin public wrappers over crate-private internals (the body CRDT) for
# the cargo-fuzz targets in `fuzz/`. Never enabled in normal builds — the
# wrappers are test scaffolding, not part of the public API.
fuzzing = []
[dependencies]
rusqlite.workspace = true
ulid.workspace = true

View file

@ -1,5 +0,0 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock

View file

@ -1,41 +0,0 @@
# cargo-fuzz harness for heph-core's parsing/CRDT surfaces. Its own workspace
# (the empty `[workspace]` table) so it never pulls into the main build; it is
# nightly-only and run ad-hoc via `mise run fuzz`. See docs/how-to/fuzz-testing.md.
[package]
name = "heph-core-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.heph-core]
path = ".."
features = ["fuzzing"]
[[bin]]
name = "crdt_merge"
path = "fuzz_targets/crdt_merge.rs"
test = false
doc = false
bench = false
[[bin]]
name = "crdt_write"
path = "fuzz_targets/crdt_write.rs"
test = false
doc = false
bench = false
[[bin]]
name = "extract"
path = "fuzz_targets/extract.rs"
test = false
doc = false
bench = false
[workspace]

View file

@ -1,15 +0,0 @@
#![no_main]
//! Fuzz `merge_body` with arbitrary `delta` bytes — the untrusted sync-ingest
//! surface. A peer's update payload is decoded and applied here; a crash is a
//! remote-input daemon crash. yrs 0.27 is known to `SIGABRT`/UB on some
//! malformed inputs (see `crdt::merge_body`'s docs) — surfacing and shrinking
//! such an input is exactly this target's job.
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let (state, body) = heph_core::crdt_fuzz::merge_body(None, data);
// Idempotence: applying the same delta again must not change the body.
let (_, body_again) = heph_core::crdt_fuzz::merge_body(Some(&state), data);
assert_eq!(body, body_again, "merge of the same delta was not idempotent");
});

View file

@ -1,21 +0,0 @@
#![no_main]
//! Fuzz `write_body`: diff two arbitrary bodies into the text CRDT and check
//! the round-trip — the materialized body and the re-materialized stored state
//! must both equal the new body exactly. Stresses the UTF-8 boundary alignment
//! in the prefix/suffix diff with arbitrary (incl. multibyte) strings.
use libfuzzer_sys::fuzz_target;
const CLIENT: u64 = 0xAAAA;
fuzz_target!(|data: (String, String)| {
let (prev, new) = data;
let (base, _, _) = heph_core::crdt_fuzz::write_body(CLIENT, None, &prev);
let (state, _delta, body) = heph_core::crdt_fuzz::write_body(CLIENT, Some(&base), &new);
assert_eq!(body, new, "write did not materialize the new body");
assert_eq!(
heph_core::crdt_fuzz::body_of(&state),
new,
"stored state did not re-materialize to the new body"
);
});

View file

@ -1,27 +0,0 @@
#![no_main]
//! Fuzz `extract` over arbitrary markdown. Asserts the invariants promotion and
//! the context-item index depend on: wiki-links are non-empty and de-duplicated,
//! and `context_item_lines` stays 1:1 with `context_items`.
use libfuzzer_sys::fuzz_target;
use std::collections::HashSet;
fuzz_target!(|data: &[u8]| {
let Ok(s) = std::str::from_utf8(data) else {
return;
};
let e = heph_core::extract(s);
let mut seen = HashSet::new();
for link in &e.wiki_links {
assert!(!link.is_empty(), "empty wiki-link target");
assert_eq!(link.trim(), link.as_str(), "untrimmed wiki-link target");
assert!(seen.insert(link.clone()), "duplicate wiki-link {link:?}");
}
assert_eq!(
heph_core::extract::context_item_lines(s).len(),
e.context_items.len(),
"context_item_lines diverged from context_items",
);
});

View file

@ -1,7 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 279ac0171bbd7d502fc4aa546b3b3fbfc8eb01314a5df24c086f0297460ed01c # shrinks to seed = "", delta = [1, 1, 0, 0, 64, 128, 128, 128, 128, 128, 128, 128, 16, 0]

View file

@ -109,71 +109,24 @@ pub(crate) struct BodyMerge {
/// 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.
///
/// `delta` arrives from sync peers, so it is untrusted. A delta that fails to
/// decode is ignored (a no-op merge). yrs 0.27 is **not** robust to malformed
/// update bytes: some inputs trip a `debug_assert!` in its block decoder
/// (unwinding panic), and at least one class triggers genuine undefined
/// behaviour (an invalid `char`), which surfaces as a non-unwinding `SIGABRT`
/// under debug UB-checks and as silent UB in release. The `catch_unwind` below
/// contains the unwinding subset so a corrupt payload degrades to a no-op
/// merge rather than crashing a debug daemon; it cannot stop the abort/UB
/// class. The blast radius is limited — `/sync/push` is authenticated — but a
/// buggy or hostile *authenticated* peer can still feed bad bytes here. Beyond
/// the unwinding panics, fuzzing also found a tiny delta (`[255,255,255,126]`)
/// that drives yrs into a huge allocation (OOM) — `catch_unwind` can't help
/// that. The real fix is upstream (or a pre-apply validator yrs doesn't yet
/// expose). Findings and the `crdt_merge` fuzz target are documented in
/// [[fuzz-testing]].
pub(crate) fn merge_body(prev_state: Option<&[u8]>, delta: &[u8]) -> BodyMerge {
let merged = std::panic::catch_unwind(|| {
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),
}
});
merged.unwrap_or_else(|_| {
let doc = load(0, prev_state);
BodyMerge {
state: encode_state(&doc),
body: materialize(&doc),
}
})
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(any(test, feature = "fuzzing"))]
#[cfg(test)]
pub(crate) fn body_of(state: &[u8]) -> String {
materialize(&load(0, Some(state)))
}
/// Thin public wrappers over the crate-private CRDT for the cargo-fuzz targets
/// (`fuzz/fuzz_targets/`). Compiled only under the `fuzzing` feature, re-exported
/// from the crate root as `crdt_fuzz`. Tuples instead of the private `BodyWrite`
/// / `BodyMerge` structs so the fuzz crate needs no access to those types.
#[cfg(feature = "fuzzing")]
pub mod fuzz {
/// `(state, delta, body)` from diffing `new` into the CRDT seeded by `prev`.
pub fn write_body(client: u64, prev: Option<&[u8]>, new: &str) -> (Vec<u8>, Vec<u8>, String) {
let w = super::write_body(client, prev, new);
(w.state, w.delta, w.body)
}
/// `(state, body)` from merging an untrusted `delta` into `prev`.
pub fn merge_body(prev: Option<&[u8]>, delta: &[u8]) -> (Vec<u8>, String) {
let m = super::merge_body(prev, delta);
(m.state, m.body)
}
/// Materialize a stored state blob to its body text.
pub fn body_of(state: &[u8]) -> String {
super::body_of(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`.
@ -254,62 +207,4 @@ mod tests {
assert_eq!(edit.body, "café au lait");
assert_eq!(body_of(&edit.state), "café au lait");
}
#[test]
fn corrupt_delta_is_a_noop_merge() {
// Minimal panicking payload found by proptest: yrs 0.27 hits a debug
// assertion in its block decoder on this delta instead of returning
// Err. It must degrade to a no-op merge, not crash the daemon.
let bad: &[u8] = &[1, 1, 0, 0, 64, 128, 128, 128, 128, 128, 128, 128, 16, 0];
let base = write_body(A, None, "hello");
let m = merge_body(Some(&base.state), bad);
assert_eq!(m.body, "hello");
assert_eq!(body_of(&m.state), "hello");
}
use proptest::prelude::*;
proptest! {
/// A whole-buffer write always materializes exactly the new body — the
/// diff's UTF-8 boundary alignment never mangles multibyte text
/// (`\PC` generates arbitrary non-control chars, incl. multibyte).
#[test]
fn write_materializes_exactly(prev in "\\PC{0,80}", new in "\\PC{0,80}") {
let base = write_body(A, None, &prev);
let w = write_body(A, Some(&base.state), &new);
prop_assert_eq!(&w.body, &new);
prop_assert_eq!(body_of(&w.state), new);
}
/// Concurrent edits converge to the same body regardless of which side
/// merges the other's delta.
#[test]
fn concurrent_edits_converge(
base in "\\PC{0,40}", ea in "\\PC{0,40}", eb in "\\PC{0,40}",
) {
let b = write_body(A, None, &base);
let on_b = merge_body(None, &b.delta);
let wa = write_body(A, Some(&b.state), &ea);
let wb = write_body(B, Some(&on_b.state), &eb);
let fa = merge_body(Some(&wa.state), &wb.delta);
let fb = merge_body(Some(&wb.state), &wa.delta);
prop_assert_eq!(fa.body, fb.body, "replicas did not converge");
}
/// Applying the same delta twice is a no-op for arbitrary edits.
#[test]
fn merge_idempotent_for_arbitrary_edits(base in "\\PC{0,40}", new in "\\PC{0,40}") {
let b = write_body(A, None, &base);
let w = write_body(A, Some(&b.state), &new);
let once = merge_body(Some(&b.state), &w.delta);
let twice = merge_body(Some(&once.state), &w.delta);
prop_assert_eq!(once.body, twice.body);
}
// NB: robustness to *arbitrary* (non-yrs) delta bytes is deliberately
// NOT asserted here. yrs 0.27 can `SIGABRT`/UB on malformed updates
// (see `merge_body`'s docs and `corrupt_delta_is_a_noop_merge`), which
// is uncatchable and would abort the whole test binary. That surface
// is fuzzed in the non-blocking Tier 2 `crdt_merge` target instead.
}
}

View file

@ -47,58 +47,50 @@ pub fn extract(body: &str) -> Extraction {
let mut code_ranges: Vec<Range<usize>> = Vec::new();
// Depth of nested code blocks; their inner text ranges are code.
let mut code_depth: u32 = 0;
// One frame per open list item: `Some(index into context_items)` once the
// item turns out to carry a task marker. A stack (not a single slot) so a
// checklist nested under a checklist item keeps both items — pushed in
// marker order, which is what keeps `context_item_lines` aligned 1:1.
let mut open_items: Vec<Option<usize>> = Vec::new();
// Append `s` to the innermost open task item's label, if any.
fn append(items: &mut [ContextItem], open: &[Option<usize>], s: &str) {
if let Some(idx) = open.iter().rev().find_map(|f| *f) {
items[idx].text.push_str(s);
}
}
// 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::Start(Tag::Item) => open_items.push(None),
Event::TaskListMarker(checked) => {
context_items.push(ContextItem {
checked,
text: String::new(),
});
if let Some(frame) = open_items.last_mut() {
*frame = Some(context_items.len() - 1);
}
current = Some((checked, String::new()));
}
Event::End(TagEnd::Item) => {
open_items.pop();
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);
}
append(&mut context_items, &open_items, &text);
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);
append(&mut context_items, &open_items, &code);
if let Some((_, label)) = current.as_mut() {
label.push_str(&code);
}
}
Event::SoftBreak | Event::HardBreak => {
append(&mut context_items, &open_items, " ");
if let Some((_, label)) = current.as_mut() {
label.push(' ');
}
}
_ => {}
}
}
for item in &mut context_items {
item.text = item.text.trim().to_string();
}
// Scan the raw body for wiki-links (CommonMark mangles `[[ ]]` brackets, so
// we can't rely on Text events), excluding any that start inside code.
@ -251,28 +243,6 @@ mod tests {
assert_eq!(lines, vec![2, 8]); // 0-based lines of "- [ ] first" / "- [x] second"
}
#[test]
fn nested_checkbox_items_are_both_extracted_in_order() {
// A checklist nested under a checklist item: both are real items, in
// document order, and `context_item_lines` must stay aligned 1:1.
let body = "- [ ] outer\n - [x] inner\n";
let e = extract(body);
assert_eq!(
e.context_items,
vec![
ContextItem {
text: "outer".to_string(),
checked: false
},
ContextItem {
text: "inner".to_string(),
checked: true
},
]
);
assert_eq!(context_item_lines(body), vec![0, 1]);
}
#[test]
fn extraction_is_idempotent() {
let body = "# Mixed\n\n- [ ] do [[X]]\n- [x] done\n\nsee [[Y]]\n";
@ -283,55 +253,4 @@ mod tests {
fn body_without_links_or_items_yields_empty() {
assert_eq!(extract("just prose, no structure"), Extraction::default());
}
use proptest::prelude::*;
/// Bodies stitched from markdown-ish fragments — checklists (incl. nested),
/// code fences, links (well-formed, empty, unterminated), and arbitrary
/// text — to stress structure the unit tests don't enumerate.
fn markdownish() -> impl Strategy<Value = String> {
let frag = prop_oneof![
Just("- [ ] feed birds\n".to_string()),
Just("- [x] done [[Roof]]\n".to_string()),
Just(" - [X] nested\n".to_string()),
Just("* [ ] star\n".to_string()),
Just("+ [x] plus\n".to_string()),
Just("- plain item\n".to_string()),
Just("```\n".to_string()),
Just("# Heading\n".to_string()),
Just("> quote\n".to_string()),
Just("[[Roof]] ".to_string()),
Just("[[Roof|the roof]] ".to_string()),
Just("[[ ]] ".to_string()),
Just("[[unterminated ".to_string()),
Just("]] stray ".to_string()),
Just("`- [ ] code`\n".to_string()),
Just("\n".to_string()),
"\\PC{0,12}",
];
proptest::collection::vec(frag, 0..12).prop_map(|v| v.concat())
}
proptest! {
/// Derivation is total (no panic) and idempotent for arbitrary input.
#[test]
fn extract_is_total_and_idempotent(body in "\\PC{0,300}") {
prop_assert_eq!(extract(&body), extract(&body));
}
/// Links are non-empty, trimmed, deduped; and `context_item_lines`
/// aligns 1:1 with `context_items` — the invariant promotion's
/// line-rewriting depends on (see [`context_item_lines`]).
#[test]
fn invariants_hold_for_markdownish_bodies(body in markdownish()) {
let e = extract(&body);
let mut seen = HashSet::new();
for l in &e.wiki_links {
prop_assert!(!l.is_empty());
prop_assert_eq!(l.trim(), l.as_str());
prop_assert!(seen.insert(l.clone()), "duplicate link {:?}", l);
}
prop_assert_eq!(context_item_lines(&body).len(), e.context_items.len());
}
}
}

View file

@ -89,35 +89,4 @@ mod tests {
let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n";
assert_eq!(strip(body), "body\n\n---\n\nmore\n");
}
use proptest::prelude::*;
/// Frontmatter-shaped fragments: fences, key lines, prose, and noise.
fn frontmatterish() -> impl Strategy<Value = String> {
let frag = prop_oneof![
Just("---\n".to_string()),
Just("---".to_string()),
Just("id: x\n".to_string()),
Just("title: Roof\n".to_string()),
Just("not a key line\n".to_string()),
Just("\n".to_string()),
Just("# Heading\n".to_string()),
"\\PC{0,12}",
];
proptest::collection::vec(frag, 0..8).prop_map(|v| v.concat())
}
proptest! {
/// `strip` is total and only ever removes a prefix: the result is
/// always a suffix of the input, and a body with no opening fence is
/// returned untouched.
#[test]
fn strip_returns_a_suffix(body in frontmatterish()) {
let out = strip(&body);
prop_assert!(body.ends_with(out));
if !body.starts_with("---\n") {
prop_assert_eq!(out, body.as_str());
}
}
}
}

View file

@ -218,12 +218,5 @@ mod tests {
let b = Hlc { physical: p2, counter: c2, origin: o2 };
prop_assert_eq!(a.cmp(&b), a.encode().cmp(&b.encode()));
}
/// Sync cursors arrive over the wire — parsing arbitrary strings must
/// return an error, never panic.
#[test]
fn parse_never_panics(s in "\\PC{0,60}") {
let _ = Hlc::parse(&s);
}
}
}

View file

@ -16,9 +16,6 @@ pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("HEPH_BU
pub mod clock;
mod crdt;
/// Public CRDT wrappers for the cargo-fuzz targets (`fuzzing` feature only).
#[cfg(feature = "fuzzing")]
pub use crdt::fuzz as crdt_fuzz;
pub mod error;
pub mod export;
pub mod extract;

View file

@ -19,10 +19,6 @@ pub mod op_type {
pub const NODE_SET: &str = "node.set";
/// A node was tombstoned. Payload: `{}`.
pub const NODE_TOMBSTONE: &str = "node.tombstone";
/// A tombstoned node was restored. Payload: `{}`. Tombstone/restore are an
/// LWW pair keyed by their own op HLCs: the latest of the two op kinds
/// targeting a node decides its tombstone state on merge.
pub const NODE_RESTORE: &str = "node.restore";
/// 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.

View file

@ -181,31 +181,5 @@ mod tests {
let once = reset_checkboxes(&body);
prop_assert_eq!(reset_checkboxes(&once), once);
}
/// For an infinite rule, the next occurrence exists and is strictly
/// after `after` — roll-forward can never schedule into the past.
#[test]
fn next_is_strictly_after(
freq in proptest::sample::select(vec!["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]),
interval in 1u32..5,
gap_days in 0i64..400,
) {
let rrule = format!("FREQ={freq};INTERVAL={interval}");
let after = JAN1 + gap_days * ONE_DAY;
let next = next_occurrence(&rrule, JAN1, after).unwrap();
let t = next.expect("infinite rule always has a next instance");
prop_assert!(t > after);
}
/// RRULEs are stored strings that may come from old data or other
/// writers — arbitrary input must error, never panic.
#[test]
fn arbitrary_rrule_never_panics(
s in "\\PC{0,60}",
anchor in proptest::num::i64::ANY,
after in proptest::num::i64::ANY,
) {
let _ = next_occurrence(&s, anchor, after);
}
}
}

View file

@ -9,10 +9,7 @@
//! 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:** an LWW pair with restores — the latest
//! `node.tombstone`/`node.restore` op (by its own HLC, read from the op-log)
//! decides a node's tombstone state. With no restore in play this degrades
//! to the old monotonic rule: a tombstone stays.
//! - **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.
@ -22,9 +19,9 @@ use serde_json::Value;
use super::{absorb_remote_hlc, new_id, nodes, ops};
use crate::crdt;
use crate::error::{Error, Result};
use crate::error::Result;
use crate::hlc::Hlc;
use crate::model::{Conflict, TaskState};
use crate::model::Conflict;
use crate::oplog::{op_type, Op};
/// Open conflicts for `owner`, newest first.
@ -49,71 +46,14 @@ pub(super) fn list_conflicts(conn: &Connection, owner: &str) -> Result<Vec<Confl
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Settle a conflict by the user's choice (`"local"`/`"remote"`): the chosen
/// value is **applied** to the task and recorded as a new `task.set` op (so
/// peers converge on the decision), then the row is marked resolved. The LWW
/// winner may have been either side, so the chosen value is written
/// unconditionally — a no-op write when it already matches.
pub(super) fn resolve_conflict(
conn: &mut Connection,
owner: &str,
now: i64,
id: &str,
choice: &str,
) -> Result<()> {
let row: Option<(String, String, Option<String>, Option<String>)> = conn
.query_row(
"SELECT node_id, field, local_val, remote_val
FROM conflicts WHERE id = ?1 AND status = 'open'",
[id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()?;
let Some((node_id, field, local_val, remote_val)) = row else {
return Err(Error::InvalidArg(format!("no open conflict {id}")));
};
let chosen = match choice {
"local" => local_val,
"remote" => remote_val,
other => {
return Err(Error::InvalidArg(format!(
"choice must be \"local\" or \"remote\", got {other:?}"
)))
}
};
let tx = conn.transaction()?;
match field.as_str() {
"do_date" => {
let v: Option<i64> =
chosen.as_deref().map(str::parse).transpose().map_err(|_| {
Error::Integrity(format!("conflict {id}: do_date not an integer"))
})?;
tx.execute(
"UPDATE tasks SET do_date = ?1 WHERE node_id = ?2",
(v, &node_id),
)?;
}
"state" => {
let v = chosen.as_deref().unwrap_or("outstanding");
TaskState::parse(v)?; // validate before writing the raw string
tx.execute(
"UPDATE tasks SET state = ?1 WHERE node_id = ?2",
(v, &node_id),
)?;
}
other => {
return Err(Error::Integrity(format!(
"conflict {id} has unknown field {other:?}"
)))
}
}
super::tasks::record_set(&tx, owner, now, &node_id)?;
tx.execute(
/// 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],
)?;
tx.commit()?;
Ok(())
}
@ -138,8 +78,8 @@ pub(super) fn apply(conn: &mut Connection, op: &Op) -> Result<bool> {
node_upsert(&tx, op)?;
true
}
op_type::NODE_TOMBSTONE | op_type::NODE_RESTORE => {
node_tombstone_state(&tx, op)?;
op_type::NODE_TOMBSTONE => {
node_tombstone(&tx, op)?;
true
}
op_type::TASK_CREATE | op_type::TASK_SET => {
@ -264,44 +204,19 @@ fn node_upsert(tx: &Connection, op: &Op) -> Result<()> {
Ok(())
}
/// Merge a `node.tombstone` or `node.restore` op. The two are an LWW pair
/// keyed by their **own** op HLCs (not the node's `hlc`, which unrelated
/// scalar ops bump): the latest tombstone-state op targeting the node decides.
/// Derived from the op-log already on hand, so no schema change (see
/// [[hub-spoke-data-evolution]]). Bump the node hlc only if this op is newer.
fn node_tombstone_state(tx: &Connection, op: &Op) -> Result<()> {
let Some(existing) = nodes::get(tx, &op.target_id)? else {
return Ok(());
};
// The latest tombstone-state op we already know of (this op isn't in the
// log yet — `apply` inserts it after the handlers run).
let known: Option<(String, String)> = tx
.query_row(
"SELECT hlc, op_type FROM oplog
WHERE target_id = ?1 AND op_type IN (?2, ?3)
ORDER BY hlc DESC LIMIT 1",
(
&op.target_id,
op_type::NODE_TOMBSTONE,
op_type::NODE_RESTORE,
),
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let winner = match known {
Some((h, t)) if h.as_str() > op.hlc.as_str() => t,
_ => op.op_type.clone(),
};
let tombstoned = winner == op_type::NODE_TOMBSTONE;
let hlc = if op.hlc.as_str() > existing.hlc.as_str() {
op.hlc.clone()
} else {
existing.hlc
};
tx.execute(
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
(tombstoned as i64, op_physical(op), hlc, &op.target_id),
)?;
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(())
}

View file

@ -23,20 +23,9 @@ pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result<usize
let mut count = 0;
for id in ids {
let Some(mut node) = nodes::get(conn, &id)? else {
let Some(node) = nodes::get(conn, &id)? else {
continue;
};
// Expand bare [[NODEID]] links to [[NODEID|Current Name]] (§8.4) so the
// exported markdown reads outside heph; custom labels stay as-authored.
if let Some(body) = node.body.as_deref() {
node.body = Some(crate::wikilink::expand(body, |target| {
nodes::get(conn, target)
.ok()
.flatten()
.filter(|n| !n.tombstoned && n.owner_id == owner)
.map(|n| n.title)
}));
}
let task = if node.kind == NodeKind::Task {
tasks::get(conn, &id)?
} else {

View file

@ -103,17 +103,6 @@ pub(super) fn outgoing(conn: &Connection, id: &str) -> Result<Vec<Link>> {
query(conn, "src_id", id)
}
/// A node's internal attachments — the docs reached by `canonical-context` /
/// `log-of` links — which live and die with it: tombstoning the node cascades
/// to them (no orphaned FTS leftovers) and restoring it revives them.
pub(super) fn attachment_ids(conn: &Connection, src_id: &str) -> Result<Vec<String>> {
Ok(outgoing(conn, src_id)?
.into_iter()
.filter(|l| matches!(l.link_type, LinkType::CanonicalContext | LinkType::LogOf))
.map(|l| l.dst_id)
.collect())
}
/// All non-tombstoned links pointing at `id`.
pub(super) fn backlinks(conn: &Connection, id: &str) -> Result<Vec<Link>> {
query(conn, "dst_id", id)

View file

@ -199,28 +199,7 @@ impl Store for LocalStore {
fn tombstone_node(&mut self, id: &str) -> Result<()> {
let now = self.clock.now_ms();
let tx = self.conn.transaction()?;
// A task's internal attachments (canonical-context doc, log doc) die
// with it — otherwise they linger in FTS as orphans.
let attachments = links::attachment_ids(&tx, id)?;
nodes::tombstone(&tx, &self.owner_id, now, id)?;
for doc in &attachments {
nodes::tombstone(&tx, &self.owner_id, now, doc)?;
}
tx.commit()?;
Ok(())
}
fn restore_node(&mut self, id: &str) -> Result<()> {
let now = self.clock.now_ms();
let tx = self.conn.transaction()?;
let attachments = links::attachment_ids(&tx, id)?;
nodes::restore(&tx, &self.owner_id, now, id)?;
for doc in &attachments {
nodes::restore(&tx, &self.owner_id, now, doc)?;
}
tx.commit()?;
Ok(())
nodes::tombstone(&self.conn, &self.owner_id, now, id)
}
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
@ -269,11 +248,6 @@ impl Store for LocalStore {
tasks::delete_project(&mut self.conn, &self.owner_id, now, project_id)
}
fn reparent_project(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
let now = self.clock.now_ms();
tasks::reparent_project(&mut self.conn, &self.owner_id, now, project_id, parent_id)
}
fn promote(
&mut self,
container_id: &str,
@ -345,27 +319,6 @@ impl Store for LocalStore {
}
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result<Link> {
// The derived/internal link kinds are never created by hand: a manual
// `wiki` row would be silently reconciled away on the next body write
// (the body is the source of truth), and canonical-context / log-of
// are task-creation internals. Internal materialization goes through
// `links::add` directly and is unaffected.
match link_type {
LinkType::Wiki => {
return Err(Error::InvalidArg(
"wiki links are derived from the body — add [[<dst>]] to the node's body \
instead (e.g. heph context <task> --append)"
.into(),
))
}
LinkType::CanonicalContext | LinkType::LogOf => {
return Err(Error::InvalidArg(format!(
"{} links are internal task attachments and cannot be added by hand",
link_type.as_str()
)))
}
_ => {}
}
let now = self.clock.now_ms();
links::add(&self.conn, &self.owner_id, now, src_id, dst_id, link_type)
}
@ -511,9 +464,7 @@ impl Store for LocalStore {
}
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()> {
let now = self.clock.now_ms();
let owner = self.owner_id.clone();
apply::resolve_conflict(&mut self.conn, &owner, now, id, choice)
apply::resolve_conflict(&self.conn, id, choice)
}
}

View file

@ -431,32 +431,17 @@ pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3); a
/// tombstone can be undone by [`restore`], the two merging as an LWW pair by
/// op HLC.
/// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge
/// monotonic (tech-spec §4.3).
pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, id: &str) -> Result<()> {
set_tombstoned(conn, owner, now, id, true)
}
/// Restore (un-tombstone) a node — the undo of [`tombstone`].
pub(super) fn restore(conn: &Connection, owner: &str, now: i64, id: &str) -> Result<()> {
set_tombstoned(conn, owner, now, id, false)
}
fn set_tombstoned(conn: &Connection, owner: &str, now: i64, id: &str, dead: bool) -> Result<()> {
let hlc = next_hlc(conn, now)?;
let updated = conn.execute(
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
(dead as i64, now, &hlc, id),
"UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3",
(now, &hlc, id),
)?;
if updated == 0 {
return Err(Error::NodeNotFound(id.to_string()));
}
let op = if dead {
op_type::NODE_TOMBSTONE
} else {
op_type::NODE_RESTORE
};
ops::record(conn, owner, &hlc, op, id, json!({}))?;
ops::record(conn, owner, &hlc, op_type::NODE_TOMBSTONE, id, json!({}))?;
Ok(())
}

View file

@ -33,7 +33,7 @@ fn scalar_payload(t: &Task) -> serde_json::Value {
/// 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).
pub(super) fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> {
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(
@ -662,51 +662,6 @@ pub(super) fn delete_project(
Ok(())
}
/// Change a project's parent (re-parent), or detach it to the root when
/// `parent_id` is `None` — the post-creation counterpart of
/// `project add --parent` (§8.1). OR-set link semantics: tombstone the
/// project's existing `parent` links, then add the new one. Rejects
/// non-project endpoints and any `parent_id` inside the project's own subtree
/// (which includes itself), so the tree stays acyclic.
pub(super) fn reparent_project(
conn: &mut Connection,
owner: &str,
now: i64,
project_id: &str,
parent_id: Option<&str>,
) -> Result<()> {
let project =
nodes::get(conn, project_id)?.ok_or_else(|| Error::NodeNotFound(project_id.into()))?;
if project.tombstoned || project.kind != NodeKind::Project {
return Err(Error::InvalidArg(format!(
"{project_id} is not a project node"
)));
}
if let Some(pid) = parent_id {
let parent = nodes::get(conn, pid)?.ok_or_else(|| Error::NodeNotFound(pid.into()))?;
if parent.tombstoned || parent.kind != NodeKind::Project {
return Err(Error::InvalidArg(format!("{pid} is not a project node")));
}
if links::project_subtree(conn, project_id)?.contains(&pid.to_string()) {
return Err(Error::InvalidArg(format!(
"re-parenting {project_id} under {pid} would create a cycle"
)));
}
}
let tx = conn.transaction()?;
for link in links::outgoing(&tx, project_id)? {
if link.link_type == LinkType::Parent {
links::tombstone(&tx, owner, now, &link.id)?;
}
}
if let Some(pid) = parent_id {
links::add(&tx, owner, now, project_id, pid, LinkType::Parent)?;
}
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` =

View file

@ -38,15 +38,9 @@ pub trait Store {
body: Option<String>,
) -> Result<Node>;
/// Tombstone (soft-delete) a node, cascading to its internal attachments
/// (canonical-context doc, log doc). No hard deletes (tech-spec §4.3).
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3).
fn tombstone_node(&mut self, id: &str) -> Result<()>;
/// Restore (un-tombstone) a node and its internal attachments — the undo
/// of [`Store::tombstone_node`]. On merge, tombstone/restore are an LWW
/// pair keyed by their own op HLCs: the later op decides.
fn restore_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`).
@ -104,12 +98,6 @@ pub trait Store {
/// the project node. Tasks are preserved, never deleted.
fn delete_project(&mut self, project_id: &str) -> Result<()>;
/// Change a project's parent, or detach it to the root when `parent_id` is
/// `None` — the post-creation counterpart of `project add --parent`
/// (tech-spec §8.1). Rejects non-project endpoints and cycle-creating
/// moves.
fn reparent_project(&mut self, project_id: &str, parent_id: Option<&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
@ -257,8 +245,6 @@ pub trait Store {
/// Open merge conflicts surfaced for the user (`heph conflicts`).
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
/// Settle a conflict by the user's choice (`"local"`/`"remote"`): the
/// chosen value is applied to the task and recorded as a new op, so peers
/// converge on the decision; the conflict row is marked resolved.
/// Settle a conflict by the user's choice (`"local"`/`"remote"`).
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>;
}

View file

@ -148,41 +148,4 @@ mod tests {
assert_eq!(expand("dangling [[01ID", &t), "dangling [[01ID");
assert_eq!(expand("", &t), "");
}
use proptest::prelude::*;
/// Bodies stitched from canonical link forms (bare/labelled/legacy), broken
/// fences, and bracket-free filler. Targets are unpadded — at-rest links
/// are canonical — so the collapse∘expand law below holds exactly.
fn linky() -> impl Strategy<Value = String> {
let frag = prop_oneof![
Just("[[01ID]]".to_string()),
Just("[[02ID]]".to_string()),
Just("[[01ID|Roof]]".to_string()),
Just("[[02ID|Garden]]".to_string()),
Just("[[01ID|custom label]]".to_string()),
Just("[[unknown]]".to_string()),
Just("[[Some Title|text]]".to_string()),
Just("[[".to_string()),
Just("]]".to_string()),
Just(" plain text ".to_string()),
"[^\\[\\]]{0,10}",
];
proptest::collection::vec(frag, 0..10).prop_map(|v| v.concat())
}
proptest! {
/// Both projections are idempotent, and the read→write round-trip law
/// holds: what a client echoes back after an expand collapses to the
/// same at-rest body a direct collapse would produce.
#[test]
fn expand_collapse_idempotent_and_round_trip(body in linky()) {
let t = titles();
let e = expand(&body, &t);
prop_assert_eq!(expand(&e, &t), e.clone(), "expand not idempotent");
let c = collapse(&body, &t);
prop_assert_eq!(collapse(&c, &t), c.clone(), "collapse not idempotent");
prop_assert_eq!(collapse(&e, &t), c, "collapse(expand(x)) != collapse(x)");
}
}
}

View file

@ -267,134 +267,3 @@ fn tombstones_propagate_and_are_monotonic() {
// Tombstoned nodes drop out of search/next on B.
assert!(b.search("doomed").unwrap().is_empty());
}
#[test]
fn restore_undoes_a_tombstone_and_propagates() {
let (mut a, ca) = replica(1000);
let (mut b, _cb) = replica(1000);
let n = a.create_node(NewNode::doc("phoenix", "rises")).unwrap();
sync_one_way(&a, &mut b, None);
ca.set(2000);
a.tombstone_node(&n.id).unwrap();
ca.set(3000);
a.restore_node(&n.id).unwrap();
sync_one_way(&a, &mut b, None);
assert!(!a.get_node(&n.id).unwrap().unwrap().tombstoned);
assert!(!b.get_node(&n.id).unwrap().unwrap().tombstoned);
// Back in search on both replicas.
assert!(!b.search("phoenix").unwrap().is_empty());
}
#[test]
fn concurrent_tombstone_and_restore_converge_to_the_later_op() {
// Tombstone/restore are an LWW pair keyed by their own op HLCs: whichever
// was written later wins on every replica, regardless of arrival order.
let (mut a, ca) = replica(1000);
let (mut b, cb) = replica(1000);
let n = a.create_node(NewNode::doc("contested", "")).unwrap();
ca.set(1500);
a.tombstone_node(&n.id).unwrap();
sync_one_way(&a, &mut b, None);
// Offline: A restores at t=2000; B re-tombstones later at t=3000.
ca.set(2000);
a.restore_node(&n.id).unwrap();
cb.set(3000);
b.restore_node(&n.id).unwrap();
cb.set(3500);
b.tombstone_node(&n.id).unwrap();
sync_one_way(&a, &mut b, None);
sync_one_way(&b, &mut a, None);
// B's tombstone (t=3500) is the latest tombstone-state op → dead on both.
assert!(a.get_node(&n.id).unwrap().unwrap().tombstoned);
assert!(b.get_node(&n.id).unwrap().unwrap().tombstoned);
// And the mirror image: a restore written later than a tombstone wins.
let (mut c, cc) = replica(1000);
let (mut d, cd) = replica(1000);
let m = c.create_node(NewNode::doc("survivor", "")).unwrap();
sync_one_way(&c, &mut d, None);
cc.set(2000);
c.tombstone_node(&m.id).unwrap();
cd.set(3000);
d.tombstone_node(&m.id).unwrap();
cd.set(3500);
d.restore_node(&m.id).unwrap();
sync_one_way(&c, &mut d, None);
sync_one_way(&d, &mut c, None);
assert!(!c.get_node(&m.id).unwrap().unwrap().tombstoned);
assert!(!d.get_node(&m.id).unwrap().unwrap().tombstoned);
}
#[test]
fn resolving_a_conflict_applies_the_chosen_value_and_propagates() {
let (mut a, ca) = replica(1000);
let (mut b, cb) = replica(1000);
let task = a
.create_task(NewTask {
title: "Water plants".into(),
do_date: Some(5_000),
..Default::default()
})
.unwrap();
sync_one_way(&a, &mut b, None);
// Divergent offline do_dates; B's is later → wins LWW on both sides.
ca.set(2000);
a.set_task_schedule(
&task.node_id,
heph_core::SchedulePatch {
do_date: Some(Some(7_000)),
..Default::default()
},
)
.unwrap();
cb.set(3000);
b.set_task_schedule(
&task.node_id,
heph_core::SchedulePatch {
do_date: Some(Some(9_000)),
..Default::default()
},
)
.unwrap();
sync_one_way(&a, &mut b, None);
sync_one_way(&b, &mut a, None);
assert_eq!(
a.get_task(&task.node_id).unwrap().unwrap().do_date,
Some(9_000)
);
// A resolves its conflict keeping the LOCAL (losing) value: the choice is
// applied, not just recorded…
let conflict = a
.conflicts_list()
.unwrap()
.into_iter()
.find(|c| c.field == "do_date")
.expect("A recorded a do_date conflict");
ca.set(4000);
a.conflicts_resolve(&conflict.id, "local").unwrap();
assert_eq!(
a.get_task(&task.node_id).unwrap().unwrap().do_date,
Some(7_000)
);
assert!(
a.conflicts_list()
.unwrap()
.iter()
.all(|c| c.id != conflict.id),
"resolved conflict no longer listed"
);
// …and the decision is an op, so B converges to it.
sync_one_way(&a, &mut b, None);
assert_eq!(
b.get_task(&task.node_id).unwrap().unwrap().do_date,
Some(7_000)
);
}

View file

@ -56,24 +56,3 @@ fn export_excludes_tombstoned_nodes() {
assert!(dir.path().join(format!("doc/{}.md", keep.id)).exists());
assert!(!dir.path().join(format!("doc/{}.md", gone.id)).exists());
}
#[test]
fn export_expands_bare_id_wiki_links_to_readable_labels() {
let dir = tempfile::tempdir().unwrap();
let mut s = store();
let target = s.create_node(NewNode::doc("Roof", "shingles")).unwrap();
let body = format!(
"see [[{id}]] and [[{id}|my label]] and [[Unknown]]",
id = target.id
);
let doc = s.create_node(NewNode::doc("Notes", &body)).unwrap();
s.export(dir.path()).unwrap();
let text = std::fs::read_to_string(dir.path().join(format!("doc/{}.md", doc.id))).unwrap();
// Bare known id gains a readable label; a custom label and an
// unresolvable target are left as-authored.
assert!(text.contains(&format!("[[{}|Roof]]", target.id)), "{text}");
assert!(text.contains(&format!("[[{}|my label]]", target.id)));
assert!(text.contains("[[Unknown]]"));
}

View file

@ -414,111 +414,3 @@ fn active_wiki(s: &LocalStore, id: &str) -> usize {
.filter(|l| l.link_type == LinkType::Wiki)
.count()
}
#[test]
fn tombstoning_a_task_cascades_to_its_context_doc_and_restore_revives() {
let mut s = store();
let task = s
.create_task(NewTask {
title: "Fix gate".into(),
..Default::default()
})
.unwrap();
let ctx = s
.outgoing_links(&task.node_id)
.unwrap()
.into_iter()
.find(|l| l.link_type == LinkType::CanonicalContext)
.expect("task has a canonical context doc")
.dst_id;
// Give the context doc a body so it's findable in search.
s.update_node(&ctx, None, Some("latch is rusted".into()))
.unwrap();
assert!(!s.search("latch").unwrap().is_empty());
s.tombstone_node(&task.node_id).unwrap();
assert!(
s.get_node(&ctx).unwrap().unwrap().tombstoned,
"context doc dies with its task"
);
assert!(s.search("latch").unwrap().is_empty(), "no FTS leftover");
s.restore_node(&task.node_id).unwrap();
assert!(!s.get_node(&task.node_id).unwrap().unwrap().tombstoned);
assert!(
!s.get_node(&ctx).unwrap().unwrap().tombstoned,
"restore revives the attachment"
);
}
#[test]
fn reparent_project_moves_detaches_and_guards_cycles() {
let mut s = store();
let mk = |s: &mut LocalStore, title: &str| {
s.create_node(NewNode {
kind: NodeKind::Project,
title: title.into(),
body: None,
})
.unwrap()
.id
};
let coding = mk(&mut s, "Coding");
let heph = mk(&mut s, "Hephaestus");
let sub = mk(&mut s, "Subproject");
// Subproject starts under Hephaestus (child holds the parent link).
s.add_link(&sub, &heph, LinkType::Parent).unwrap();
let parent_of = |s: &LocalStore, title: &str| {
s.project_overview()
.unwrap()
.into_iter()
.find(|p| p.title == title)
.unwrap()
.parent_id
};
// Move Hephaestus under Coding.
s.reparent_project(&heph, Some(&coding)).unwrap();
assert_eq!(parent_of(&s, "Hephaestus"), Some(coding.clone()));
// Detach back to the root.
s.reparent_project(&heph, None).unwrap();
assert_eq!(parent_of(&s, "Hephaestus"), None);
// Cycle guard: with Coding > Hephaestus > Subproject, moving Coding under
// Subproject would loop — rejected, as is self-parenting.
s.reparent_project(&heph, Some(&coding)).unwrap();
assert!(s.reparent_project(&coding, Some(&sub)).is_err());
assert!(s.reparent_project(&heph, Some(&heph)).is_err());
// Only live project nodes may be parents (or children).
let task = s
.create_task(NewTask {
title: "not a project".into(),
..Default::default()
})
.unwrap();
assert!(s.reparent_project(&heph, Some(&task.node_id)).is_err());
assert!(s.reparent_project(&task.node_id, None).is_err());
}
#[test]
fn add_link_rejects_derived_and_internal_link_types() {
let mut s = store();
let a = s.create_node(NewNode::doc("A", "")).unwrap();
let b = s.create_node(NewNode::doc("B", "")).unwrap();
// `wiki` rows are derived from the body (and reconciled away on the next
// body write); canonical-context / log-of are task-creation internals.
for t in [LinkType::Wiki, LinkType::CanonicalContext, LinkType::LogOf] {
let err = s.add_link(&a.id, &b.id, t).unwrap_err().to_string();
assert!(
err.contains("cannot be added by hand") || err.contains("derived from the body"),
"{t:?}: {err}"
);
}
// The explicit relationship types still work.
s.add_link(&a.id, &b.id, LinkType::Blocks).unwrap();
}

View file

@ -107,12 +107,6 @@ enum InputKind {
},
/// Full-text search query.
Search,
/// Rename the task (and its same-named canonical-context doc).
Rename {
task_id: String,
context_id: Option<String>,
before: String,
},
}
/// An active full-text search: the query, its hits, and the highlighted row.
@ -124,31 +118,6 @@ pub struct SearchView {
pub cursor: usize,
}
/// One row of the conflicts view: the conflict + the node's title for display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictItem {
pub conflict: heph_core::Conflict,
pub title: String,
}
/// The open-conflicts review (`C`): the rows and the highlighted one. While
/// `Some`, the center pane lists conflicts; `l`/`r` settle the highlighted one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictsView {
pub items: Vec<ConflictItem>,
pub cursor: usize,
}
/// The full task-log view (`L`): every log line for one task, scrollable —
/// the preview pane shows only the last few. While `Some`, it replaces the
/// center pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogView {
pub title: String,
pub lines: Vec<String>,
pub scroll: usize,
}
/// 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)]
@ -204,7 +173,8 @@ impl From<&RankedTask> for TaskSnapshot {
}
/// The original triage action, kept alongside its before-snapshot so redo can
/// re-apply it without re-reading state.
/// 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"
@ -213,53 +183,22 @@ enum TriageAction {
Move(Option<String>), // re-file (or unfile) to a project id
}
/// One reversible step. Scalar triage carries a before-snapshot; the
/// structural actions (delete, rename, re-parent) carry exactly what their
/// inverse needs.
/// One reversible step: the task state before it + the action that changed it.
#[derive(Debug, Clone, PartialEq, Eq)]
enum UndoEntry {
/// A scalar triage action: the task state before it + the action.
Triage {
before: TaskSnapshot,
action: TriageAction,
},
/// A task delete; undone by `node.restore` (which also revives the
/// canonical-context doc).
DeleteTask { task_id: String, title: String },
/// A project delete; undone by restoring the project node and re-filing
/// the tasks that were unfiled by the delete.
DeleteProject {
project_id: String,
title: String,
filed: Vec<String>,
},
/// A rename of a task (and its same-named context doc, when it has one).
Rename {
task_id: String,
context_id: Option<String>,
before: String,
after: String,
},
/// A project re-parent; undone by re-parenting back.
Reparent {
project_id: String,
title: String,
before_parent: Option<String>,
after_parent: Option<String>,
},
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 picker.
/// One choice in the move-to-project picker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveOption {
/// Remove the task from any project.
Unfile,
/// Detach the project to the root (re-parent mode's "no parent").
Root,
/// File under (or re-parent to) an existing project.
/// 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 },
@ -270,59 +209,40 @@ impl MoveOption {
pub fn label(&self) -> String {
match self {
MoveOption::Unfile => "(Unfile)".to_string(),
MoveOption::Root => "(Move to root)".to_string(),
MoveOption::Project { title, .. } => title.clone(),
MoveOption::Create { name } => format!("+ New project \"{name}\""),
}
}
}
/// What the move picker is moving — a task being re-filed, or a project being
/// re-parented. Each carries what its undo needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveKind {
/// Re-file a task; `before` is its snapshot for undo.
Task { before: TaskSnapshot },
/// Re-parent a project; `current_parent` for undo.
Project { current_parent: Option<String> },
}
/// The move picker state: the subject (task or project), a live filter over the
/// candidate projects, the matching choices, and the highlighted row. The picker
/// is fzf-style — typing narrows the list; in task mode a non-matching name
/// offers to create.
/// 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 {
/// The node being moved (a task id in task mode, a project id otherwise).
pub subject_id: String,
pub subject_title: String,
pub kind: MoveKind,
/// The candidate projects, title-sorted — the source the `filter` narrows.
/// In re-parent mode the subject and its descendants are pre-excluded.
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).
pub filter: String,
/// The currently visible choices, recomputed whenever `filter` changes.
/// 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`: the no-project row
/// (`(Unfile)` / `(Move to root)`), the fuzzy-matching projects, and — in
/// task mode, when the text names no existing project — a "create" row.
/// Clamps the cursor.
/// 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 is_task = matches!(self.kind, MoveKind::Task { .. });
let (none_opt, none_label) = if is_task {
(MoveOption::Unfile, "Unfile")
} else {
(MoveOption::Root, "Root")
};
let mut opts = Vec::new();
if f.is_empty() || fuzzy_match(f, none_label) {
opts.push(none_opt);
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) {
@ -332,8 +252,7 @@ impl MoveState {
});
}
}
if is_task
&& !f.is_empty()
if !f.is_empty()
&& !self
.projects
.iter()
@ -509,10 +428,6 @@ pub struct App<B: Backend> {
pub sort_mode: SortMode,
/// When `Some`, a full-text search overlays the task list.
pub search: Option<SearchView>,
/// When `Some`, the open-conflicts review overlays the task list.
pub conflicts_view: Option<ConflictsView>,
/// When `Some`, a task's full log overlays the task list.
pub log_view: Option<LogView>,
/// When `Some`, a delete is awaiting y/N confirmation.
pub pending_delete: Option<PendingDelete>,
/// When `true`, an attention chord is in progress: `a` was pressed and the
@ -559,8 +474,6 @@ impl<B: Backend> App<B> {
mode: Mode::Normal,
sort_mode: SortMode::Default,
search: None,
conflicts_view: None,
log_view: None,
pending_delete: None,
pending_attention: false,
undo_stack: Vec::new(),
@ -859,79 +772,22 @@ impl<B: Backend> App<B> {
// --- undo / redo (`u` / Ctrl-z) ---
/// Record a reversible scalar-triage step.
/// Record a reversible step and invalidate the redo stack.
fn push_undo(&mut self, before: TaskSnapshot, action: TriageAction) {
self.push_entry(UndoEntry::Triage { before, action });
}
/// Record any reversible step and invalidate the redo stack.
fn push_entry(&mut self, entry: UndoEntry) {
self.undo_stack.push(entry);
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 reversible action.
/// 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;
};
match &entry {
UndoEntry::Triage { before, .. } => {
let (before, status) = (before.clone(), format!("undo: {}", before.title));
self.restore(&before, status);
}
UndoEntry::DeleteTask { task_id, title } => {
let id = task_id.clone();
self.mutate(format!("undo delete: {title}"), move |b| b.restore(&id));
}
UndoEntry::DeleteProject {
project_id,
title,
filed,
} => {
let (id, filed) = (project_id.clone(), filed.clone());
self.mutate(format!("undo delete project: {title}"), move |b| {
b.restore(&id)?;
// The delete unfiled these tasks; put them back.
for t in &filed {
b.set_project(t, Some(&id))?;
}
Ok(())
});
self.rebuild_projects();
}
UndoEntry::Rename {
task_id,
context_id,
before,
..
} => {
let (id, ctx, title) = (task_id.clone(), context_id.clone(), before.clone());
self.mutate(format!("undo rename: {title}"), move |b| {
b.rename(&id, &title)?;
if let Some(ctx) = &ctx {
b.rename(ctx, &title)?;
}
Ok(())
});
}
UndoEntry::Reparent {
project_id,
title,
before_parent,
..
} => {
let (id, parent) = (project_id.clone(), before_parent.clone());
self.mutate(format!("undo move: {title}"), move |b| {
b.reparent(&id, parent.as_deref())
});
self.rebuild_projects();
}
}
self.restore(&entry.before, format!("undo: {}", entry.before.title));
self.redo_stack.push(entry);
}
@ -941,52 +797,8 @@ impl<B: Backend> App<B> {
self.status = "nothing to redo".into();
return;
};
match &entry {
UndoEntry::Triage { before, action } => {
let status = format!("redo: {}", before.title);
self.apply_action(before.task_id.clone(), action.clone(), status);
}
UndoEntry::DeleteTask { task_id, title } => {
let id = task_id.clone();
self.mutate(format!("redo delete: {title}"), move |b| b.tombstone(&id));
}
UndoEntry::DeleteProject {
project_id, title, ..
} => {
let id = project_id.clone();
self.mutate(format!("redo delete project: {title}"), move |b| {
b.delete_project(&id)
});
self.rebuild_projects();
}
UndoEntry::Rename {
task_id,
context_id,
after,
..
} => {
let (id, ctx, title) = (task_id.clone(), context_id.clone(), after.clone());
self.mutate(format!("redo rename: {title}"), move |b| {
b.rename(&id, &title)?;
if let Some(ctx) = &ctx {
b.rename(ctx, &title)?;
}
Ok(())
});
}
UndoEntry::Reparent {
project_id,
title,
after_parent,
..
} => {
let (id, parent) = (project_id.clone(), after_parent.clone());
self.mutate(format!("redo move: {title}"), move |b| {
b.reparent(&id, parent.as_deref())
});
self.rebuild_projects();
}
}
let status = format!("redo: {}", entry.before.title);
self.apply_action(entry.before.task_id.clone(), entry.action.clone(), status);
self.undo_stack.push(entry);
}
@ -1050,39 +862,16 @@ impl<B: Backend> App<B> {
}
}
/// Confirm the armed delete: tombstone the task or project, record the
/// undo entry, and reload.
/// Confirm the armed delete: tombstone the task or project and reload.
pub fn confirm_delete(&mut self) {
match self.pending_delete.take() {
Some(PendingDelete::Task { task_id, title }) => {
self.push_entry(UndoEntry::DeleteTask {
task_id: task_id.clone(),
title: title.clone(),
});
self.mutate(format!("deleted: {title} (u to undo)"), |b| {
b.tombstone(&task_id)
});
self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id));
}
Some(PendingDelete::Project { project_id, title }) => {
// Snapshot which tasks the delete is about to unfile, so undo
// can re-file them after restoring the project node.
let filed: Vec<String> = self
.backend
.list(&heph_core::ListFilter {
scope: vec![project_id.clone()],
..Default::default()
})
.map(|ts| ts.into_iter().map(|t| t.node_id).collect())
.unwrap_or_default();
self.push_entry(UndoEntry::DeleteProject {
project_id: project_id.clone(),
title: title.clone(),
filed,
self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| {
b.delete_project(&project_id)
});
self.mutate(
format!("deleted project: {title} (tasks → Inbox; u to undo)"),
|b| b.delete_project(&project_id),
);
self.rebuild_projects();
self.reload();
}
@ -1106,11 +895,9 @@ impl<B: Backend> App<B> {
return;
};
let mut state = MoveState {
subject_id: t.node_id.clone(),
subject_title: t.title.clone(),
kind: MoveKind::Task {
before: TaskSnapshot::from(&t),
},
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(),
@ -1129,64 +916,6 @@ impl<B: Backend> App<B> {
self.mode = Mode::MoveToProject(state);
}
/// Open the picker in re-parent mode for the sidebar-selected project.
/// Candidates exclude the project itself and its descendants (a tree can't
/// contain itself); "(Move to root)" detaches it.
pub fn begin_reparent(&mut self) {
let Some(SidebarEntry::Project { id, title, .. }) =
self.sidebar.get(self.sidebar_cursor).cloned()
else {
self.status = "select a project in the sidebar to move".into();
return;
};
let overview = match self.backend.project_overview() {
Ok(o) => o,
Err(e) => {
self.status = format!("error: {e}");
return;
}
};
let current_parent = overview
.iter()
.find(|p| p.id == id)
.and_then(|p| p.parent_id.clone());
// The subject's subtree (itself + descendants) can't be its new parent.
let mut excluded: std::collections::HashSet<String> = [id.clone()].into();
loop {
let before = excluded.len();
for p in &overview {
if p.parent_id
.as_deref()
.is_some_and(|pp| excluded.contains(pp))
{
excluded.insert(p.id.clone());
}
}
if excluded.len() == before {
break;
}
}
let projects: Vec<Project> = overview
.into_iter()
.filter(|p| !excluded.contains(&p.id))
.map(|p| Project {
id: p.id,
title: p.title,
})
.collect();
let mut state = MoveState {
subject_id: id,
subject_title: title,
kind: MoveKind::Project { current_parent },
projects,
filter: String::new(),
options: Vec::new(),
cursor: 0,
};
state.recompute();
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 {
@ -1213,8 +942,8 @@ impl<B: Backend> App<B> {
}
}
/// Apply the highlighted choice and reload. Task mode: unfile, re-file, or
/// create-then-file. Project mode: re-parent (or detach to the root).
/// 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;
@ -1222,53 +951,30 @@ impl<B: Backend> App<B> {
let Some(choice) = m.options.get(m.cursor).cloned() else {
return;
};
let subject_id = m.subject_id.clone();
let title = m.subject_title.clone();
let kind = m.kind.clone();
let task_id = m.task_id.clone();
let title = m.task_title.clone();
let before = m.before.clone();
self.mode = Mode::Normal;
match kind {
MoveKind::Task { before } => match choice {
MoveOption::Unfile => {
self.push_undo(before, TriageAction::Move(None));
self.mutate(format!("→ (Unfile): {title}"), |b| {
b.set_project(&subject_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(&subject_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(&subject_id, Some(&id))
});
self.rebuild_projects();
}
MoveOption::Root => {}
},
MoveKind::Project { current_parent } => {
let after = match choice {
MoveOption::Root => None,
MoveOption::Project { id, .. } => Some(id),
// Unfile/Create are not offered in re-parent mode.
_ => return,
};
self.push_entry(UndoEntry::Reparent {
project_id: subject_id.clone(),
title: title.clone(),
before_parent: current_parent,
after_parent: after.clone(),
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))
});
let status = match &after {
Some(_) => format!("moved project: {title}"),
None => format!("moved project to root: {title}"),
};
self.mutate(status, move |b| b.reparent(&subject_id, after.as_deref()));
self.rebuild_projects();
}
}
@ -1358,23 +1064,6 @@ impl<B: Backend> App<B> {
});
}
/// Start renaming the highlighted task in place (`R`). The buffer is
/// prefilled with the current title for editing.
pub fn begin_rename(&mut self) {
let Some(t) = self.selected_task() else {
return;
};
self.mode = Mode::Input(InputState {
prompt: "Rename task".into(),
buffer: t.title.clone(),
kind: InputKind::Rename {
task_id: t.node_id.clone(),
context_id: t.canonical_context_id.clone(),
before: t.title.clone(),
},
});
}
/// Open the full-text search prompt.
pub fn begin_search(&mut self) {
self.mode = Mode::Input(InputState {
@ -1389,114 +1078,6 @@ impl<B: Backend> App<B> {
self.search = None;
}
// --- conflicts review (`C`) ---
/// Open (or refresh) the conflicts view: fetch the open conflicts and label
/// each with its node's title.
pub fn open_conflicts(&mut self) {
match self.backend.conflicts() {
Ok(rows) => {
let items: Vec<ConflictItem> = rows
.into_iter()
.map(|conflict| {
let title = self
.backend
.node_title(&conflict.node_id)
.ok()
.flatten()
.unwrap_or_else(|| conflict.node_id.clone());
ConflictItem { conflict, title }
})
.collect();
let cursor = self
.conflicts_view
.as_ref()
.map(|v| v.cursor.min(items.len().saturating_sub(1)))
.unwrap_or(0);
self.status = if items.is_empty() {
"no open conflicts".into()
} else {
format!("{} open conflict(s)", items.len())
};
self.conflicts_view = Some(ConflictsView { items, cursor });
}
Err(e) => self.status = format!("error: {e}"),
}
}
/// Close the conflicts view.
pub fn close_conflicts(&mut self) {
self.conflicts_view = None;
}
/// Move the conflicts cursor by `delta` (clamped).
pub fn conflicts_move(&mut self, delta: isize) {
if let Some(v) = &mut self.conflicts_view {
if v.items.is_empty() {
return;
}
let max = v.items.len() as isize - 1;
v.cursor = (v.cursor as isize + delta).clamp(0, max) as usize;
}
}
/// Settle the highlighted conflict keeping the `"local"` or `"remote"`
/// value, then refresh the list (and the status-line conflict chip).
pub fn conflicts_resolve(&mut self, choice: &str) {
let Some(item) = self
.conflicts_view
.as_ref()
.and_then(|v| v.items.get(v.cursor))
.cloned()
else {
return;
};
match self.backend.resolve_conflict(&item.conflict.id, choice) {
Ok(()) => {
self.status = format!("kept {choice}: {} ({})", item.title, item.conflict.field);
self.open_conflicts();
self.refresh_sync();
self.reload();
}
Err(e) => self.status = format!("error: {e}"),
}
}
// --- full task log (`L`) ---
/// How much history the full log view fetches (the preview shows 5 lines).
const LOG_VIEW_LINES: usize = 1000;
/// Open the full, scrollable log of the highlighted task.
pub fn open_log(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
match self.backend.log_tail(&t.node_id, Self::LOG_VIEW_LINES) {
Ok(lines) => {
self.log_view = Some(LogView {
title: t.title,
lines,
scroll: 0,
});
}
Err(e) => self.status = format!("error: {e}"),
}
}
/// Close the log view.
pub fn close_log(&mut self) {
self.log_view = None;
}
/// Scroll the log view by `delta` lines (clamped).
pub fn log_scroll(&mut self, delta: isize) {
if let Some(v) = &mut self.log_view {
let max = v.lines.len().saturating_sub(1) as isize;
v.scroll = (v.scroll as isize + delta).clamp(0, max) as usize;
}
}
/// Move the search-results cursor by `delta` (clamped).
pub fn search_move(&mut self, delta: isize) {
if let Some(s) = &mut self.search {
@ -1606,32 +1187,6 @@ impl<B: Backend> App<B> {
Err(e) => self.status = format!("error: {e}"),
}
}
InputKind::Rename {
task_id,
context_id,
before,
} => {
let after = buf;
if after.is_empty() || after == before {
self.status = "rename cancelled".into();
return;
}
self.push_entry(UndoEntry::Rename {
task_id: task_id.clone(),
context_id: context_id.clone(),
before,
after: after.clone(),
});
self.mutate(format!("renamed: {after} (u to undo)"), move |b| {
b.rename(&task_id, &after)?;
// The context doc is created with the task's title — keep
// them in step so resolution/search stay coherent.
if let Some(ctx) = &context_id {
b.rename(ctx, &after)?;
}
Ok(())
});
}
InputKind::Reschedule { task_id } => {
let patch = if buf.is_empty() {
SchedulePatch {

View file

@ -90,18 +90,6 @@ pub trait Backend {
fn sync_status(&mut self) -> Result<SyncStatus> {
Ok(SyncStatus::default())
}
/// A node's title, for labelling conflict rows. `None` if it's missing.
fn node_title(&mut self, _id: &str) -> Result<Option<String>> {
Ok(None)
}
/// Open merge conflicts (the `conflicts.list` RPC). Default: none.
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
Ok(Vec::new())
}
/// Settle a conflict keeping the `"local"` or `"remote"` value.
fn resolve_conflict(&mut self, _id: &str, _choice: &str) -> Result<()> {
Ok(())
}
// --- triage mutations (T2) ---
@ -112,13 +100,6 @@ pub trait Backend {
/// 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<()>;
/// Restore (un-tombstone) a node — the undo of [`Backend::tombstone`] and
/// of project deletion.
fn restore(&mut self, node_id: &str) -> Result<()>;
/// Rename a node (title only; body untouched).
fn rename(&mut self, node_id: &str, title: &str) -> Result<()>;
/// Re-parent a project (`None` detaches it to the root).
fn reparent(&mut self, project_id: &str, parent_id: Option<&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.
@ -243,43 +224,6 @@ impl Backend for ClientBackend {
Ok(())
}
fn restore(&mut self, node_id: &str) -> Result<()> {
self.call("node.restore", json!({ "id": node_id }))?;
Ok(())
}
fn rename(&mut self, node_id: &str, title: &str) -> Result<()> {
self.call("node.update", json!({ "id": node_id, "title": title }))?;
Ok(())
}
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
self.call(
"project.reparent",
json!({ "id": project_id, "parent_id": parent_id }),
)?;
Ok(())
}
fn node_title(&mut self, id: &str) -> Result<Option<String>> {
let v = self.call("node.get", json!({ "id": id }))?;
if v.is_null() {
return Ok(None);
}
let node: heph_core::Node = serde_json::from_value(v)?;
Ok(Some(node.title))
}
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
let v = self.call("conflicts.list", json!({}))?;
Ok(serde_json::from_value(v)?)
}
fn resolve_conflict(&mut self, id: &str, choice: &str) -> Result<()> {
self.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?;
Ok(())
}
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
self.call(
"task.set_attention",

View file

@ -159,34 +159,6 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None;
}
// While the full task log is shown, j/k scroll it.
if app.log_view.is_some() {
app.status.clear();
match key.code {
KeyCode::Esc | KeyCode::Char('L') => app.close_log(),
KeyCode::Char('j') | KeyCode::Down => app.log_scroll(1),
KeyCode::Char('k') | KeyCode::Up => app.log_scroll(-1),
KeyCode::Char('q') => app.should_quit = true,
_ => {}
}
return None;
}
// While the conflicts review is shown, the center pane navigates/settles it.
if app.conflicts_view.is_some() {
app.status.clear();
match key.code {
KeyCode::Esc | KeyCode::Char('C') => app.close_conflicts(),
KeyCode::Char('j') | KeyCode::Down => app.conflicts_move(1),
KeyCode::Char('k') | KeyCode::Up => app.conflicts_move(-1),
KeyCode::Char('l') => app.conflicts_resolve("local"),
KeyCode::Char('r') => app.conflicts_resolve("remote"),
KeyCode::Char('q') => app.should_quit = true,
_ => {}
}
return None;
}
// While search results are shown, the center pane navigates them.
if app.search.is_some() {
app.status.clear();
@ -222,7 +194,6 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('u') => app.undo(),
KeyCode::Char('z') if ctrl => app.redo(),
KeyCode::Char('C') => app.open_conflicts(),
// 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 {
@ -233,16 +204,14 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('a') => app.begin_attention(),
KeyCode::Char('e') => app.begin_reschedule(),
KeyCode::Char('m') => app.begin_move(),
KeyCode::Char('R') => app.begin_rename(),
KeyCode::Char('L') => app.open_log(),
KeyCode::Char('D') => app.begin_delete(),
_ => {}
},
Focus::Sidebar => match key.code {
KeyCode::Char('m') => app.begin_reparent(),
KeyCode::Char('D') => app.begin_delete_project(),
_ => {}
},
Focus::Sidebar => {
if let KeyCode::Char('D') = key.code {
app.begin_delete_project()
}
}
},
}
None

View file

@ -19,18 +19,14 @@ use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
// Task-pane gestures (the focused pane shows its own hints, §8.1).
const HINTS: &str =
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move R rename L log D del u undo / search q quit";
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn 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 n add m move-project D del-project u undo s sort / search Tab tasks q quit";
" j/k move ⏎ open n 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";
const CONFLICT_HINTS: &str = " j/k move l keep local r keep remote Esc close";
const LOG_HINTS: &str = " j/k scroll Esc close";
/// Draw the whole UI for the current frame.
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
let outer = Layout::default()
@ -48,11 +44,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
.split(outer[0]);
render_sidebar(frame, app, panes[0]);
if app.log_view.is_some() {
render_log(frame, app, panes[1]);
} else if app.conflicts_view.is_some() {
render_conflicts(frame, app, panes[1]);
} else if app.search.is_some() {
if app.search.is_some() {
render_search(frame, app, panes[1]);
} else {
render_tasks(frame, app, panes[1]);
@ -95,7 +87,7 @@ fn render_move(frame: &mut Frame, state: &MoveState) {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(" Move \"{}\" to ", state.subject_title)),
.title(format!(" Move \"{}\" to ", state.task_title)),
);
frame.render_widget(input, chunks[0]);
@ -482,86 +474,6 @@ fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
frame.render_widget(list, area);
}
/// The open-conflicts review in the center pane: one row per conflict, the
/// node's title + which field diverged + both values.
fn render_conflicts<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let Some(v) = &app.conflicts_view else { return };
let today = today_local();
let items: Vec<ListItem> = if v.items.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" (no open conflicts — Esc to close)",
Style::default().fg(Color::DarkGray),
)))]
} else {
v.items
.iter()
.enumerate()
.map(|(i, item)| {
let selected = i == v.cursor;
let title_style = if selected {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let cursor = if selected { "" } else { " " };
let c = &item.conflict;
let val = |v: &Option<String>| match (c.field.as_str(), v) {
("do_date", Some(ms)) => ms
.parse::<i64>()
.map(|ms| fmt_date(ms, today))
.unwrap_or_else(|_| ms.clone()),
(_, Some(s)) => s.clone(),
(_, None) => "(unset)".into(),
};
ListItem::new(Line::from(vec![
Span::styled(cursor, Style::default().fg(Color::Cyan)),
Span::styled(item.title.clone(), title_style),
Span::styled(
format!(
" {}: local {} · remote {}",
c.field,
val(&c.local_val),
val(&c.remote_val)
),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect()
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(" Conflicts ({}) ", v.items.len())),
);
frame.render_widget(list, area);
}
/// A task's full log in the center pane, scrollable (the preview shows only
/// the last few lines).
fn render_log<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let Some(v) = &app.log_view else { return };
let lines: Vec<Line> = if v.lines.is_empty() {
vec![Line::from(Span::styled(
" (no log entries — Esc to close)",
Style::default().fg(Color::DarkGray),
))]
} else {
v.lines.iter().map(|l| Line::from(l.clone())).collect()
};
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((v.scroll as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(" Log: {} ({} lines) ", v.title, v.lines.len())),
);
frame.render_widget(para, 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() {
@ -609,11 +521,7 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
);
return;
}
let hints = if app.log_view.is_some() {
LOG_HINTS
} else if app.conflicts_view.is_some() {
CONFLICT_HINTS
} else if app.search.is_some() {
let hints = if app.search.is_some() {
SEARCH_HINTS
} else if app.focus == Focus::Sidebar {
SIDEBAR_HINTS

View file

@ -28,13 +28,9 @@ struct Recorder {
created: Vec<CreatedTask>,
scheduled: Vec<(String, SchedulePatch)>,
tombstoned: Vec<String>,
restored: Vec<String>,
renamed: Vec<(String, String)>,
reparented: Vec<(String, Option<String>)>,
refiled: Vec<(String, Option<String>)>,
created_projects: Vec<String>,
states: Vec<(String, String)>,
resolved: Vec<(String, String)>,
}
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
@ -61,8 +57,6 @@ struct Fake {
bodies: HashMap<String, String>,
search_hits: Vec<SearchHit>,
contexts: HashMap<String, String>,
conflicts: Vec<heph_core::Conflict>,
logs: HashMap<String, Vec<String>>,
rec: Rc<RefCell<Recorder>>,
}
@ -80,23 +74,8 @@ impl Backend for Fake {
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(self.logs.get(task_id).cloned().unwrap_or_default())
}
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
Ok(self
.conflicts
.iter()
.filter(|c| !self.rec.borrow().resolved.iter().any(|(id, _)| id == &c.id))
.cloned()
.collect())
}
fn resolve_conflict(&mut self, id: &str, choice: &str) -> Result<()> {
self.rec
.borrow_mut()
.resolved
.push((id.into(), choice.into()));
Ok(())
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
@ -120,24 +99,6 @@ impl Backend for Fake {
self.rec.borrow_mut().tombstoned.push(node_id.into());
Ok(())
}
fn restore(&mut self, node_id: &str) -> Result<()> {
self.rec.borrow_mut().restored.push(node_id.into());
Ok(())
}
fn rename(&mut self, node_id: &str, title: &str) -> Result<()> {
self.rec
.borrow_mut()
.renamed
.push((node_id.into(), title.into()));
Ok(())
}
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
self.rec
.borrow_mut()
.reparented
.push((project_id.into(), parent_id.map(str::to_string)));
Ok(())
}
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
Ok(())
}
@ -396,7 +357,7 @@ fn move_to_project_picker_refiles_the_selected_task() {
app.begin_move();
match &app.mode {
Mode::MoveToProject(m) => {
assert_eq!(m.subject_id, "t1");
assert_eq!(m.task_id, "t1");
assert_eq!(
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
vec!["(Unfile)", "Camano"]
@ -607,218 +568,3 @@ fn reschedule_with_blank_clears_the_do_date() {
// do_date present-and-null = "clear" (the double-option).
assert_eq!(scheduled[0].1.do_date, Some(None));
}
#[test]
fn deleted_task_is_undoable_via_restore_and_redoable() {
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_delete();
app.confirm_delete(); // tombstones t1
assert_eq!(rec.borrow().tombstoned, vec!["t1".to_string()]);
app.undo(); // restores it
assert_eq!(rec.borrow().restored, vec!["t1".to_string()]);
app.redo(); // tombstones it again
assert_eq!(
rec.borrow().tombstoned,
vec!["t1".to_string(), "t1".to_string()]
);
}
#[test]
fn deleted_project_undo_restores_node_and_refiles_its_tasks() {
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 (it holds task "pt").
for _ in 0..6 {
app.move_sidebar(1);
}
app.begin_delete_project();
app.confirm_delete();
assert_eq!(rec.borrow().tombstoned, vec!["p1".to_string()]);
app.undo();
assert_eq!(rec.borrow().restored, vec!["p1".to_string()]);
// The task that was filed under it is re-filed.
assert_eq!(
rec.borrow().refiled.last().unwrap(),
&("pt".to_string(), Some("p1".to_string()))
);
}
#[test]
fn rename_prefills_renames_task_and_context_and_is_undoable() {
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.begin_rename(); // t1, title "red one", ctx c1
// The buffer is prefilled; clear it and type a new title.
for _ in 0.."red one".len() {
app.input_backspace();
}
type_and_submit(&mut app, "crimson one");
{
let renamed = &rec.borrow().renamed;
assert_eq!(
renamed.as_slice(),
[
("t1".to_string(), "crimson one".to_string()),
("c1".to_string(), "crimson one".to_string()),
]
);
}
app.undo(); // back to "red one" on both nodes
assert_eq!(
rec.borrow().renamed.last().unwrap(),
&("c1".to_string(), "red one".to_string())
);
}
#[test]
fn rename_to_same_or_empty_title_is_a_noop() {
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.begin_rename();
app.input_submit(); // unchanged prefill
assert!(rec.borrow().renamed.is_empty());
app.begin_rename();
for _ in 0.."red one".len() {
app.input_backspace();
}
app.input_submit(); // emptied
assert!(rec.borrow().renamed.is_empty());
}
#[test]
fn reparent_picker_excludes_subtree_and_reparents_with_undo() {
use heph_tui::app::{Mode, MoveOption};
let rec = Rc::new(RefCell::new(Recorder::default()));
let mut fake = fixture();
// A second project to be the new parent.
fake.projects.push(Project {
id: "p2".into(),
title: "Coding".into(),
});
fake.rec = rec.clone();
let mut app = App::new(fake).unwrap();
// Sidebar → Camano (p1).
for _ in 0..6 {
app.move_sidebar(1);
}
assert_eq!(app.task_pane_title(), "Camano");
app.begin_reparent();
match &app.mode {
Mode::MoveToProject(m) => {
assert_eq!(m.subject_id, "p1");
let labels: Vec<String> = m.options.iter().map(|o| o.label()).collect();
assert!(labels.contains(&"(Move to root)".to_string()), "{labels:?}");
assert!(labels.contains(&"Coding".to_string()), "{labels:?}");
assert!(
!labels.contains(&"Camano".to_string()),
"a project can't be its own parent: {labels:?}"
);
assert!(
!m.options
.iter()
.any(|o| matches!(o, MoveOption::Create { .. })),
"no create row in re-parent mode"
);
}
_ => panic!("expected the move picker"),
}
// Pick Coding and submit.
let coding_idx = match &app.mode {
Mode::MoveToProject(m) => m
.options
.iter()
.position(|o| matches!(o, MoveOption::Project { title, .. } if title == "Coding"))
.unwrap(),
_ => unreachable!(),
};
app.move_picker_move(coding_idx as isize);
app.move_picker_submit();
assert_eq!(
rec.borrow().reparented.last().unwrap(),
&("p1".to_string(), Some("p2".to_string()))
);
app.undo(); // back to the root (no parent in the fixture overview)
assert_eq!(
rec.borrow().reparented.last().unwrap(),
&("p1".to_string(), None)
);
}
#[test]
fn conflicts_view_lists_and_resolves() {
let mut fake = fixture();
fake.conflicts = vec![heph_core::Conflict {
id: "cf1".into(),
node_id: "t1".into(),
field: "do_date".into(),
local_val: Some("1000".into()),
remote_val: Some("2000".into()),
local_hlc: String::new(),
remote_hlc: String::new(),
status: "open".into(),
created_at: 0,
}];
let rec = Rc::new(RefCell::new(Recorder::default()));
fake.rec = rec.clone();
let mut app = App::new(fake).unwrap();
app.open_conflicts();
let v = app.conflicts_view.as_ref().expect("conflicts view open");
assert_eq!(v.items.len(), 1);
app.conflicts_resolve("remote");
assert_eq!(
rec.borrow().resolved.as_slice(),
[("cf1".to_string(), "remote".to_string())]
);
app.close_conflicts();
assert!(app.conflicts_view.is_none());
}
#[test]
fn log_view_opens_scrolls_and_closes() {
let mut fake = fixture();
fake.logs.insert(
"t1".into(),
vec!["one".into(), "two".into(), "three".into()],
);
let mut app = App::new(fake).unwrap();
app.focus_tasks();
app.open_log();
assert_eq!(app.log_view.as_ref().unwrap().lines.len(), 3);
app.log_scroll(5); // clamped to the last line
assert_eq!(app.log_view.as_ref().unwrap().scroll, 2);
app.log_scroll(-9);
assert_eq!(app.log_view.as_ref().unwrap().scroll, 0);
app.close_log();
assert!(app.log_view.is_none());
}

View file

@ -281,12 +281,6 @@ enum NodeAction {
/// Node id.
id: String,
},
/// Restore (un-tombstone) a node — the undo of `rm`. A restored task gets
/// its canonical-context doc back too.
Restore {
/// Node id.
id: String,
},
}
#[derive(Subcommand, Debug)]
@ -297,8 +291,7 @@ enum LinkAction {
src: String,
/// Destination node id.
dst: String,
/// Link type: blocks|parent|tagged|in-project|context-of. (wiki links
/// are derived from `[[…]]` in the body and can't be added by hand.)
/// Link type: blocks|parent|tagged|in-project|context-of|…
link_type: String,
},
}
@ -315,17 +308,6 @@ enum ProjectAction {
},
/// List all projects.
List,
/// Re-parent a project: move it under another project, or to the root.
Move {
/// Project to move (name; fuzzy like `--project` elsewhere).
name: String,
/// New parent project name.
#[arg(long, conflicts_with = "root")]
parent: Option<String>,
/// Detach to the root (no parent).
#[arg(long)]
root: bool,
},
}
#[derive(Subcommand, Debug)]
@ -653,16 +635,6 @@ fn main() -> Result<()> {
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)?);
// A task node's own `body` is always null — the real content
// lives in its canonical-context doc, so show that too.
if let Ok(doc_id) = canonical_context_id(&mut client, &id) {
let body = context_body(&mut client, &doc_id)?;
if body.trim().is_empty() {
println!("context ({doc_id}): (empty)");
} else {
println!("context ({doc_id}):\n{}", body.trim_end());
}
}
}
}
Command::Log { id, text, n } => {
@ -743,10 +715,6 @@ fn main() -> Result<()> {
client.call("node.tombstone", json!({ "id": id }))?;
println!("Tombstoned {id}");
}
NodeAction::Restore { id } => {
client.call("node.restore", json!({ "id": id }))?;
println!("Restored {id}");
}
},
Command::Get { id } => {
let result = client.call("node.get", json!({ "id": id }))?;
@ -820,26 +788,6 @@ fn main() -> Result<()> {
println!("{} {}", n.id, n.title);
}
}
ProjectAction::Move { name, parent, root } => {
let id = resolve_project(&mut client, Some(&name))?
.with_context(|| format!("no project named {name:?}"))?;
let parent_id = match (&parent, root) {
(Some(p), false) => Some(
resolve_project(&mut client, Some(p))?
.with_context(|| format!("no parent project named {p:?}"))?,
),
(None, true) => None,
_ => bail!("pass exactly one of --parent <project> or --root"),
};
client.call(
"project.reparent",
json!({ "id": id, "parent_id": parent_id }),
)?;
match parent {
Some(p) => println!("Moved {name} under {p}"),
None => println!("Moved {name} to the root"),
}
}
},
Command::Tag { action } => match action {
TagAction::Add { node, tag } => {

View file

@ -691,86 +691,6 @@ fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path)
println!("log : {}", p.log.display());
if !running {
println!("\n(start it with `heph daemon start`)");
return;
}
print_runtime_status(&p.socket);
}
/// Ask the live daemon (over its socket) for its runtime config + sync /
/// self-update state, and print it under the service facts. Best-effort: a
/// daemon that won't answer is reported, not an error.
fn print_runtime_status(socket: &Path) {
let status = hephd::Client::connect(socket)
.and_then(|mut c| c.call("sync.status", serde_json::json!({})));
let status = match status {
Ok(s) => s,
Err(e) => {
println!("\n(daemon did not answer sync.status: {e})");
return;
}
};
let s = |v: &serde_json::Value| v.as_str().map(str::to_string);
let runtime = &status["runtime"];
println!();
if let Some(v) = s(&runtime["version"]) {
println!("version : {v}");
}
if let Some(m) = s(&runtime["mode"]) {
println!("mode : {m}");
}
match s(&status["hub_url"]) {
Some(hub) => {
let interval = runtime["sync_interval_secs"]
.as_u64()
.map(|n| format!(" (every {n}s)"))
.unwrap_or_default();
println!("hub : {hub}{interval}");
if let Some(issuer) = s(&status["auth"]["issuer"]) {
println!("oidc : {issuer}");
}
let health = &status["health"];
match s(&health["last_error"]) {
Some(err) => println!("sync : FAILING — {err}"),
None => match health["last_success_ms"].as_i64() {
Some(ms) => println!("sync : ok (last success {})", fmt_age(ms)),
None => println!("sync : no exchange yet"),
},
}
}
None => println!("hub : (none — standalone)"),
}
if let Some(n) = status["conflicts"].as_u64() {
if n > 0 {
println!("conflicts : {n} open (see `heph conflicts list`)");
}
}
match &runtime["self_update"] {
serde_json::Value::Null => println!("selfupdate: off"),
su => {
let every = su["interval_secs"]
.as_u64()
.map(|n| format!("every {n}s"))
.unwrap_or_default();
let outcome = match (su["last_check_ms"].as_i64(), s(&su["last_outcome"])) {
(Some(ms), Some(o)) => format!("; last check {}: {o}", fmt_age(ms)),
_ => "; no check yet".to_string(),
};
println!("selfupdate: on, {every}{outcome}");
}
}
}
/// "Ns ago" for an epoch-ms timestamp (coarse, human-scale).
fn fmt_age(epoch_ms: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let secs = ((now - epoch_ms) / 1000).max(0);
match secs {
0..=119 => format!("{secs}s ago"),
120..=7199 => format!("{}m ago", secs / 60),
_ => format!("{}h ago", secs / 3600),
}
}

View file

@ -249,78 +249,3 @@ fn export_writes_markdown_files() {
let text = std::fs::read_to_string(docs[0].path()).unwrap();
assert!(text.contains("title: \"Roof log\""), "{text}");
}
#[test]
fn node_rm_then_restore_round_trips() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["task", "Doomed", "--attention", "red"]);
assert!(ok, "{out}");
let id = out
.split_whitespace()
.find(|w| w.len() == 26) // the ULID in "Created task <id> …"
.expect("task id in output")
.to_string();
let (out, ok) = heph(&socket, &["node", "rm", &id]);
assert!(ok, "{out}");
let (out, _) = heph(&socket, &["list"]);
assert!(
!out.contains("Doomed"),
"tombstoned task still listed: {out}"
);
let (out, ok) = heph(&socket, &["node", "restore", &id]);
assert!(ok, "{out}");
assert!(out.contains("Restored"));
let (out, _) = heph(&socket, &["list"]);
assert!(out.contains("Doomed"), "restored task missing: {out}");
}
#[test]
fn project_move_reparents_and_detaches() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["project", "add", "Coding"]);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["project", "add", "Hephaestus"]);
assert!(ok, "{out}");
let (out, ok) = heph(
&socket,
&["project", "move", "Hephaestus", "--parent", "Coding"],
);
assert!(ok, "{out}");
assert!(out.contains("under Coding"), "{out}");
// A cycle is rejected with a real error.
let (out, ok) = heph(
&socket,
&["project", "move", "Coding", "--parent", "Hephaestus"],
);
assert!(!ok, "cycle unexpectedly allowed: {out}");
let (out, ok) = heph(&socket, &["project", "move", "Hephaestus", "--root"]);
assert!(ok, "{out}");
assert!(out.contains("to the root"), "{out}");
}
#[test]
fn show_on_a_task_prints_its_context_doc_body() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["task", "Fix roof"]);
assert!(ok, "{out}");
let id = out
.split_whitespace()
.find(|w| w.len() == 26)
.expect("task id in output")
.to_string();
let (out, ok) = heph(&socket, &["context", &id, "--body", "buy shingles first"]);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["show", &id]);
assert!(ok, "{out}");
assert!(out.contains("\"kind\": \"task\""), "{out}");
assert!(
out.contains("buy shingles"),
"show hid the context body: {out}"
);
}

View file

@ -44,7 +44,6 @@ dbus-secret-service-keyring-store.workspace = true
[dev-dependencies]
tempfile = "3"
proptest = "1"
# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo).
rsa = "0.9"
rand = "0.8"

View file

@ -1,7 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 9e3bbefcd78c4c389f8d6088d0a5462bf5516aed780ff5dd5952f636c3ae7ba2 # shrinks to s = "A0𐻂 a"

View file

@ -98,20 +98,12 @@ fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> {
let n: u64 = num
.parse()
.with_context(|| format!("not a relative date offset: +{rest}"))?;
// Checked throughout: a large `n` would otherwise overflow chrono's date
// arithmetic and panic (the `+` operators do), so an out-of-range offset
// must surface as a clean error instead of crashing the parse.
let out = match unit.trim() {
"" | "d" | "day" | "days" => today.checked_add_days(Days::new(n)),
"w" | "wk" | "week" | "weeks" => n
.checked_mul(7)
.and_then(|days| today.checked_add_days(Days::new(days))),
"m" | "mo" | "month" | "months" => u32::try_from(n)
.ok()
.and_then(|m| today.checked_add_months(Months::new(m))),
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)"),
};
out.with_context(|| format!("date offset +{rest} is out of range"))
}
}
/// Map a weekday name (full or common abbreviation) to a `Weekday`. Matches
@ -266,10 +258,7 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> {
return None;
}
let month = |t: &str| -> Option<u32> {
// First three *chars* (not bytes): a multibyte token like "𐻂" would
// make a byte slice land mid-codepoint and panic.
let key: String = t.chars().take(3).collect();
match key.as_str() {
match &t[..t.len().min(3)] {
"jan" => Some(1),
"feb" => Some(2),
"mar" => Some(3),
@ -648,37 +637,4 @@ mod tests {
assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through");
}
}
#[test]
fn huge_day_offset_does_not_panic() {
// `+<huge>d` parses as a valid u64 then overflows the date — must be a
// clean Err, not a chrono arithmetic panic.
assert!(parse_date("+999999999999999999d", today()).is_err());
assert!(parse_date("+999999999w", today()).is_err());
assert!(parse_date("+999999999m", today()).is_err());
}
use proptest::prelude::*;
proptest! {
/// Date parsing is total for any input — surfaces feed it raw user text.
#[test]
fn parse_date_never_panics(s in "\\PC{0,40}") {
let _ = parse_date(&s, today());
}
/// Offset/ISO forms that parse round-trip through `fmt_iso`-style ISO.
#[test]
fn offset_dates_round_trip_through_iso(n in 0u32..3650) {
let date = parse_date(&format!("+{n}d"), today()).unwrap();
let iso = date.format("%Y-%m-%d").to_string();
prop_assert_eq!(parse_date(&iso, today()).unwrap(), date);
}
/// Recurrence parsing is total for any input.
#[test]
fn parse_recurrence_never_panics(s in "\\PC{0,40}") {
let _ = parse_recurrence(&s);
}
}
}

View file

@ -84,12 +84,6 @@ struct Cli {
#[arg(long)]
oidc_client_id: Option<String>,
/// Adopt this canonical owner id at startup (idempotent once adopted).
/// Path-A hub seeding: a fresh hub started with an existing device's owner
/// id rebuilds from that spoke's first full op-log push — no snapshot copy.
#[arg(long)]
owner_id: Option<String>,
/// Opt-in (default off): periodically poll the forge for a newer release and
/// auto-update this daemon. Off unless this flag is given.
#[arg(long)]
@ -160,9 +154,7 @@ async fn main() -> Result<()> {
};
(
None,
Daemon::new(store)
.with_mode("client")
.with_self_update(self_update.clone()),
Daemon::new(store).with_self_update(self_update.clone()),
)
}
Mode::Local | Mode::Server => {
@ -173,21 +165,11 @@ async fn main() -> Result<()> {
}
// Take the exclusive lock before opening the store (tech-spec §3.1).
let lock = LockGuard::acquire(&db)?;
let mut store = LocalStore::open(&db, Box::new(SystemClock))?;
if let Some(owner) = &cli.owner_id {
use heph_core::Store as _;
store.adopt_owner(owner)?;
tracing::info!(%owner, "adopted canonical owner id");
}
let store = LocalStore::open(&db, Box::new(SystemClock))?;
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_mode(if cli.mode == Mode::Server {
"server"
} else {
"local"
})
.with_hub(cli.hub_url.clone())
.with_spoke_auth(spoke)
.with_self_update(self_update.clone());

View file

@ -240,25 +240,4 @@ mod tests {
assert_eq!(r.title, "Review every report");
assert_eq!(r.recurrence, None);
}
use proptest::prelude::*;
proptest! {
/// Quick-add is total — it's the daemon's parse of raw capture text.
#[test]
fn parse_never_panics(s in "\\PC{0,60}") {
let _ = parse(&s, today(), &projects());
}
/// Every word in the title came from the input: the parser only ever
/// drops recognized tokens, never invents text.
#[test]
fn title_words_are_a_subset_of_input_words(s in "[\\PC ]{0,60}") {
let r = parse(&s, today(), &projects());
let input: std::collections::HashSet<&str> = s.split_whitespace().collect();
for w in r.title.split_whitespace() {
prop_assert!(input.contains(w), "title word {w:?} not in input {s:?}");
}
}
}
}

View file

@ -133,23 +133,11 @@ impl Store for RemoteStore {
self.call("node.tombstone", json!({ "id": id })).map(|_| ())
}
fn restore_node(&mut self, id: &str) -> Result<()> {
self.call("node.restore", json!({ "id": id })).map(|_| ())
}
fn delete_project(&mut self, project_id: &str) -> Result<()> {
self.call("project.delete", json!({ "id": project_id }))
.map(|_| ())
}
fn reparent_project(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
self.call(
"project.reparent",
json!({ "id": project_id, "parent_id": parent_id }),
)
.map(|_| ())
}
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
self.call_as("node.resolve", json!({ "title": title }))
}

View file

@ -178,14 +178,6 @@ struct SetProjectParams {
project_id: Option<String>,
}
#[derive(Deserialize)]
struct ReparentParams {
id: String,
/// New parent project node id; `null`/absent detaches to the root.
#[serde(default)]
parent_id: Option<String>,
}
#[derive(Deserialize)]
struct PromoteParams {
container_id: String,
@ -352,11 +344,6 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
store.tombstone_node(&p.id)?;
json!({ "ok": true })
}
"node.restore" => {
let p: IdParam = parse(params)?;
store.restore_node(&p.id)?;
json!({ "ok": true })
}
"node.resolve" => {
let p: ResolveParams = parse(params)?;
json!(store.resolve_node(&p.title)?)
@ -398,11 +385,6 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
store.delete_project(&p.id)?;
Value::Null
}
"project.reparent" => {
let p: ReparentParams = parse(params)?;
store.reparent_project(&p.id, p.parent_id.as_deref())?;
json!({ "ok": true })
}
"task.skip" => {
let p: IdParam = parse(params)?;
json!(store.skip_recurrence(&p.id)?)

View file

@ -32,29 +32,6 @@ impl SelfUpdateConfig {
}
}
/// Observed poller state, shared with the daemon so `sync.status` (and through
/// it `heph daemon status`) can report what self-update last did instead of
/// only logging it. All times epoch ms; `None` means "no check yet".
#[derive(Clone, Default, serde::Serialize)]
pub struct SelfUpdateHealth {
/// When the last release check completed.
pub last_check_ms: Option<i64>,
/// Human-readable outcome of that check.
pub last_outcome: Option<String>,
}
impl SelfUpdateHealth {
fn record(health: &std::sync::Mutex<Self>, outcome: String) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let mut h = health.lock().expect("self-update health mutex poisoned");
h.last_check_ms = Some(now);
h.last_outcome = Some(outcome);
}
}
/// The forge releases feed for this project — the latest tagged release. The
/// repo is public, so this is an unauthenticated GET on the canonical public
/// host.
@ -248,15 +225,13 @@ pub async fn apply_update(
}
/// The background poll loop: tick on `interval`, check for a newer release, and
/// when one is available, apply it. Each check's outcome is folded into
/// `health` for `sync.status`. Runs forever; spawned as a task.
/// when one is available, apply it. Runs forever; spawned as a task.
pub async fn run_poll_loop<S: ReleaseSource>(
source: S,
installer: Arc<dyn Installer>,
restarter: Arc<dyn Restarter>,
interval: Duration,
current: &'static str,
health: Arc<std::sync::Mutex<SelfUpdateHealth>>,
) {
let mut tick = tokio::time::interval(interval);
loop {
@ -264,22 +239,14 @@ pub async fn run_poll_loop<S: ReleaseSource>(
match check_release(&source, current).await {
CheckOutcome::UpdateAvailable(tag) => {
tracing::info!(%tag, current, "self-update: newer release available, applying");
SelfUpdateHealth::record(&health, format!("applying update to {tag}"));
// On success the restarter exits the process, so this only
// returns on failure — log it and keep polling.
if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await {
tracing::error!("self-update: failed for {tag}: {e}");
SelfUpdateHealth::record(&health, format!("install of {tag} failed: {e}"));
}
}
CheckOutcome::UpToDate => {
tracing::debug!(current, "self-update: up to date");
SelfUpdateHealth::record(&health, format!("up to date ({current})"));
}
CheckOutcome::Failed(e) => {
tracing::warn!("self-update: release check failed: {e}");
SelfUpdateHealth::record(&health, format!("release check failed: {e}"));
}
CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"),
CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"),
}
}
}

View file

@ -64,12 +64,6 @@ struct Ctx {
self_update: Option<SelfUpdateConfig>,
/// Live sync health, shared between the background loop and `sync.status`.
sync_health: Arc<Mutex<SyncHealth>>,
/// Live self-update poller state, shared with `sync.status`.
self_update_health: Arc<Mutex<selfupdate::SelfUpdateHealth>>,
/// Runtime mode (`local`/`server`/`client`), for `sync.status`.
mode: Option<String>,
/// Background sync cadence, recorded when the loop is spawned.
sync_interval_secs: Arc<Mutex<Option<u64>>>,
}
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
@ -159,46 +153,6 @@ fn annotate_reauth(
}
}
/// Log every 1-in-N repeats of an identical sync failure (the first occurrence
/// always logs).
const REPEAT_LOG_EVERY: u32 = 10;
/// Per-loop log throttling state for background sync: announce recovery after a
/// failure streak, and suppress repeats of an identical failure message so a
/// down hub doesn't write the same warning every cycle.
#[derive(Default)]
struct SyncLoopLog {
consecutive_failures: u32,
last_error: Option<String>,
repeats: u32,
}
impl SyncLoopLog {
/// Fold in a success. Returns `Some(n)` when this ends a streak of `n`
/// failures — the recovery transition worth announcing.
fn on_success(&mut self) -> Option<u32> {
let failures = std::mem::take(&mut self.consecutive_failures);
self.last_error = None;
self.repeats = 0;
(failures > 0).then_some(failures)
}
/// Fold in a failure. Returns whether this occurrence should log at warn
/// level: a new message always does; an identical repeat only every
/// [`REPEAT_LOG_EVERY`]-th time.
fn on_failure(&mut self, msg: &str) -> bool {
self.consecutive_failures += 1;
if self.last_error.as_deref() == Some(msg) {
self.repeats += 1;
self.repeats.is_multiple_of(REPEAT_LOG_EVERY)
} else {
self.last_error = Some(msg.to_string());
self.repeats = 0;
true
}
}
}
impl Ctx {
/// The current bearer token for hub sync (refreshing if expired). `Ok(None)`
/// means this spoke has no auth configured / no token stored (it syncs
@ -242,19 +196,10 @@ impl Daemon {
auth: None,
self_update: None,
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
self_update_health: Arc::new(Mutex::new(selfupdate::SelfUpdateHealth::default())),
mode: None,
sync_interval_secs: Arc::new(Mutex::new(None)),
},
}
}
/// Record the runtime mode (`local`/`server`/`client`) for `sync.status`.
pub fn with_mode(mut self, mode: impl Into<String>) -> Daemon {
self.ctx.mode = Some(mode.into());
self
}
/// Configure the hub this device syncs with (`sync.now` targets it).
pub fn with_hub(mut self, hub_url: Option<String>) -> Daemon {
self.ctx.hub_url = hub_url;
@ -309,7 +254,6 @@ impl Daemon {
current = heph_core::VERSION,
"self-update enabled"
);
let health = self.ctx.self_update_health.clone();
tokio::spawn(async move {
selfupdate::run_poll_loop(
source,
@ -317,7 +261,6 @@ impl Daemon {
restarter,
cfg.interval,
heph_core::VERSION,
health,
)
.await;
});
@ -330,15 +273,9 @@ impl Daemon {
let Some(hub) = self.ctx.hub_url.clone() else {
return;
};
*self
.ctx
.sync_interval_secs
.lock()
.expect("sync interval mutex poisoned") = Some(interval.as_secs());
let ctx = self.ctx.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
let mut log = SyncLoopLog::default();
loop {
tick.tick().await;
let bearer = match ctx.bearer().await {
@ -348,13 +285,7 @@ impl Daemon {
// rejected refresh) and skip; sending an unauthenticated
// request would only 401 and mask it.
record_bearer_failure(&ctx, &e);
let msg = format!("could not obtain bearer token: {e}");
if log.on_failure(&msg) {
tracing::warn!(
consecutive = log.consecutive_failures,
"background sync: {msg}"
);
}
tracing::warn!("background sync: could not obtain bearer token: {e}");
continue;
}
};
@ -362,35 +293,8 @@ impl Daemon {
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx, &result);
match result {
Ok(report) => {
if let Some(failures) = log.on_success() {
tracing::info!(failures, "background sync recovered");
}
// Cycles that move ops log their volume + cursor advance
// at info; idle cycles stay at debug.
if report.pulled + report.pushed > 0 {
tracing::info!(
pulled = report.pulled,
applied = report.applied,
pushed = report.pushed,
pull_cursor = report.pull_cursor.as_deref().unwrap_or("-"),
push_cursor = report.push_cursor.as_deref().unwrap_or("-"),
"background sync moved ops"
);
} else {
tracing::debug!(?report, "background sync");
}
}
// `:#` prints the anyhow context chain (phase + hub url).
Err(e) => {
let msg = format!("{e:#}");
if log.on_failure(&msg) {
tracing::warn!(
consecutive = log.consecutive_failures,
"background sync failed: {msg}"
);
}
}
Ok(report) => tracing::debug!(?report, "background sync"),
Err(e) => tracing::warn!("background sync failed: {e}"),
}
}
});
@ -511,10 +415,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
}
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync
/// health (last-success time / last error / auth-failure flag), the pending
/// merge-conflict count, and the daemon's runtime config (version, mode, sync
/// cadence, self-update state). A spoke that is silently failing is visible
/// here (and, via it, in the TUI status line and `heph daemon status`).
/// health (last-success time / last error / auth-failure flag), and the pending
/// merge-conflict count. A spoke that is silently failing is visible here (and,
/// via it, in the TUI status line).
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
// Conflict count is meaningful even on a hub / standalone instance.
let store = ctx.store.clone();
@ -529,35 +432,8 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
})?
.map_err(RpcError::from)?;
// Runtime config: launch-time facts a client can't otherwise see.
let self_update = ctx.self_update.as_ref().map(|cfg| {
let h = ctx
.self_update_health
.lock()
.expect("self-update health mutex poisoned")
.clone();
json!({
"interval_secs": cfg.interval.as_secs(),
"last_check_ms": h.last_check_ms,
"last_outcome": h.last_outcome,
})
});
let runtime = json!({
"version": heph_core::VERSION,
"mode": ctx.mode,
"sync_interval_secs": *ctx
.sync_interval_secs
.lock()
.expect("sync interval mutex poisoned"),
"self_update": self_update,
});
let Some(hub_url) = ctx.hub_url.clone() else {
return Ok(json!({
"hub_url": Value::Null,
"conflicts": conflicts,
"runtime": runtime,
}));
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
};
let store = ctx.store.clone();
@ -596,42 +472,5 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
"health": health,
"auth": auth,
"reauth_command": reauth_command(Some(&hub_url), ctx.auth.as_ref()),
"runtime": runtime,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_loop_log_announces_recovery_after_a_failure_streak() {
let mut log = SyncLoopLog::default();
assert_eq!(log.on_success(), None, "no streak, nothing to announce");
assert!(log.on_failure("hub down"));
assert!(!log.on_failure("hub down"));
assert!(!log.on_failure("hub down"));
assert_eq!(log.on_success(), Some(3));
assert_eq!(log.on_success(), None, "recovery announced once");
}
#[test]
fn sync_loop_log_throttles_identical_failures_but_not_new_ones() {
let mut log = SyncLoopLog::default();
assert!(log.on_failure("hub down"), "first occurrence logs");
for _ in 0..REPEAT_LOG_EVERY - 1 {
assert!(!log.on_failure("hub down"), "repeats are suppressed");
}
assert!(
log.on_failure("hub down"),
"every {REPEAT_LOG_EVERY}th repeat logs"
);
assert!(
log.on_failure("dns broke"),
"a new message logs immediately"
);
assert!(!log.on_failure("dns broke"));
// Back to a previously-seen message: it changed, so it logs.
assert!(log.on_failure("hub down"));
}
}

View file

@ -26,7 +26,7 @@
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use anyhow::Result;
use axum::extract::{Query, Request, State};
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
use axum::middleware::{self, Next};
@ -72,12 +72,6 @@ pub struct SyncReport {
pub applied: usize,
/// Ops sent to the hub.
pub pushed: usize,
/// The pull cursor (HLC) this exchange advanced to, if it moved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_cursor: Option<String>,
/// The push cursor (HLC) this exchange advanced to, if it moved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push_cursor: Option<String>,
}
/// Run `f` against the locked store on the blocking pool (DB calls never run on
@ -397,22 +391,13 @@ pub async fn sync_once(
if let Some(token) = bearer {
req = req.bearer_auth(token);
}
let pulled: OpsBody = req
.send()
.await
.with_context(|| format!("sync pull: request to {base}/sync/pull failed"))?
.error_for_status()
.with_context(|| format!("sync pull: hub {base} rejected the request"))?
.json()
.await
.with_context(|| format!("sync pull: decoding response from {base}"))?;
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();
report.pull_cursor = Some(cursor.clone());
with_store(&store, move |s| s.record_sync(&hub, None, Some(&cursor))).await?;
}
}
@ -432,14 +417,9 @@ pub async fn sync_once(
if let Some(token) = bearer {
req = req.bearer_auth(token);
}
req.send()
.await
.with_context(|| format!("sync push: request to {base}/sync/push failed"))?
.error_for_status()
.with_context(|| format!("sync push: hub {base} rejected the request"))?;
req.send().await?.error_for_status()?;
if let Some(cursor) = max_pushed {
let hub = hub_url.to_string();
report.push_cursor = Some(cursor.clone());
with_store(&store, move |s| s.record_sync(&hub, Some(&cursor), None)).await?;
}
}

View file

@ -658,88 +658,3 @@ fn list_takes_a_filter_and_view_runs_a_builtin_over_socket() {
// An unknown view name is a reported RPC error.
assert!(c.call("view", json!({ "name": "bogus" })).is_err());
}
#[test]
fn node_restore_undoes_a_tombstone_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call("task.create", json!({ "title": "Doomed task" }))
.unwrap();
let id = task["node_id"].as_str().unwrap().to_string();
c.call("node.tombstone", json!({ "id": id })).unwrap();
let dead = c.call("node.get", json!({ "id": id })).unwrap();
assert_eq!(dead["tombstoned"], true);
c.call("node.restore", json!({ "id": id })).unwrap();
let alive = c.call("node.get", json!({ "id": id })).unwrap();
assert_eq!(alive["tombstoned"], false);
}
#[test]
fn project_reparent_moves_and_rejects_cycles_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let mk = |c: &mut Client, title: &str| {
c.call("node.create", json!({ "kind": "project", "title": title }))
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
};
let coding = mk(&mut c, "Coding");
let heph = mk(&mut c, "Hephaestus");
c.call(
"project.reparent",
json!({ "id": heph, "parent_id": coding }),
)
.unwrap();
let overview = c.call("project.overview", json!({})).unwrap();
let heph_row = overview
.as_array()
.unwrap()
.iter()
.find(|p| p["title"] == "Hephaestus")
.unwrap();
assert_eq!(heph_row["parent_id"].as_str(), Some(coding.as_str()));
// A cycle-creating move errors.
assert!(c
.call(
"project.reparent",
json!({ "id": coding, "parent_id": heph })
)
.is_err());
// Detach to root with a null parent.
c.call("project.reparent", json!({ "id": heph, "parent_id": null }))
.unwrap();
let overview = c.call("project.overview", json!({})).unwrap();
let heph_row = overview
.as_array()
.unwrap()
.iter()
.find(|p| p["title"] == "Hephaestus")
.unwrap();
assert_eq!(heph_row["parent_id"], Value::Null);
}
#[test]
fn sync_status_reports_runtime_config() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let status = c.call("sync.status", json!({})).unwrap();
// Standalone test daemon: no hub, self-update off, but the runtime block
// always reports the version (mode is unset when not built via main()).
assert_eq!(status["hub_url"], Value::Null);
assert_eq!(
status["runtime"]["version"].as_str(),
Some(heph_core::VERSION)
);
assert_eq!(status["runtime"]["self_update"], Value::Null);
}

View file

@ -204,54 +204,3 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() {
"B recorded no conflict"
);
}
#[tokio::test]
async fn fresh_hub_seeded_with_owner_id_rebuilds_from_first_sync() {
// Path-A seeding: a brand-new hub started with the device's owner id (the
// `hephd --owner-id` flag) needs no snapshot copy — the spoke's first sync
// replays its whole op-log into the hub, and the hub keeps its own fresh
// device origin by construction.
let http = reqwest::Client::new();
// The device: its own generated owner, with pre-existing data.
let dir = tempfile::tempdir().unwrap();
let mut device =
LocalStore::open(dir.path().join("heph.db"), Box::new(StepClock::new(1000))).unwrap();
let owner = device.owner_id().to_string();
let task = device
.create_task(NewTask {
title: "Pre-hub task".into(),
..Default::default()
})
.unwrap();
let device = Arc::new(Mutex::new(device));
// The hub: a fresh store adopting the device's owner (what --owner-id does).
let hub_dir = tempfile::tempdir().unwrap();
let mut hub_store = LocalStore::open(
hub_dir.path().join("heph.db"),
Box::new(StepClock::new(1000)),
)
.unwrap();
hub_store.adopt_owner(&owner).unwrap();
let hub = Arc::new(Mutex::new(hub_store));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = sync::router(hub.clone(), None);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let hub_url = format!("http://{addr}");
let report = sync::sync_once(device.clone(), &hub_url, &http, None)
.await
.unwrap();
assert!(report.pushed > 0, "device pushed nothing");
let on_hub = hub
.lock()
.unwrap()
.get_node(&task.node_id)
.unwrap()
.expect("task reached the hub");
assert_eq!(on_hub.title, "Pre-hub task");
}

View file

@ -0,0 +1 @@
Attention is now set directly instead of cycled, and surfaces it as `a1``a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1``4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1``a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1``p4` to `a1``a4` across every capture surface. The `heph` CLI's `-a/--attention` flag now accepts `a1``a4`, a bare `1``4`, or a colour word (`red`/`orange`/`white`/`blue`). The colour mappings are unchanged.

View file

@ -1 +0,0 @@
Fixed two parser panics found by fuzzing: a relative date offset like `+999999999999d` overflowed chrono's date arithmetic, and an `every <Month> <day>` recurrence phrase containing a multibyte character (e.g. `𐻂`) sliced a string on a non-char boundary. Both now return a clean error or fall through instead of crashing the daemon's parse.

View file

@ -1 +0,0 @@
Added property-based tests (proptest) across the parsing and CRDT surfaces (extraction, wiki-link projection, body CRDT, frontmatter, recurrence, HLC, datespec, quick-add), runnable as part of `cargo test`. See [[fuzz-testing]].

View file

@ -1 +0,0 @@
Added cargo-fuzz targets for the CRDT and extraction surfaces (`crates/heph-core/fuzz/`, behind heph-core's `fuzzing` feature) plus a `mise run fuzz` task. Nightly-only and ad-hoc, not wired into CI. These targets surfaced robustness gaps in `yrs` 0.27 on malformed sync deltas (OOM, abort/UB) — documented as a known limitation in [[fuzz-testing]].

View file

@ -1,125 +0,0 @@
---
title: Fuzz Testing
modified: 2026-06-09
tags:
- how-to
---
# Fuzz Testing
heph's parsing layer is pure and clock-injected, which makes it a natural fit
for randomized testing. Fuzzing runs at two tiers:
## Tier 1 — property tests (proptest, stable Rust, runs in CI)
Property-based tests live alongside the unit tests in each module and run as
part of the normal `cargo test` suite — no extra tooling, and CI picks them up
via the standard build hook.
Covered invariants:
| Module | Invariants |
|--------|-----------|
| `heph-core/src/extract.rs` | extraction never panics, is idempotent; links are non-empty/trimmed/deduped; `context_item_lines` aligns 1:1 with `context_items` |
| `heph-core/src/wikilink.rs` | `expand`/`collapse` are idempotent; `collapse(expand(x)) == collapse(x)` |
| `heph-core/src/crdt.rs` | a write materializes exactly; concurrent edits converge regardless of merge order; merge is idempotent; merging arbitrary garbage bytes never panics |
| `heph-core/src/frontmatter.rs` | `strip` is idempotent and always returns a suffix of its input |
| `heph-core/src/recurrence.rs` | checkbox reset properties (pre-existing); `next_occurrence` is strictly after `after`; arbitrary RRULE strings never panic |
| `heph-core/src/hlc.rs` | HLC ordering properties (pre-existing); `parse` never panics |
| `hephd/src/datespec.rs` | `parse_date` never panics (including huge `+N` offsets); offsets and ISO dates round-trip |
| `hephd/src/quickadd.rs` | `parse` never panics; title words always come from the input |
Run them with the rest of the suite:
```bash
cargo test
```
## Tier 2 — coverage-guided fuzzing (cargo-fuzz, nightly, run ad-hoc)
libFuzzer targets live in `crates/heph-core/fuzz/`. These are for the surfaces
where coverage guidance beats random generation — chiefly the CRDT layer, which
decodes attacker-controllable bytes arriving via sync (`yrs` update payloads in
op-log entries).
Targets:
- `crdt_merge` — feeds arbitrary `(state, delta)` byte pairs to `merge_body`;
asserts no panic and merge idempotence. This is the remote-input surface.
- `crdt_write` — arbitrary `(prev, new)` string pairs through `write_body`;
asserts the diff/CRDT round-trip materializes `new` exactly (UTF-8 boundary
stress).
- `extract` — arbitrary markdown through `extract` + `context_item_lines`;
asserts the 1:1 alignment invariant promotion depends on.
Requirements: `rustup toolchain install nightly` and `cargo install cargo-fuzz`.
The fuzz targets reach crate-private CRDT internals through the `fuzzing`
cargo feature of `heph-core`, which exposes thin public wrappers — the feature
is never enabled in normal builds.
Run all targets briefly (default 60s each), or one target for longer:
```bash
mise run fuzz # all targets, 60s each
mise run fuzz 300 # all targets, 5 min each
cargo +nightly fuzz run crdt_merge --fuzz-dir crates/heph-core/fuzz -- -max_total_time=3600
```
Crash artifacts land in `crates/heph-core/fuzz/artifacts/<target>/`; the corpus
accumulates in `crates/heph-core/fuzz/corpus/<target>/` (both gitignored).
Reproduce a crash with
`cargo +nightly fuzz run <target> --fuzz-dir crates/heph-core/fuzz <artifact-path>`.
Tier 2 is deliberately not wired into CI: it needs nightly and meaningful wall
clock to earn its keep. Run it ad-hoc after touching `crdt.rs`, `extract.rs`,
or the sync payload path. If it ever moves to CI, a scheduled (not per-push)
workflow with a persistent corpus is the right shape.
## Findings so far
The first runs paid for themselves. Tier 1 proptests found two reachable
panics on user input, both fixed in the same change:
- **`datespec::parse_offset`** panicked on a large relative offset (e.g.
`+999999999999d`) because chrono's `+` overflows; now uses checked
arithmetic and returns an out-of-range error.
- **`datespec::parse_month_day`** sliced a token on a non-char boundary for
multibyte input (e.g. an `every <Month> <day>` phrase containing `𐻂`); now
takes the first three *chars*.
Tier 2 (`crdt_merge`) surfaced **robustness gaps in `yrs` 0.27 on malformed
update bytes**, reachable through the authenticated `/sync/push` path:
- a tiny delta `[255, 255, 255, 126]` triggers a huge allocation → **OOM**;
- some inputs trip a `debug_assert!` in the yrs block decoder (unwinding
panic — contained by the `catch_unwind` in `merge_body`);
- at least one class hits genuine UB (an invalid `char`) → `SIGABRT` under
debug UB-checks, silent UB in release.
These are not fully fixable in-tree: `yrs` exposes no pre-apply validator, and
the OOM/abort classes are uncatchable. The blast radius is limited (the sync
endpoint is authenticated), but a buggy or hostile authenticated peer can still
crash a daemon. The `catch_unwind` in `merge_body` is partial mitigation;
durable fixes need upstream `yrs` work or a bounded decoder. Until then this is
a known limitation, tracked here and reproduced by the `crdt_merge` target.
## Why these targets
The high-value surfaces, ranked when this was set up:
1. **`crdt::merge_body`** — decodes untrusted bytes from sync peers; a panic
here is a remote-input daemon crash.
2. **`extract`** — custom scanning logic layered over pulldown-cmark; promotion
rewrites body lines based on its output, so misalignment corrupts bodies.
3. **`wikilink` rewriting** — span arithmetic where off-by-ones hide.
4. **`datespec`/`quickadd`** — user-typed input parsed inside the daemon
process.
Crashes found in dependencies (`yrs`, `rrule`, `pulldown-cmark`) are still
real `hephd` crashes — handle by validating/catching before the call, and
report upstream.
## Related
- [[v1-prototype-tech-spec]] — testing strategy
- [[design]] — sync/CRDT rationale

View file

@ -23,4 +23,3 @@ Task-oriented guides for common operations.
- [[self-update]] — Opt-in `hephd` self-update: poll the forge for new releases and auto-update
- [[heph-pwa]] — The mobile app: an installable PWA mirror of heph-tui (browse, triage, fast quick-add, voice)
- [[host-heph-pwa]] — Serve the mobile app from the hub (indri) with OIDC, in the hub/spoke deployment
- [[fuzz-testing]] — Property tests (proptest, in `cargo test`) and cargo-fuzz targets (`mise run fuzz`) for the parsing/CRDT surfaces

View file

@ -73,37 +73,23 @@ avoid re-authenticating often, set generous validities on the heph provider:
## 2. Bring up the hub on `indri`
**Seed it from `gilbert` (Path A) with `--owner-id`.** No snapshot copy: start
the hub on a **fresh, empty store** that adopts `gilbert`'s `owner_id`, and the
spoke's first sync replays its entire op-log into the hub (sync is op-based, so
a hub that shares the owner rebuilds completely from ops). The hub gets its own
device origin by construction — no origin-reset step, and `gilbert` is never
rewritten.
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
copy its store to `indri`, and give `indri` its **own device origin** so the two
replicas don't share one (see *Current gaps* — this seeding step is the bit the
blumeops deployment finalizes). `indri` now holds `gilbert`'s data under the same
`owner_id`.
Find the device's owner id on `gilbert` (any node row carries it):
```bash
heph show "$(heph list --json | jq -r '.[0].node_id')" | grep owner_id
# or directly: sqlite3 ~/.local/share/heph/heph.db 'SELECT id FROM users'
```
Run the hub with that owner and auth enabled (issuer **and** audience together
turn auth on; omit both only for local dev):
Run the hub with auth enabled (issuer **and** audience together turn auth on;
omit both only for local dev):
```bash
hephd --mode server \
--http-addr 0.0.0.0:8787 \
--db /var/lib/heph/heph.db \
--owner-id <gilbert-owner-id> \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-audience <heph-client-id>
```
`--owner-id` is idempotent once adopted, so it is safe baked into the service
unit. (Copying the SQLite snapshot still works as a seeding shortcut for huge
stores, but then the copy shares `gilbert`'s device origin and must have it
reset — prefer the flag.)
The first identity to authenticate **claims** the hub's owner; thereafter only
that identity is served (single-owner today — see [[design]] and the
`Adoption + multi-tenant` task for the multi-tenancy seam).
@ -164,6 +150,15 @@ The spoke then can't refresh on its own and needs a re-login — but this is
Run the printed `heph auth login …` command to restore sync.
## Current gaps (finalized by the blumeops deployment)
The flag-level flow above works today; one enabler makes it a clean, managed
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
- **Path A seeding is manual** (copy the store + reset the device origin). A
small enabler — seed a hub from a snapshot with a fresh origin, or
`hephd --owner-id` — would make this one step.
> `heph daemon start`/`restart` can now bake the spoke/hub config (`--hub-url`,
> `--mode server`, `--http-addr`, `--oidc-*`) into the generated service (see
> [[run-the-daemon]]). The canonical hub on `indri` is still provisioned via the

View file

@ -58,7 +58,6 @@ 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 fuzz [seconds] [target]` | Run the nightly cargo-fuzz targets briefly — see [[fuzz-testing]] |
| `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 |

View file

@ -1,43 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run the cargo-fuzz targets briefly (nightly). Usage: mise run fuzz [seconds] [target]"
# Tier 2 fuzzing (see docs/how-to/fuzz-testing.md). Nightly-only and ad-hoc —
# not part of `cargo test` or CI. Runs each libFuzzer target for a bounded time
# so it terminates; the corpus under fuzz/corpus/ persists between runs.
#
# mise run fuzz # all targets, 60s each
# mise run fuzz 300 # all targets, 5 min each
# mise run fuzz 600 crdt_merge # one target, 10 min
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FUZZ_DIR="$ROOT/crates/heph-core/fuzz"
SECONDS_PER="${1:-60}"
ONLY="${2:-}"
if ! command -v cargo-fuzz >/dev/null 2>&1 && ! cargo +nightly fuzz --version >/dev/null 2>&1; then
echo "cargo-fuzz not found. Install with:" >&2
echo " rustup toolchain install nightly && cargo install cargo-fuzz" >&2
exit 1
fi
if [[ -n "$ONLY" ]]; then
targets=("$ONLY")
else
# No `mapfile` — macOS ships bash 3.2. Target names are bare words.
# shellcheck disable=SC2207
targets=($(cargo +nightly fuzz list --fuzz-dir "$FUZZ_DIR"))
fi
rc=0
for t in "${targets[@]}"; do
echo "=== fuzzing $t for ${SECONDS_PER}s ==="
if ! cargo +nightly fuzz run "$t" --fuzz-dir "$FUZZ_DIR" -- -max_total_time="$SECONDS_PER"; then
echo "!!! $t produced a crash — artifact in $FUZZ_DIR/artifacts/$t/" >&2
rc=1
fi
done
exit "$rc"