Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
8fb689e917 Merge pull request 'Fuzz testing: proptest tier + cargo-fuzz tier (fixes 2 parser panics)' (#19) from feature/fuzz-testing into main
All checks were successful
Build / validate (push) Successful in 9m2s
Reviewed-on: #19
2026-06-09 15:21:38 -07:00
e7ced4f8f9 test(fuzz): add cargo-fuzz targets for CRDT and extraction surfaces
All checks were successful
Build / validate (pull_request) Successful in 10m29s
Tier 2 fuzzing: a nightly cargo-fuzz crate at crates/heph-core/fuzz/ with
three targets (crdt_merge, crdt_write, extract), reaching crate-private CRDT
internals through heph-core's new 'fuzzing' feature. Driven ad-hoc via
'mise run fuzz'; not in CI (needs nightly + wall clock).

crdt_merge immediately surfaced robustness gaps in yrs 0.27 on malformed sync
deltas (a 4-byte input OOMs; other inputs abort/UB) — uncatchable, limited
blast radius (authenticated /sync/push), documented as a known limitation.
extract and crdt_write ran clean over ~1M cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 13:03:10 -07:00
c0e633f7a6 test: add property tests for parsing/CRDT surfaces; fix two parser panics
Tier 1 fuzzing: proptest properties across extract, wikilink, crdt,
frontmatter, recurrence, hlc, datespec, and quickadd — they run with the
normal cargo test suite. Fuzzing surfaced and this fixes:

- parse_offset overflowing chrono date arithmetic on huge offsets (panic)
- parse_month_day slicing a multibyte token on a non-char boundary (panic)

Also hardens crdt::merge_body against malformed sync deltas with catch_unwind
(partial: yrs 0.27 can still SIGABRT/UB on some inputs — tracked separately).
Fixes the extract nested-checkbox alignment so context_item_lines stays 1:1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 12:56:31 -07:00
4960e72e76 docs: add fuzz-testing how-to (two-tier proptest + cargo-fuzz plan)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 12:45:41 -07:00
Forgejo Actions
98c9c7a517 Update changelog for v1.4.3 [skip ci] 2026-06-09 11:39:17 -07:00
c8009ad0ef Merge pull request 'Task sweep: undoable delete, conflicts UI, re-parenting, https sync, observability, and more' (#18) from feature/task-sweep into main
All checks were successful
Build / validate (push) Successful in 7m59s
Reviewed-on: #18
2026-06-09 11:37:32 -07:00
b33cafe2e0 feat(core)!: reject manual creation of derived/internal link types
All checks were successful
Build / validate (pull_request) Successful in 9m34s
A hand-made `wiki` link row looked like it worked, then silently
vanished on the next body write — the links table's wiki rows are an
index derived from `[[…]]` in the body, and `sync_wiki_links` diffs
them against it. Store::add_link now rejects `wiki` (with a pointer at
the body as the durable path) plus the `canonical-context` / `log-of`
task internals. Body materialization uses the internal path and is
unaffected; `link.add` ops arriving over sync still apply.

Drops the wiki-links explanation card — it existed to document the
footgun, and the footgun is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 11:24:36 -07:00
8417f70326 feat(tui): undoable delete, rename, project re-parent, conflicts view, full log view
All checks were successful
Build / validate (pull_request) Successful in 9m21s
- D (task) and D (project) are now undoable with u: task delete restores
  via node.restore; project delete restores the node and re-files the
  tasks the delete unfiled. Redo re-deletes.
- R renames the highlighted task in place (prefilled input modal),
  keeping its canonical-context doc's title in step; undoable.
- m on a sidebar project opens the move picker in re-parent mode —
  self + descendants excluded, "(Move to root)" detaches; undoable.
- C opens a conflicts review in the center pane: per-row node title,
  field, and both values; l/r keeps local/remote (applies the value via
  the new conflicts.resolve semantics) and refreshes the chip.
- L opens the highlighted task's full scrollable log (the preview pane
  shows only the last five lines).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 11:12:06 -07:00
3db026f6e5 feat: actionable conflicts, node restore, project move, task-aware show
- conflicts.resolve now applies the chosen value (local/remote) as a new
  task.set op — peers converge on the decision — instead of only marking
  the row resolved.
- heph node restore <id> — undo of node rm.
- heph project move <name> --parent <p>|--root — post-creation
  re-parenting from the CLI.
- heph show on a task prints its canonical-context doc body (the task
  node's own body is always null and was hiding the real content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 11:03:13 -07:00
8fe11c75cd feat(hephd): --owner-id flag — one-step Path-A hub seeding
All checks were successful
Build / validate (pull_request) Successful in 13m12s
A fresh hub started with an existing device's owner id rebuilds itself
entirely from that spoke's first full op-log push: no snapshot copy and
no origin reset (the new store mints its own). adopt_owner is idempotent
once adopted, so the flag is safe baked into a service unit.
[[set-up-sync-hub]] documents the recipe and drops the manual-seeding gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:55:17 -07:00
0e5bed3282 feat: daemon status surfaces runtime config + self-update state
sync.status now carries a runtime block — version, mode, sync cadence,
and self-update state (interval + last check/outcome, tracked by a new
SelfUpdateHealth shared with the poll loop). `heph daemon status` asks
the live daemon and prints it under the service facts: hub + oidc, sync
health at a glance, open-conflict count, self-update status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:53:05 -07:00
e65e2d3910 feat(hephd): sync observability — recovery, per-cycle volume, throttled failures
Background sync now logs cycles that move ops at info (pulled/applied/
pushed + the cursors they advanced to), announces recovery with the
length of the failure streak it ends, and suppresses repeats of an
identical failure to one warn per ten cycles. SyncReport carries the
advanced cursors (additive, wire-compatible).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:49:37 -07:00
66d78ac39a fix(hephd): https hub urls work; sync errors name the phase and hub
reqwest was built with no TLS backend, so any https --hub-url failed
with a bare "error sending request". Compile in rustls (platform trust
store via rustls-platform-verifier) and wrap each sync phase's errors
with the url and phase so failures are actionable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:45:56 -07:00
9189543b4c feat(hephd): node.restore and project.reparent RPCs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:43:55 -07:00
aea7a51860 feat(core): export expands [[id]] wiki-links to readable labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:42:32 -07:00
32197bd170 feat(core): reparent_project — change a project's parent after creation
Tombstone the old parent link, add the new one (or none for root), with
non-project endpoints and cycle-creating moves rejected via the existing
project_subtree traversal. Pure link ops — no schema or sync change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:41:41 -07:00
f62836b1b4 feat(core): node.restore op + tombstone cascade to task attachments
Tombstone/restore are an LWW pair keyed by their own op HLCs, with the
winner derived from the op-log at apply time — no schema change, and the
old monotonic rule survives as the no-restores degenerate case.
Tombstoning a node now cascades to its canonical-context and log docs
(no more orphaned FTS leftovers); restore revives them symmetrically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:40:20 -07:00
05212133ac docs: task-sweep end state — wiki-links explanation + changelog fragments
All checks were successful
Build / validate (pull_request) Successful in 5m33s
Docs-first commit for the feature/task-sweep branch, which addresses the
bulk of the outstanding Hephaestus-project tasks in one sweep. The
fragments describe the intended end state; code follows.

Also delivers the "understand heph link wiki semantics" task outright:
the new [[wiki-links]] explanation card documents body-vs-links-table
layering, the manual-wiki-link reconciliation footgun, resolution tiers,
and per-client surfaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:33:59 -07:00
Forgejo Actions
19ababc57f Update changelog for v1.4.2 [skip ci] 2026-06-09 09:16:12 -07:00
2911f418a5 Merge pull request 'Attention as a1–a4: set bands directly, retire cycling' (#17) from feature/attention-a1-a4 into main
Some checks failed
Build / validate (push) Failing after 6m30s
Reviewed-on: #17
2026-06-09 09:15:32 -07:00
57 changed files with 3161 additions and 177 deletions

View file

@ -12,6 +12,38 @@ 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",
"jni 0.22.4",
"libc",
"log",
"ndk",
@ -512,6 +512,28 @@ 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"
@ -792,6 +814,12 @@ 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"
@ -895,6 +923,15 @@ 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"
@ -1375,6 +1412,12 @@ 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"
@ -1844,6 +1887,12 @@ 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"
@ -1936,8 +1985,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -2273,6 +2324,7 @@ dependencies = [
"heph-core",
"jsonwebtoken",
"keyring-core",
"proptest",
"rand 0.8.6",
"reqwest",
"rsa",
@ -2389,6 +2441,22 @@ 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"
@ -2636,6 +2704,22 @@ 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"
@ -2921,6 +3005,12 @@ 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"
@ -3601,6 +3691,12 @@ 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"
@ -4092,6 +4188,62 @@ 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"
@ -4353,16 +4505,22 @@ 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",
@ -4507,6 +4665,7 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@ -4516,21 +4675,62 @@ 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",
@ -4569,6 +4769,15 @@ 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"
@ -5301,6 +5510,21 @@ 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"
@ -5327,6 +5551,16 @@ 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"
@ -5977,7 +6211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
dependencies = [
"core-foundation 0.10.1",
"jni",
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.4",
@ -5986,6 +6220,15 @@ 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"
@ -6454,6 +6697,15 @@ 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"
@ -6490,6 +6742,21 @@ 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"
@ -6532,6 +6799,12 @@ 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"
@ -6544,6 +6817,12 @@ 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"
@ -6556,6 +6835,12 @@ 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"
@ -6580,6 +6865,12 @@ 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"
@ -6592,6 +6883,12 @@ 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"
@ -6604,6 +6901,12 @@ 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"
@ -6616,6 +6919,12 @@ 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,9 +55,13 @@ 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,6 +8,12 @@ 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

5
crates/heph-core/fuzz/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,41 @@
# 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

@ -0,0 +1,15 @@
#![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

@ -0,0 +1,21 @@
#![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

@ -0,0 +1,27 @@
#![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

@ -0,0 +1,7 @@
# 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,24 +109,71 @@ 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 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),
}
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),
}
})
}
/// Materialize a stored CRDT state blob to its body text.
#[cfg(test)]
#[cfg(any(test, feature = "fuzzing"))]
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`.
@ -207,4 +254,62 @@ 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,50 +47,58 @@ 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;
// The task item currently being collected, if any: (checked, accumulated text).
let mut current: Option<(bool, String)> = None;
// 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);
}
}
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) => {
current = Some((checked, String::new()));
context_items.push(ContextItem {
checked,
text: String::new(),
});
if let Some(frame) = open_items.last_mut() {
*frame = Some(context_items.len() - 1);
}
}
Event::End(TagEnd::Item) => {
if let Some((checked, text)) = current.take() {
context_items.push(ContextItem {
checked,
text: text.trim().to_string(),
});
}
open_items.pop();
}
Event::Text(text) => {
if code_depth > 0 {
code_ranges.push(range);
}
if let Some((_, label)) = current.as_mut() {
label.push_str(&text);
}
append(&mut context_items, &open_items, &text);
}
// Inline code is part of an item's visible label, but its contents
// are never a wiki-link source.
Event::Code(code) => {
code_ranges.push(range);
if let Some((_, label)) = current.as_mut() {
label.push_str(&code);
}
append(&mut context_items, &open_items, &code);
}
Event::SoftBreak | Event::HardBreak => {
if let Some((_, label)) = current.as_mut() {
label.push(' ');
}
append(&mut context_items, &open_items, " ");
}
_ => {}
}
}
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.
@ -243,6 +251,28 @@ 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";
@ -253,4 +283,55 @@ 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,4 +89,35 @@ 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,5 +218,12 @@ 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,6 +16,9 @@ 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,6 +19,10 @@ 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,5 +181,31 @@ 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,7 +9,10 @@
//! scalar value from a different device is recorded in `conflicts` (surfaced,
//! not silently dropped).
//! - **links:** OR-set add/remove keyed by the link's own id → no conflicts.
//! - **tombstones:** monotonic — once set, they stay.
//! - **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.
//!
//! 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.
@ -19,9 +22,9 @@ use serde_json::Value;
use super::{absorb_remote_hlc, new_id, nodes, ops};
use crate::crdt;
use crate::error::Result;
use crate::error::{Error, Result};
use crate::hlc::Hlc;
use crate::model::Conflict;
use crate::model::{Conflict, TaskState};
use crate::oplog::{op_type, Op};
/// Open conflicts for `owner`, newest first.
@ -46,14 +49,71 @@ pub(super) fn list_conflicts(conn: &Connection, owner: &str) -> Result<Vec<Confl
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// 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(
/// 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(
"UPDATE conflicts SET status = 'resolved' WHERE id = ?1",
[id],
)?;
tx.commit()?;
Ok(())
}
@ -78,8 +138,8 @@ pub(super) fn apply(conn: &mut Connection, op: &Op) -> Result<bool> {
node_upsert(&tx, op)?;
true
}
op_type::NODE_TOMBSTONE => {
node_tombstone(&tx, op)?;
op_type::NODE_TOMBSTONE | op_type::NODE_RESTORE => {
node_tombstone_state(&tx, op)?;
true
}
op_type::TASK_CREATE | op_type::TASK_SET => {
@ -204,19 +264,44 @@ fn node_upsert(tx: &Connection, op: &Op) -> Result<()> {
Ok(())
}
fn node_tombstone(tx: &Connection, op: &Op) -> Result<()> {
// Monotonic: tombstone always wins. Bump hlc only if this op is newer.
if let Some(existing) = nodes::get(tx, &op.target_id)? {
let hlc = if op.hlc.as_str() > existing.hlc.as_str() {
op.hlc.clone()
} else {
existing.hlc.clone()
};
tx.execute(
"UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3",
(op_physical(op), hlc, &op.target_id),
)?;
}
/// 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),
)?;
Ok(())
}

View file

@ -23,9 +23,20 @@ pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result<usize
let mut count = 0;
for id in ids {
let Some(node) = nodes::get(conn, &id)? else {
let Some(mut 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,6 +103,17 @@ 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,7 +199,28 @@ impl Store for LocalStore {
fn tombstone_node(&mut self, id: &str) -> Result<()> {
let now = self.clock.now_ms();
nodes::tombstone(&self.conn, &self.owner_id, now, id)
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(())
}
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
@ -248,6 +269,11 @@ 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,
@ -319,6 +345,27 @@ 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)
}
@ -464,7 +511,9 @@ impl Store for LocalStore {
}
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()> {
apply::resolve_conflict(&self.conn, id, choice)
let now = self.clock.now_ms();
let owner = self.owner_id.clone();
apply::resolve_conflict(&mut self.conn, &owner, now, id, choice)
}
}

View file

@ -431,17 +431,32 @@ 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 — tombstones keep merge
/// monotonic (tech-spec §4.3).
/// 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.
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 = ?1, hlc = ?2 WHERE id = ?3",
(now, &hlc, id),
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
(dead as i64, now, &hlc, id),
)?;
if updated == 0 {
return Err(Error::NodeNotFound(id.to_string()));
}
ops::record(conn, owner, &hlc, op_type::NODE_TOMBSTONE, id, json!({}))?;
let op = if dead {
op_type::NODE_TOMBSTONE
} else {
op_type::NODE_RESTORE
};
ops::record(conn, owner, &hlc, op, 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).
fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> {
pub(super) 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,6 +662,51 @@ 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,9 +38,15 @@ pub trait Store {
body: Option<String>,
) -> Result<Node>;
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3).
/// Tombstone (soft-delete) a node, cascading to its internal attachments
/// (canonical-context doc, log doc). 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`).
@ -98,6 +104,12 @@ 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
@ -245,6 +257,8 @@ 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"`).
/// 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.
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>;
}

View file

@ -148,4 +148,41 @@ 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,3 +267,134 @@ 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,3 +56,24 @@ 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,3 +414,111 @@ 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,6 +107,12 @@ 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.
@ -118,6 +124,31 @@ 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)]
@ -173,8 +204,7 @@ impl From<&RankedTask> for TaskSnapshot {
}
/// The original triage action, kept alongside its before-snapshot so redo can
/// re-apply it without re-reading state. (Delete/tombstone is *not* here — it has
/// no restore path yet, so it is excluded from undo and guarded by its y/N prompt.)
/// re-apply it without re-reading state.
#[derive(Debug, Clone, PartialEq, Eq)]
enum TriageAction {
State(&'static str), // "done" | "dropped"
@ -183,22 +213,53 @@ enum TriageAction {
Move(Option<String>), // re-file (or unfile) to a project id
}
/// One reversible step: the task state before it + the action that changed it.
/// One reversible step. Scalar triage carries a before-snapshot; the
/// structural actions (delete, rename, re-parent) carry exactly what their
/// inverse needs.
#[derive(Debug, Clone, PartialEq, Eq)]
struct UndoEntry {
before: TaskSnapshot,
action: TriageAction,
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>,
},
}
/// Cap on the undo history, so a long session can't grow it unbounded.
const UNDO_CAP: usize = 200;
/// One choice in the move-to-project picker.
/// One choice in the move picker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveOption {
/// Remove the task from any project.
Unfile,
/// File under an existing project.
/// Detach the project to the root (re-parent mode's "no parent").
Root,
/// File under (or re-parent to) an existing project.
Project { id: String, title: String },
/// Create a new project named after the filter text, then file under it.
Create { name: String },
@ -209,40 +270,59 @@ 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}\""),
}
}
}
/// 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.
/// 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MoveState {
pub task_id: String,
pub task_title: String,
/// The task's state before the move, for undo.
before: TaskSnapshot,
/// All projects, title-sorted — the source the `filter` narrows.
/// 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.
projects: Vec<Project>,
/// The live filter query (fzf-style subsequence match).
pub filter: String,
/// The currently visible choices (`(Unfile)` + matching projects + an
/// optional "create" row), recomputed whenever `filter` changes.
/// The currently visible choices, recomputed whenever `filter` changes.
pub options: Vec<MoveOption>,
pub cursor: usize,
}
impl MoveState {
/// Rebuild `options` from `projects` + `filter`: `(Unfile)` (when it matches
/// or the filter is empty), the fuzzy-matching projects, and — when the text
/// names no existing project — a "create" row. Clamps the cursor.
/// 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.
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, "Unfile") {
opts.push(MoveOption::Unfile);
if f.is_empty() || fuzzy_match(f, none_label) {
opts.push(none_opt);
}
for p in &self.projects {
if f.is_empty() || fuzzy_match(f, &p.title) {
@ -252,7 +332,8 @@ impl MoveState {
});
}
}
if !f.is_empty()
if is_task
&& !f.is_empty()
&& !self
.projects
.iter()
@ -428,6 +509,10 @@ 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
@ -474,6 +559,8 @@ 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(),
@ -772,22 +859,79 @@ impl<B: Backend> App<B> {
// --- undo / redo (`u` / Ctrl-z) ---
/// Record a reversible step and invalidate the redo stack.
/// Record a reversible scalar-triage step.
fn push_undo(&mut self, before: TaskSnapshot, action: TriageAction) {
self.undo_stack.push(UndoEntry { before, action });
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);
if self.undo_stack.len() > UNDO_CAP {
self.undo_stack.remove(0);
}
self.redo_stack.clear();
}
/// Undo the last triage action (restores the task's prior state).
/// Undo the last reversible action.
pub fn undo(&mut self) {
let Some(entry) = self.undo_stack.pop() else {
self.status = "nothing to undo".into();
return;
};
self.restore(&entry.before, format!("undo: {}", entry.before.title));
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.redo_stack.push(entry);
}
@ -797,8 +941,52 @@ impl<B: Backend> App<B> {
self.status = "nothing to redo".into();
return;
};
let status = format!("redo: {}", entry.before.title);
self.apply_action(entry.before.task_id.clone(), entry.action.clone(), status);
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();
}
}
self.undo_stack.push(entry);
}
@ -862,16 +1050,39 @@ impl<B: Backend> App<B> {
}
}
/// Confirm the armed delete: tombstone the task or project and reload.
/// Confirm the armed delete: tombstone the task or project, record the
/// undo entry, and reload.
pub fn confirm_delete(&mut self) {
match self.pending_delete.take() {
Some(PendingDelete::Task { task_id, title }) => {
self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id));
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)
});
}
Some(PendingDelete::Project { project_id, title }) => {
self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| {
b.delete_project(&project_id)
// 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; u to undo)"),
|b| b.delete_project(&project_id),
);
self.rebuild_projects();
self.reload();
}
@ -895,9 +1106,11 @@ impl<B: Backend> App<B> {
return;
};
let mut state = MoveState {
task_id: t.node_id.clone(),
task_title: t.title.clone(),
before: TaskSnapshot::from(&t),
subject_id: t.node_id.clone(),
subject_title: t.title.clone(),
kind: MoveKind::Task {
before: TaskSnapshot::from(&t),
},
projects: self.project_list(),
filter: String::new(),
options: Vec::new(),
@ -916,6 +1129,64 @@ 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 {
@ -942,8 +1213,8 @@ impl<B: Backend> App<B> {
}
}
/// Apply the highlighted choice: unfile, re-file, or create-then-file, and
/// reload.
/// Apply the highlighted choice and reload. Task mode: unfile, re-file, or
/// create-then-file. Project mode: re-parent (or detach to the root).
pub fn move_picker_submit(&mut self) {
let Mode::MoveToProject(m) = &self.mode else {
return;
@ -951,30 +1222,53 @@ impl<B: Backend> App<B> {
let Some(choice) = m.options.get(m.cursor).cloned() else {
return;
};
let task_id = m.task_id.clone();
let title = m.task_title.clone();
let before = m.before.clone();
let subject_id = m.subject_id.clone();
let title = m.subject_title.clone();
let kind = m.kind.clone();
self.mode = Mode::Normal;
match choice {
MoveOption::Unfile => {
self.push_undo(before, TriageAction::Move(None));
self.mutate(format!("→ (Unfile): {title}"), |b| {
b.set_project(&task_id, None)
});
}
MoveOption::Project { id, title: pt } => {
self.push_undo(before, TriageAction::Move(Some(id.clone())));
self.mutate(format!("{pt}: {title}"), move |b| {
b.set_project(&task_id, Some(&id))
});
}
MoveOption::Create { name } => {
// Creating + filing is constructive; not added to the undo history
// (we'd have to track the new project's id to replay it).
self.mutate(format!("→ new project \"{name}\": {title}"), move |b| {
let id = b.create_project(&name)?;
b.set_project(&task_id, Some(&id))
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(),
});
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();
}
}
@ -1064,6 +1358,23 @@ 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 {
@ -1078,6 +1389,114 @@ 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 {
@ -1187,6 +1606,32 @@ 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,6 +90,18 @@ 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) ---
@ -100,6 +112,13 @@ 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.
@ -224,6 +243,43 @@ 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,6 +159,34 @@ 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();
@ -194,6 +222,7 @@ 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 {
@ -204,14 +233,16 @@ 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 => {
if let KeyCode::Char('D') = key.code {
app.begin_delete_project()
}
}
Focus::Sidebar => match key.code {
KeyCode::Char('m') => app.begin_reparent(),
KeyCode::Char('D') => app.begin_delete_project(),
_ => {}
},
},
}
None

View file

@ -19,14 +19,18 @@ 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 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 R rename L log 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 D del-project u undo s sort / search Tab tasks q quit";
" j/k move ⏎ open n add m move-project 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()
@ -44,7 +48,11 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
.split(outer[0]);
render_sidebar(frame, app, panes[0]);
if app.search.is_some() {
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() {
render_search(frame, app, panes[1]);
} else {
render_tasks(frame, app, panes[1]);
@ -87,7 +95,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.task_title)),
.title(format!(" Move \"{}\" to ", state.subject_title)),
);
frame.render_widget(input, chunks[0]);
@ -474,6 +482,86 @@ 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() {
@ -521,7 +609,11 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
);
return;
}
let hints = if app.search.is_some() {
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() {
SEARCH_HINTS
} else if app.focus == Focus::Sidebar {
SIDEBAR_HINTS

View file

@ -28,9 +28,13 @@ 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 {
@ -57,6 +61,8 @@ 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>>,
}
@ -74,8 +80,23 @@ 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(Vec::new())
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 search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
Ok(self
@ -99,6 +120,24 @@ 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(())
}
@ -357,7 +396,7 @@ fn move_to_project_picker_refiles_the_selected_task() {
app.begin_move();
match &app.mode {
Mode::MoveToProject(m) => {
assert_eq!(m.task_id, "t1");
assert_eq!(m.subject_id, "t1");
assert_eq!(
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
vec!["(Unfile)", "Camano"]
@ -568,3 +607,218 @@ 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,6 +281,12 @@ 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)]
@ -291,7 +297,8 @@ enum LinkAction {
src: String,
/// Destination node id.
dst: String,
/// Link type: blocks|parent|tagged|in-project|context-of|…
/// 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: String,
},
}
@ -308,6 +315,17 @@ 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)]
@ -635,6 +653,16 @@ 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 } => {
@ -715,6 +743,10 @@ 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 }))?;
@ -788,6 +820,26 @@ 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,6 +691,86 @@ 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,3 +249,78 @@ 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,6 +44,7 @@ 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

@ -0,0 +1,7 @@
# 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,12 +98,20 @@ fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> {
let n: u64 = num
.parse()
.with_context(|| format!("not a relative date offset: +{rest}"))?;
match unit.trim() {
"" | "d" | "day" | "days" => Ok(today + Days::new(n)),
"w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)),
"m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)),
// 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))),
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
@ -258,7 +266,10 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> {
return None;
}
let month = |t: &str| -> Option<u32> {
match &t[..t.len().min(3)] {
// 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() {
"jan" => Some(1),
"feb" => Some(2),
"mar" => Some(3),
@ -637,4 +648,37 @@ 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,6 +84,12 @@ 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)]
@ -154,7 +160,9 @@ async fn main() -> Result<()> {
};
(
None,
Daemon::new(store).with_self_update(self_update.clone()),
Daemon::new(store)
.with_mode("client")
.with_self_update(self_update.clone()),
)
}
Mode::Local | Mode::Server => {
@ -165,11 +173,21 @@ async fn main() -> Result<()> {
}
// Take the exclusive lock before opening the store (tech-spec §3.1).
let lock = LockGuard::acquire(&db)?;
let store = LocalStore::open(&db, Box::new(SystemClock))?;
let 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 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,4 +240,25 @@ 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,11 +133,23 @@ 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,6 +178,14 @@ 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,
@ -344,6 +352,11 @@ 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)?)
@ -385,6 +398,11 @@ 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,6 +32,29 @@ 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.
@ -225,13 +248,15 @@ pub async fn apply_update(
}
/// The background poll loop: tick on `interval`, check for a newer release, and
/// when one is available, apply it. Runs forever; spawned as a task.
/// when one is available, apply it. Each check's outcome is folded into
/// `health` for `sync.status`. 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 {
@ -239,14 +264,22 @@ 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"),
CheckOutcome::Failed(e) => tracing::warn!("self-update: release check 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}"));
}
}
}
}

View file

@ -64,6 +64,12 @@ 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).
@ -153,6 +159,46 @@ 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
@ -196,10 +242,19 @@ 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;
@ -254,6 +309,7 @@ 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,
@ -261,6 +317,7 @@ impl Daemon {
restarter,
cfg.interval,
heph_core::VERSION,
health,
)
.await;
});
@ -273,9 +330,15 @@ 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 {
@ -285,7 +348,13 @@ impl Daemon {
// rejected refresh) and skip; sending an unauthenticated
// request would only 401 and mask it.
record_bearer_failure(&ctx, &e);
tracing::warn!("background sync: could not obtain bearer token: {e}");
let msg = format!("could not obtain bearer token: {e}");
if log.on_failure(&msg) {
tracing::warn!(
consecutive = log.consecutive_failures,
"background sync: {msg}"
);
}
continue;
}
};
@ -293,8 +362,35 @@ impl Daemon {
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx, &result);
match result {
Ok(report) => tracing::debug!(?report, "background sync"),
Err(e) => tracing::warn!("background sync failed: {e}"),
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}"
);
}
}
}
}
});
@ -415,9 +511,10 @@ 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), and the pending
/// merge-conflict count. A spoke that is silently failing is visible here (and,
/// via it, in the TUI status line).
/// 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`).
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
// Conflict count is meaningful even on a hub / standalone instance.
let store = ctx.store.clone();
@ -432,8 +529,35 @@ 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 }));
return Ok(json!({
"hub_url": Value::Null,
"conflicts": conflicts,
"runtime": runtime,
}));
};
let store = ctx.store.clone();
@ -472,5 +596,42 @@ 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::Result;
use anyhow::{Context, Result};
use axum::extract::{Query, Request, State};
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
use axum::middleware::{self, Next};
@ -72,6 +72,12 @@ 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
@ -391,13 +397,22 @@ pub async fn sync_once(
if let Some(token) = bearer {
req = req.bearer_auth(token);
}
let pulled: OpsBody = req.send().await?.error_for_status()?.json().await?;
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}"))?;
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?;
}
}
@ -417,9 +432,14 @@ pub async fn sync_once(
if let Some(token) = bearer {
req = req.bearer_auth(token);
}
req.send().await?.error_for_status()?;
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"))?;
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,3 +658,88 @@ 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,3 +204,54 @@ 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

@ -1 +0,0 @@
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

@ -0,0 +1 @@
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

@ -0,0 +1 @@
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

@ -0,0 +1 @@
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]].

125
docs/how-to/fuzz-testing.md Normal file
View file

@ -0,0 +1,125 @@
---
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,3 +23,4 @@ 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,23 +73,37 @@ avoid re-authenticating often, set generous validities on the heph provider:
## 2. Bring up the hub on `indri`
**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`.
**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.
Run the hub with auth enabled (issuer **and** audience together turn auth on;
omit both only for local dev):
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):
```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).
@ -150,15 +164,6 @@ 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,6 +58,7 @@ Technical reference material for the repository tooling that ships with this pro
| `mise run docs-check-links` | Validate wiki-links against existing doc filenames |
| `mise run docs-mikado` | Inspect active Mikado chains and resume C2 work |
| `mise run docs-preview <tarball>` | Extract and serve a released docs tarball locally |
| `mise run 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 |

43
mise-tasks/fuzz Executable file
View file

@ -0,0 +1,43 @@
#!/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"