generated from eblume/project-template
Compare commits
34 commits
feature/da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fb689e917 | |||
| e7ced4f8f9 | |||
| c0e633f7a6 | |||
| 4960e72e76 | |||
|
|
98c9c7a517 | ||
| c8009ad0ef | |||
| b33cafe2e0 | |||
| 8417f70326 | |||
| 3db026f6e5 | |||
| 8fe11c75cd | |||
| 0e5bed3282 | |||
| e65e2d3910 | |||
| 66d78ac39a | |||
| 9189543b4c | |||
| aea7a51860 | |||
| 32197bd170 | |||
| f62836b1b4 | |||
| 05212133ac | |||
|
|
19ababc57f | ||
| 2911f418a5 | |||
| 730863b832 | |||
| ebb2366236 | |||
|
|
b34371af87 | ||
| 17dab0e281 | |||
| 470ef1de0e | |||
| aec807fd28 | |||
| b04a71421e | |||
| 5c2b4bde2c | |||
|
|
2ca1e246f0 | ||
| 9a4f18fbd5 | |||
| e943a940f1 | |||
| b82264892f | |||
| f6b27414a8 | |||
| 5535cc7127 |
75 changed files with 4156 additions and 401 deletions
54
CHANGELOG.md
54
CHANGELOG.md
|
|
@ -12,6 +12,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
<!-- towncrier release notes start -->
|
<!-- 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
|
||||||
|
|
||||||
|
- The `heph` CLI and `heph-tui` now survive a daemon restart. Previously the unix-socket client connected once and never reconnected, so an opt-in self-update or `heph daemon restart` left every subsequent call failing — `heph-tui` would sit on errors until relaunched. The client now reconnects on a dropped socket: a request that never went out is retried transparently, while a reply lost mid-request is surfaced (not silently retried) so a mutation is never double-applied. A long-running TUI self-heals on its next refresh tick.
|
||||||
|
- Quick-add popover (⌘'): hand keyboard focus back to the previously active app when it hides, and stop the (now invisible) overlay from intercepting clicks where it used to sit.
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.4.0] - 2026-06-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Spoke auth failures now tell you how to recover. When a refresh token is rejected or the hub returns 401, `hephd` records the real cause plus the exact `heph auth login --hub-url … --issuer … --client-id …` command (keyed to this spoke's hub) in its sync health. A new `heph auth status` prints that health and the re-login command, `heph sync --status`'s `last_error` carries it, and `heph-tui`'s status line points at it with a `⚠ auth · heph auth status` chip.
|
||||||
|
- `heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config.
|
||||||
|
- heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- hephd no longer reports a rejected OAuth refresh as "identity provider unreachable". A reachable IdP that returns an HTTP error (e.g. `400 invalid_grant` once a refresh token expires/rotates) is now surfaced as a *rejection* — `identity provider rejected the request: HTTP 400 (invalid_grant): …` — with the OAuth error body, distinct from a genuine transport failure. This stops the wording from misdirecting incident response toward the network when the real fix is re-authentication.
|
||||||
|
- `heph daemon restart` on macOS no longer intermittently fails with `launchctl bootstrap failed: 5: Input/output error`. The old code bootstrapped immediately after `bootout`, racing launchd's asynchronous teardown; it now waits for the service to fully unload and retries the bootstrap. When the plist is unchanged (e.g. a plain binary upgrade) it uses `launchctl kickstart -k` to restart the loaded job atomically, sidestepping the bootout→bootstrap dance entirely.
|
||||||
|
|
||||||
|
|
||||||
## [v1.2.3] - 2026-06-06
|
## [v1.2.3] - 2026-06-06
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
315
Cargo.lock
generated
315
Cargo.lock
generated
|
|
@ -162,7 +162,7 @@ dependencies = [
|
||||||
"android-properties",
|
"android-properties",
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"cc",
|
"cc",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
|
|
@ -512,6 +512,28 @@ version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
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]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
|
|
@ -792,6 +814,12 @@ dependencies = [
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -895,6 +923,15 @@ dependencies = [
|
||||||
"error-code",
|
"error-code",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
|
|
@ -1375,6 +1412,12 @@ version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.16.9"
|
version = "0.16.9"
|
||||||
|
|
@ -1844,6 +1887,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1936,8 +1985,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2237,6 +2288,8 @@ dependencies = [
|
||||||
"heph-core",
|
"heph-core",
|
||||||
"hephd",
|
"hephd",
|
||||||
"libc",
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
@ -2271,6 +2324,7 @@ dependencies = [
|
||||||
"heph-core",
|
"heph-core",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
|
"proptest",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
|
@ -2387,6 +2441,22 @@ dependencies = [
|
||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
|
|
@ -2634,6 +2704,22 @@ version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
|
|
@ -2919,6 +3005,12 @@ dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"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]]
|
[[package]]
|
||||||
name = "mac_address"
|
name = "mac_address"
|
||||||
version = "1.1.8"
|
version = "1.1.8"
|
||||||
|
|
@ -3599,6 +3691,12 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "openssl-src"
|
name = "openssl-src"
|
||||||
version = "300.6.0+3.6.2"
|
version = "300.6.0+3.6.2"
|
||||||
|
|
@ -4090,6 +4188,62 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
|
|
@ -4351,16 +4505,22 @@ dependencies = [
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -4505,6 +4665,7 @@ version = "0.23.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
|
|
@ -4514,21 +4675,62 @@ dependencies = [
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
|
|
@ -4567,6 +4769,15 @@ dependencies = [
|
||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "scoped-tls"
|
name = "scoped-tls"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
@ -5299,6 +5510,21 @@ dependencies = [
|
||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
|
|
@ -5325,6 +5551,16 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
|
@ -5975,7 +6211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
|
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
|
|
@ -5984,6 +6220,15 @@ dependencies = [
|
||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
|
@ -6452,6 +6697,15 @@ dependencies = [
|
||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|
@ -6488,6 +6742,21 @@ dependencies = [
|
||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6530,6 +6799,12 @@ dependencies = [
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6542,6 +6817,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6554,6 +6835,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6578,6 +6865,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6590,6 +6883,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6602,6 +6901,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -6614,6 +6919,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,13 @@ dbus-secret-service-keyring-store = { version = "1", features = [
|
||||||
"vendored",
|
"vendored",
|
||||||
] }
|
] }
|
||||||
ureq = { version = "3", features = ["json"] }
|
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 = [
|
reqwest = { version = "0.13", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
"query",
|
"query",
|
||||||
|
"rustls",
|
||||||
] }
|
] }
|
||||||
semver = "1"
|
semver = "1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ publish.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.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]
|
[dependencies]
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
ulid.workspace = true
|
ulid.workspace = true
|
||||||
|
|
|
||||||
5
crates/heph-core/fuzz/.gitignore
vendored
Normal file
5
crates/heph-core/fuzz/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
target/
|
||||||
|
corpus/
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
|
Cargo.lock
|
||||||
41
crates/heph-core/fuzz/Cargo.toml
Normal file
41
crates/heph-core/fuzz/Cargo.toml
Normal 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]
|
||||||
15
crates/heph-core/fuzz/fuzz_targets/crdt_merge.rs
Normal file
15
crates/heph-core/fuzz/fuzz_targets/crdt_merge.rs
Normal 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");
|
||||||
|
});
|
||||||
21
crates/heph-core/fuzz/fuzz_targets/crdt_write.rs
Normal file
21
crates/heph-core/fuzz/fuzz_targets/crdt_write.rs
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
27
crates/heph-core/fuzz/fuzz_targets/extract.rs
Normal file
27
crates/heph-core/fuzz/fuzz_targets/extract.rs
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
7
crates/heph-core/proptest-regressions/crdt.txt
Normal file
7
crates/heph-core/proptest-regressions/crdt.txt
Normal 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]
|
||||||
|
|
@ -109,7 +109,24 @@ pub(crate) struct BodyMerge {
|
||||||
/// Merge a peer's `delta` update into the CRDT seeded from `prev_state`. The
|
/// 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
|
/// merging doc never authors, so its `client_id` is irrelevant. Commutative and
|
||||||
/// idempotent — applying the same delta twice is a no-op.
|
/// 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 {
|
pub(crate) fn merge_body(prev_state: Option<&[u8]>, delta: &[u8]) -> BodyMerge {
|
||||||
|
let merged = std::panic::catch_unwind(|| {
|
||||||
let doc = load(0, prev_state);
|
let doc = load(0, prev_state);
|
||||||
if let Ok(update) = Update::decode_v1(delta) {
|
if let Ok(update) = Update::decode_v1(delta) {
|
||||||
let mut txn = doc.transact_mut();
|
let mut txn = doc.transact_mut();
|
||||||
|
|
@ -119,14 +136,44 @@ pub(crate) fn merge_body(prev_state: Option<&[u8]>, delta: &[u8]) -> BodyMerge {
|
||||||
state: encode_state(&doc),
|
state: encode_state(&doc),
|
||||||
body: materialize(&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.
|
/// 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 {
|
pub(crate) fn body_of(state: &[u8]) -> String {
|
||||||
materialize(&load(0, Some(state)))
|
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
|
/// Common prefix/suffix diff over byte indices, cut points aligned to UTF-8
|
||||||
/// char boundaries. Returns `(start, delete_len, inserted)` such that replacing
|
/// char boundaries. Returns `(start, delete_len, inserted)` such that replacing
|
||||||
/// `cur[start..start+delete_len]` with `inserted` yields `new`.
|
/// `cur[start..start+delete_len]` with `inserted` yields `new`.
|
||||||
|
|
@ -207,4 +254,62 @@ mod tests {
|
||||||
assert_eq!(edit.body, "café au lait");
|
assert_eq!(edit.body, "café au lait");
|
||||||
assert_eq!(body_of(&edit.state), "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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,50 +47,58 @@ pub fn extract(body: &str) -> Extraction {
|
||||||
let mut code_ranges: Vec<Range<usize>> = Vec::new();
|
let mut code_ranges: Vec<Range<usize>> = Vec::new();
|
||||||
// Depth of nested code blocks; their inner text ranges are code.
|
// Depth of nested code blocks; their inner text ranges are code.
|
||||||
let mut code_depth: u32 = 0;
|
let mut code_depth: u32 = 0;
|
||||||
// The task item currently being collected, if any: (checked, accumulated text).
|
// One frame per open list item: `Some(index into context_items)` once the
|
||||||
let mut current: Option<(bool, String)> = None;
|
// 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() {
|
for (event, range) in Parser::new_ext(body, options).into_offset_iter() {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(Tag::CodeBlock(_)) => code_depth += 1,
|
Event::Start(Tag::CodeBlock(_)) => code_depth += 1,
|
||||||
Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1),
|
Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1),
|
||||||
|
|
||||||
|
Event::Start(Tag::Item) => open_items.push(None),
|
||||||
Event::TaskListMarker(checked) => {
|
Event::TaskListMarker(checked) => {
|
||||||
current = Some((checked, String::new()));
|
|
||||||
}
|
|
||||||
Event::End(TagEnd::Item) => {
|
|
||||||
if let Some((checked, text)) = current.take() {
|
|
||||||
context_items.push(ContextItem {
|
context_items.push(ContextItem {
|
||||||
checked,
|
checked,
|
||||||
text: text.trim().to_string(),
|
text: String::new(),
|
||||||
});
|
});
|
||||||
|
if let Some(frame) = open_items.last_mut() {
|
||||||
|
*frame = Some(context_items.len() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::End(TagEnd::Item) => {
|
||||||
|
open_items.pop();
|
||||||
|
}
|
||||||
|
|
||||||
Event::Text(text) => {
|
Event::Text(text) => {
|
||||||
if code_depth > 0 {
|
if code_depth > 0 {
|
||||||
code_ranges.push(range);
|
code_ranges.push(range);
|
||||||
}
|
}
|
||||||
if let Some((_, label)) = current.as_mut() {
|
append(&mut context_items, &open_items, &text);
|
||||||
label.push_str(&text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Inline code is part of an item's visible label, but its contents
|
// Inline code is part of an item's visible label, but its contents
|
||||||
// are never a wiki-link source.
|
// are never a wiki-link source.
|
||||||
Event::Code(code) => {
|
Event::Code(code) => {
|
||||||
code_ranges.push(range);
|
code_ranges.push(range);
|
||||||
if let Some((_, label)) = current.as_mut() {
|
append(&mut context_items, &open_items, &code);
|
||||||
label.push_str(&code);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Event::SoftBreak | Event::HardBreak => {
|
Event::SoftBreak | Event::HardBreak => {
|
||||||
if let Some((_, label)) = current.as_mut() {
|
append(&mut context_items, &open_items, " ");
|
||||||
label.push(' ');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for item in &mut context_items {
|
||||||
|
item.text = item.text.trim().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
// Scan the raw body for wiki-links (CommonMark mangles `[[ ]]` brackets, so
|
// Scan the raw body for wiki-links (CommonMark mangles `[[ ]]` brackets, so
|
||||||
// we can't rely on Text events), excluding any that start inside code.
|
// 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"
|
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]
|
#[test]
|
||||||
fn extraction_is_idempotent() {
|
fn extraction_is_idempotent() {
|
||||||
let body = "# Mixed\n\n- [ ] do [[X]]\n- [x] done\n\nsee [[Y]]\n";
|
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() {
|
fn body_without_links_or_items_yields_empty() {
|
||||||
assert_eq!(extract("just prose, no structure"), Extraction::default());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,4 +89,35 @@ mod tests {
|
||||||
let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n";
|
let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n";
|
||||||
assert_eq!(strip(body), "body\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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,5 +218,12 @@ mod tests {
|
||||||
let b = Hlc { physical: p2, counter: c2, origin: o2 };
|
let b = Hlc { physical: p2, counter: c2, origin: o2 };
|
||||||
prop_assert_eq!(a.cmp(&b), a.encode().cmp(&b.encode()));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("HEPH_BU
|
||||||
|
|
||||||
pub mod clock;
|
pub mod clock;
|
||||||
mod crdt;
|
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 error;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,36 @@ impl Attention {
|
||||||
other => return Err(Error::Integrity(format!("unknown attention: {other}"))),
|
other => return Err(Error::Integrity(format!("unknown attention: {other}"))),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The UI nomenclature (`a1`..`a4`), ordered by intensity — surfaces show
|
||||||
|
/// these instead of the colour words. The colour *mapping* is unchanged:
|
||||||
|
/// a1 = red, a2 = orange, a3 = white, a4 = blue.
|
||||||
|
pub fn ui_label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Attention::Red => "a1",
|
||||||
|
Attention::Orange => "a2",
|
||||||
|
Attention::White => "a3",
|
||||||
|
Attention::Blue => "a4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a *user-facing* attention input: the `a1`..`a4` label, a bare digit
|
||||||
|
/// `1`..`4`, or a colour word (`red`/`orange`/`white`/`blue`). Surfaces
|
||||||
|
/// accept any of these; the colour mapping matches [`Attention::ui_label`].
|
||||||
|
/// Use this for human input; [`Attention::parse`] is the strict storage form.
|
||||||
|
pub fn parse_input(s: &str) -> Result<Attention> {
|
||||||
|
Ok(match s.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"1" | "a1" | "red" => Attention::Red,
|
||||||
|
"2" | "a2" | "orange" => Attention::Orange,
|
||||||
|
"3" | "a3" | "white" => Attention::White,
|
||||||
|
"4" | "a4" | "blue" => Attention::Blue,
|
||||||
|
other => {
|
||||||
|
return Err(Error::Integrity(format!(
|
||||||
|
"unknown attention: {other} (use a1-a4, 1-4, or red/orange/white/blue)"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped`
|
/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped`
|
||||||
|
|
@ -398,3 +428,29 @@ impl NewNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_input_accepts_labels_digits_and_colours() {
|
||||||
|
for (inputs, want) in [
|
||||||
|
(["a1", "1", "red"], Attention::Red),
|
||||||
|
(["a2", "2", "orange"], Attention::Orange),
|
||||||
|
(["a3", "3", "white"], Attention::White),
|
||||||
|
(["a4", "4", "blue"], Attention::Blue),
|
||||||
|
] {
|
||||||
|
for s in inputs {
|
||||||
|
assert_eq!(Attention::parse_input(s).unwrap(), want, "input {s:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case-insensitive and whitespace-tolerant.
|
||||||
|
assert_eq!(Attention::parse_input(" A1 ").unwrap(), Attention::Red);
|
||||||
|
assert_eq!(Attention::parse_input("RED").unwrap(), Attention::Red);
|
||||||
|
// The a-label maps to its colour, and round-trips back to the label.
|
||||||
|
assert_eq!(Attention::Red.ui_label(), "a1");
|
||||||
|
assert!(Attention::parse_input("p1").is_err());
|
||||||
|
assert!(Attention::parse_input("5").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ pub mod op_type {
|
||||||
pub const NODE_SET: &str = "node.set";
|
pub const NODE_SET: &str = "node.set";
|
||||||
/// A node was tombstoned. Payload: `{}`.
|
/// A node was tombstoned. Payload: `{}`.
|
||||||
pub const NODE_TOMBSTONE: &str = "node.tombstone";
|
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.
|
/// A task row was created. Payload: the task scalars.
|
||||||
pub const TASK_CREATE: &str = "task.create";
|
pub const TASK_CREATE: &str = "task.create";
|
||||||
/// One or more task scalars were set (LWW). Payload: the changed scalars.
|
/// One or more task scalars were set (LWW). Payload: the changed scalars.
|
||||||
|
|
|
||||||
|
|
@ -181,5 +181,31 @@ mod tests {
|
||||||
let once = reset_checkboxes(&body);
|
let once = reset_checkboxes(&body);
|
||||||
prop_assert_eq!(reset_checkboxes(&once), once);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@
|
||||||
//! scalar value from a different device is recorded in `conflicts` (surfaced,
|
//! scalar value from a different device is recorded in `conflicts` (surfaced,
|
||||||
//! not silently dropped).
|
//! not silently dropped).
|
||||||
//! - **links:** OR-set add/remove keyed by the link's own id → no conflicts.
|
//! - **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
|
//! 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.
|
//! 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 super::{absorb_remote_hlc, new_id, nodes, ops};
|
||||||
use crate::crdt;
|
use crate::crdt;
|
||||||
use crate::error::Result;
|
use crate::error::{Error, Result};
|
||||||
use crate::hlc::Hlc;
|
use crate::hlc::Hlc;
|
||||||
use crate::model::Conflict;
|
use crate::model::{Conflict, TaskState};
|
||||||
use crate::oplog::{op_type, Op};
|
use crate::oplog::{op_type, Op};
|
||||||
|
|
||||||
/// Open conflicts for `owner`, newest first.
|
/// 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<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settle a conflict. v1 records the user's choice by marking it resolved; the
|
/// Settle a conflict by the user's choice (`"local"`/`"remote"`): the chosen
|
||||||
/// LWW winner is already materialized (choosing the loser's value is a
|
/// value is **applied** to the task and recorded as a new `task.set` op (so
|
||||||
/// follow-up — see [[design]]).
|
/// peers converge on the decision), then the row is marked resolved. The LWW
|
||||||
pub(super) fn resolve_conflict(conn: &Connection, id: &str, _choice: &str) -> Result<()> {
|
/// winner may have been either side, so the chosen value is written
|
||||||
conn.execute(
|
/// 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",
|
"UPDATE conflicts SET status = 'resolved' WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,8 +138,8 @@ pub(super) fn apply(conn: &mut Connection, op: &Op) -> Result<bool> {
|
||||||
node_upsert(&tx, op)?;
|
node_upsert(&tx, op)?;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
op_type::NODE_TOMBSTONE => {
|
op_type::NODE_TOMBSTONE | op_type::NODE_RESTORE => {
|
||||||
node_tombstone(&tx, op)?;
|
node_tombstone_state(&tx, op)?;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
op_type::TASK_CREATE | op_type::TASK_SET => {
|
op_type::TASK_CREATE | op_type::TASK_SET => {
|
||||||
|
|
@ -204,19 +264,44 @@ fn node_upsert(tx: &Connection, op: &Op) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node_tombstone(tx: &Connection, op: &Op) -> Result<()> {
|
/// Merge a `node.tombstone` or `node.restore` op. The two are an LWW pair
|
||||||
// Monotonic: tombstone always wins. Bump hlc only if this op is newer.
|
/// keyed by their **own** op HLCs (not the node's `hlc`, which unrelated
|
||||||
if let Some(existing) = nodes::get(tx, &op.target_id)? {
|
/// 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() {
|
let hlc = if op.hlc.as_str() > existing.hlc.as_str() {
|
||||||
op.hlc.clone()
|
op.hlc.clone()
|
||||||
} else {
|
} else {
|
||||||
existing.hlc.clone()
|
existing.hlc
|
||||||
};
|
};
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3",
|
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
|
||||||
(op_physical(op), hlc, &op.target_id),
|
(tombstoned as i64, op_physical(op), hlc, &op.target_id),
|
||||||
)?;
|
)?;
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,20 @@ pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result<usize
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let Some(node) = nodes::get(conn, &id)? else {
|
let Some(mut node) = nodes::get(conn, &id)? else {
|
||||||
continue;
|
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 {
|
let task = if node.kind == NodeKind::Task {
|
||||||
tasks::get(conn, &id)?
|
tasks::get(conn, &id)?
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,17 @@ pub(super) fn outgoing(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
||||||
query(conn, "src_id", id)
|
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`.
|
/// All non-tombstoned links pointing at `id`.
|
||||||
pub(super) fn backlinks(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
pub(super) fn backlinks(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
||||||
query(conn, "dst_id", id)
|
query(conn, "dst_id", id)
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,28 @@ impl Store for LocalStore {
|
||||||
|
|
||||||
fn tombstone_node(&mut self, id: &str) -> Result<()> {
|
fn tombstone_node(&mut self, id: &str) -> Result<()> {
|
||||||
let now = self.clock.now_ms();
|
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>> {
|
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)
|
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(
|
fn promote(
|
||||||
&mut self,
|
&mut self,
|
||||||
container_id: &str,
|
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> {
|
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();
|
let now = self.clock.now_ms();
|
||||||
links::add(&self.conn, &self.owner_id, now, src_id, dst_id, link_type)
|
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<()> {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,17 +431,32 @@ pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
|
||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge
|
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3); a
|
||||||
/// monotonic (tech-spec §4.3).
|
/// 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<()> {
|
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 hlc = next_hlc(conn, now)?;
|
||||||
let updated = conn.execute(
|
let updated = conn.execute(
|
||||||
"UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3",
|
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
|
||||||
(now, &hlc, id),
|
(dead as i64, now, &hlc, id),
|
||||||
)?;
|
)?;
|
||||||
if updated == 0 {
|
if updated == 0 {
|
||||||
return Err(Error::NodeNotFound(id.to_string()));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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).
|
/// 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 task = require(conn, node_id)?;
|
||||||
let hlc = next_hlc(conn, now)?;
|
let hlc = next_hlc(conn, now)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -662,6 +662,51 @@ pub(super) fn delete_project(
|
||||||
Ok(())
|
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
|
/// Apply a partial schedule update (do-date / late-on / recurrence) — the
|
||||||
/// "reschedule" path (tech-spec §6). Reads the current row, overlays the
|
/// "reschedule" path (tech-spec §6). Reads the current row, overlays the
|
||||||
/// present `patch` fields (a double-option per field: absent = leave, `null` =
|
/// present `patch` fields (a double-option per field: absent = leave, `null` =
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,15 @@ pub trait Store {
|
||||||
body: Option<String>,
|
body: Option<String>,
|
||||||
) -> Result<Node>;
|
) -> 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<()>;
|
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`,
|
/// List non-tombstoned nodes (owner-scoped), optionally filtered by `kind`,
|
||||||
/// ordered by title. The enumeration surfaces (projects, tags) build on this
|
/// ordered by title. The enumeration surfaces (projects, tags) build on this
|
||||||
/// (tech-spec §6 `node.list`).
|
/// (tech-spec §6 `node.list`).
|
||||||
|
|
@ -98,6 +104,12 @@ pub trait Store {
|
||||||
/// the project node. Tasks are preserved, never deleted.
|
/// the project node. Tasks are preserved, never deleted.
|
||||||
fn delete_project(&mut self, project_id: &str) -> Result<()>;
|
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
|
/// Promote a `- [ ]` context-item line in `container_id`'s body into a
|
||||||
/// committed task, rewriting that source line into a `[[link]]` to the new
|
/// 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
|
/// 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`).
|
/// Open merge conflicts surfaced for the user (`heph conflicts`).
|
||||||
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
|
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<()>;
|
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,4 +148,41 @@ mod tests {
|
||||||
assert_eq!(expand("dangling [[01ID", &t), "dangling [[01ID");
|
assert_eq!(expand("dangling [[01ID", &t), "dangling [[01ID");
|
||||||
assert_eq!(expand("", &t), "");
|
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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -267,3 +267,134 @@ fn tombstones_propagate_and_are_monotonic() {
|
||||||
// Tombstoned nodes drop out of search/next on B.
|
// Tombstoned nodes drop out of search/next on B.
|
||||||
assert!(b.search("doomed").unwrap().is_empty());
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", keep.id)).exists());
|
||||||
assert!(!dir.path().join(format!("doc/{}.md", gone.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]]"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -414,3 +414,111 @@ fn active_wiki(s: &LocalStore, id: &str) -> usize {
|
||||||
.filter(|l| l.link_type == LinkType::Wiki)
|
.filter(|l| l.link_type == LinkType::Wiki)
|
||||||
.count()
|
.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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,16 @@ global-hotkey = "0.8"
|
||||||
|
|
||||||
# macOS-only: winit for the accessory-mode activation policy (no Dock icon),
|
# macOS-only: winit for the accessory-mode activation policy (no Dock icon),
|
||||||
# pinned to the same minor eframe carries so cargo unifies to one winit; libc
|
# pinned to the same minor eframe carries so cargo unifies to one winit; libc
|
||||||
# for getppid() (orphan detection — self-exit when the supervising daemon dies).
|
# for getppid() (orphan detection — self-exit when the supervising daemon dies);
|
||||||
|
# objc2 + objc2-app-kit to hand keyboard focus back to the previously active app
|
||||||
|
# when the popover hides (NSApplication.hide:/unhide:). Pinned to the 0.6/0.3
|
||||||
|
# line global-hotkey already pulls in, so cargo unifies to one copy.
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3", default-features = false, features = [
|
||||||
|
"std",
|
||||||
|
"NSApplication",
|
||||||
|
"NSResponder",
|
||||||
|
] }
|
||||||
|
|
|
||||||
|
|
@ -43,46 +43,46 @@ const HINT_DELAY: f64 = 2.0;
|
||||||
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
|
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
|
||||||
/// though they reference projects a given store may not have.
|
/// though they reference projects a given store may not have.
|
||||||
const HINTS: &[&str] = &[
|
const HINTS: &[&str] = &[
|
||||||
"Water plants tomorrow p2 #Chores every 3 days",
|
"Water plants tomorrow a2 #Chores every 3 days",
|
||||||
"Call the dentist fri p1",
|
"Call the dentist fri a1",
|
||||||
"Email Sarah the report today",
|
"Email Sarah the report today",
|
||||||
"Buy milk #Errands",
|
"Buy milk #Errands",
|
||||||
"Renew passport +30d p2",
|
"Renew passport +30d a2",
|
||||||
"Review pull requests p3 #Work",
|
"Review pull requests a4 #Work",
|
||||||
"Take out recycling every other wed",
|
"Take out recycling every other wed",
|
||||||
"Pay rent every 1st p1",
|
"Pay rent every 1st a1",
|
||||||
"Stretch every day",
|
"Stretch every day",
|
||||||
"Submit timesheet every friday #Work",
|
"Submit timesheet every friday #Work",
|
||||||
"Water the garden every 2 days",
|
"Water the garden every 2 days",
|
||||||
"Back up the laptop every week p3",
|
"Back up the laptop every week a4",
|
||||||
"Book flights +1w p2 #Travel",
|
"Book flights +1w a2 #Travel",
|
||||||
"Doctor appointment 2026-07-15 p1",
|
"Doctor appointment 2026-07-15 a1",
|
||||||
"Read a chapter today #Reading",
|
"Read a chapter today #Reading",
|
||||||
"Standup notes every weekday #Work",
|
"Standup notes every weekday #Work",
|
||||||
"Change the air filter every 3 months",
|
"Change the air filter every 3 months",
|
||||||
"File taxes every April 15 p1",
|
"File taxes every April 15 a1",
|
||||||
"Clean the gutters every 6 months #Home",
|
"Clean the gutters every 6 months #Home",
|
||||||
"Wish Mom happy birthday every May 4 p1",
|
"Wish Mom happy birthday every May 4 a1",
|
||||||
"Vacuum the house every saturday #Chores",
|
"Vacuum the house every saturday #Chores",
|
||||||
"Replace toothbrush every 3 months",
|
"Replace toothbrush every 3 months",
|
||||||
"Prep slides for monday p2 #Work",
|
"Prep slides for monday a2 #Work",
|
||||||
"Walk the dog every day",
|
"Walk the dog every day",
|
||||||
"Refill prescription every 30 days p2 #Health",
|
"Refill prescription every 30 days a2 #Health",
|
||||||
"Grocery run +2d #Errands",
|
"Grocery run +2d #Errands",
|
||||||
"Mow the lawn every week #Home",
|
"Mow the lawn every week #Home",
|
||||||
"Schedule a 1:1 with Alex thu p3 #Work",
|
"Schedule a 1:1 with Alex thu a4 #Work",
|
||||||
"Send the invoice every 15th p2",
|
"Send the invoice every 15th a2",
|
||||||
"Defrost the freezer every 6 months",
|
"Defrost the freezer every 6 months",
|
||||||
"Update the resume +14d p3",
|
"Update the resume +14d a4",
|
||||||
"Check smoke detectors every 6 months #Home",
|
"Check smoke detectors every 6 months #Home",
|
||||||
"Plan the sprint every other monday #Work",
|
"Plan the sprint every other monday #Work",
|
||||||
"Order coffee beans every 2 weeks",
|
"Order coffee beans every 2 weeks",
|
||||||
"Call grandma every sunday p2",
|
"Call grandma every sunday a2",
|
||||||
"Rotate the car tires every 6 months #Car",
|
"Rotate the car tires every 6 months #Car",
|
||||||
"Weekly review every friday p2",
|
"Weekly review every friday a2",
|
||||||
"Pick up dry cleaning tomorrow #Errands",
|
"Pick up dry cleaning tomorrow #Errands",
|
||||||
"Pay the credit card every 28th p1",
|
"Pay the credit card every 28th a1",
|
||||||
"Tidy the inbox every day p4",
|
"Tidy the inbox every day a3",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand`
|
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand`
|
||||||
|
|
@ -226,6 +226,9 @@ impl QuickAdd {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show(&mut self, ctx: &egui::Context) {
|
fn show(&mut self, ctx: &egui::Context) {
|
||||||
|
// Undo the app-level hide from the previous `hide()` so we can take focus
|
||||||
|
// again (no-op the first time / off macOS).
|
||||||
|
app_take_focus();
|
||||||
self.visible = true;
|
self.visible = true;
|
||||||
self.focus_pending = true;
|
self.focus_pending = true;
|
||||||
self.current_hint = random_hint(self.current_hint);
|
self.current_hint = random_hint(self.current_hint);
|
||||||
|
|
@ -256,6 +259,13 @@ impl QuickAdd {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H)));
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H)));
|
||||||
self.win_h_applied = BASE_H;
|
self.win_h_applied = BASE_H;
|
||||||
}
|
}
|
||||||
|
// Hand keyboard focus back to the app underneath us. winit's
|
||||||
|
// `Visible(false)` alone leaves *us* the active application, so focus
|
||||||
|
// never returns and the borderless always-on-top overlay can keep eating
|
||||||
|
// clicks where it used to sit. `NSApplication.hide:` orders our windows
|
||||||
|
// fully out and activates the next app in line — exactly the one the user
|
||||||
|
// was in (no-op off macOS).
|
||||||
|
app_yield_focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optimistic submit: hide now, create in the background.
|
/// Optimistic submit: hide now, create in the background.
|
||||||
|
|
@ -540,18 +550,14 @@ impl QuickAdd {
|
||||||
let mut any = false;
|
let mut any = false;
|
||||||
|
|
||||||
if let Some(att) = parsed.attention {
|
if let Some(att) = parsed.attention {
|
||||||
let (label, color) = match att {
|
// a1–a4 nomenclature; the colour mapping is unchanged.
|
||||||
heph_core::Attention::Red => {
|
let color = match att {
|
||||||
("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60))
|
heph_core::Attention::Red => egui::Color32::from_rgb(0xe0, 0x6c, 0x60),
|
||||||
}
|
heph_core::Attention::Orange => egui::Color32::from_rgb(0xe5, 0xc0, 0x7b),
|
||||||
heph_core::Attention::Orange => {
|
heph_core::Attention::Blue => egui::Color32::from_rgb(0x61, 0xaf, 0xef),
|
||||||
("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b))
|
heph_core::Attention::White => egui::Color32::from_gray(200),
|
||||||
}
|
|
||||||
heph_core::Attention::Blue => {
|
|
||||||
("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef))
|
|
||||||
}
|
|
||||||
heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)),
|
|
||||||
};
|
};
|
||||||
|
let label = format!("⚑ {}", att.ui_label());
|
||||||
ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
|
ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
|
||||||
any = true;
|
any = true;
|
||||||
}
|
}
|
||||||
|
|
@ -587,7 +593,7 @@ impl QuickAdd {
|
||||||
|
|
||||||
if !any {
|
if !any {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("type p1–p4 · #project · a date · every …")
|
egui::RichText::new("type a1–a4 · #project · a date · every …")
|
||||||
.color(egui::Color32::from_gray(140))
|
.color(egui::Color32::from_gray(140))
|
||||||
.size(LABEL_SIZE),
|
.size(LABEL_SIZE),
|
||||||
);
|
);
|
||||||
|
|
@ -596,6 +602,39 @@ impl QuickAdd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hide the popover at the *application* level so macOS hands keyboard focus
|
||||||
|
/// back to the previously active app. `NSApplication.hide:` orders all our
|
||||||
|
/// windows out and activates the next app in line — the one the user was in —
|
||||||
|
/// which a plain winit `Visible(false)` does not do. No-op off macOS.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn app_yield_focus() {
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::NSApplication;
|
||||||
|
// eframe's `update` runs on the main thread, so this marker is always Some.
|
||||||
|
if let Some(mtm) = MainThreadMarker::new() {
|
||||||
|
NSApplication::sharedApplication(mtm).hide(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn app_yield_focus() {}
|
||||||
|
|
||||||
|
/// Undo [`app_yield_focus`]: clear the app-level hidden flag before re-showing,
|
||||||
|
/// so the window the viewport `Focus` command then makes key actually appears.
|
||||||
|
/// (`unhide:` also re-activates us; the per-window `Focus`/`Visible` viewport
|
||||||
|
/// commands do the rest.) No-op off macOS.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn app_take_focus() {
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::NSApplication;
|
||||||
|
if let Some(mtm) = MainThreadMarker::new() {
|
||||||
|
NSApplication::sharedApplication(mtm).unhide(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn app_take_focus() {}
|
||||||
|
|
||||||
/// The current parent process id, for orphan detection. `None` off macOS (where
|
/// The current parent process id, for orphan detection. `None` off macOS (where
|
||||||
/// hephd does not supervise a helper — there is no Aqua session to inherit).
|
/// hephd does not supervise a helper — there is no Aqua session to inherit).
|
||||||
fn current_parent_pid() -> Option<i32> {
|
fn current_parent_pid() -> Option<i32> {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `heph-quickadd` — the global quick-capture popover (tech-spec §8).
|
//! `heph-quickadd` — the global quick-capture popover (tech-spec §8).
|
||||||
//!
|
//!
|
||||||
//! A tiny always-warm egui agent: ⌘' shows a single-line capture field that
|
//! A tiny always-warm egui agent: ⌘' shows a single-line capture field that
|
||||||
//! parses Todoist-style inline syntax (`p2 #Chores tomorrow every 3 days`) and
|
//! parses Todoist-style inline syntax (`a2 #Chores tomorrow every 3 days`) and
|
||||||
//! creates a task over the `hephd` unix socket. It is **supervised by hephd**
|
//! creates a task over the `hephd` unix socket. It is **supervised by hephd**
|
||||||
//! (spawned in local mode on macOS), so the user installs/manages exactly one
|
//! (spawned in local mode on macOS), so the user installs/manages exactly one
|
||||||
//! service — there is no separate launch agent.
|
//! service — there is no separate launch agent.
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,12 @@ enum InputKind {
|
||||||
},
|
},
|
||||||
/// Full-text search query.
|
/// Full-text search query.
|
||||||
Search,
|
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.
|
/// An active full-text search: the query, its hits, and the highlighted row.
|
||||||
|
|
@ -118,6 +124,31 @@ pub struct SearchView {
|
||||||
pub cursor: usize,
|
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) —
|
/// A pending delete awaiting y/N confirmation (the most destructive gesture) —
|
||||||
/// either a task (from the task pane) or a project (from the sidebar).
|
/// either a task (from the task pane) or a project (from the sidebar).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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
|
/// 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
|
/// re-apply it without re-reading state.
|
||||||
/// no restore path yet, so it is excluded from undo and guarded by its y/N prompt.)
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum TriageAction {
|
enum TriageAction {
|
||||||
State(&'static str), // "done" | "dropped"
|
State(&'static str), // "done" | "dropped"
|
||||||
|
|
@ -183,22 +213,53 @@ enum TriageAction {
|
||||||
Move(Option<String>), // re-file (or unfile) to a project id
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct UndoEntry {
|
enum UndoEntry {
|
||||||
|
/// A scalar triage action: the task state before it + the action.
|
||||||
|
Triage {
|
||||||
before: TaskSnapshot,
|
before: TaskSnapshot,
|
||||||
action: TriageAction,
|
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.
|
/// Cap on the undo history, so a long session can't grow it unbounded.
|
||||||
const UNDO_CAP: usize = 200;
|
const UNDO_CAP: usize = 200;
|
||||||
|
|
||||||
/// One choice in the move-to-project picker.
|
/// One choice in the move picker.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum MoveOption {
|
pub enum MoveOption {
|
||||||
/// Remove the task from any project.
|
/// Remove the task from any project.
|
||||||
Unfile,
|
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 },
|
Project { id: String, title: String },
|
||||||
/// Create a new project named after the filter text, then file under it.
|
/// Create a new project named after the filter text, then file under it.
|
||||||
Create { name: String },
|
Create { name: String },
|
||||||
|
|
@ -209,40 +270,59 @@ impl MoveOption {
|
||||||
pub fn label(&self) -> String {
|
pub fn label(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
MoveOption::Unfile => "(Unfile)".to_string(),
|
MoveOption::Unfile => "(Unfile)".to_string(),
|
||||||
|
MoveOption::Root => "(Move to root)".to_string(),
|
||||||
MoveOption::Project { title, .. } => title.clone(),
|
MoveOption::Project { title, .. } => title.clone(),
|
||||||
MoveOption::Create { name } => format!("+ New project \"{name}\""),
|
MoveOption::Create { name } => format!("+ New project \"{name}\""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The move-to-project picker state: which task is being re-filed, a live filter
|
/// What the move picker is moving — a task being re-filed, or a project being
|
||||||
/// over the projects, the matching choices, and the highlighted row. The picker
|
/// re-parented. Each carries what its undo needs.
|
||||||
/// is fzf-style — typing narrows the list; a non-matching name offers to create.
|
#[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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct MoveState {
|
pub struct MoveState {
|
||||||
pub task_id: String,
|
/// The node being moved (a task id in task mode, a project id otherwise).
|
||||||
pub task_title: String,
|
pub subject_id: String,
|
||||||
/// The task's state before the move, for undo.
|
pub subject_title: String,
|
||||||
before: TaskSnapshot,
|
pub kind: MoveKind,
|
||||||
/// All projects, title-sorted — the source the `filter` narrows.
|
/// 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>,
|
projects: Vec<Project>,
|
||||||
/// The live filter query (fzf-style subsequence match).
|
/// The live filter query (fzf-style subsequence match).
|
||||||
pub filter: String,
|
pub filter: String,
|
||||||
/// The currently visible choices (`(Unfile)` + matching projects + an
|
/// The currently visible choices, recomputed whenever `filter` changes.
|
||||||
/// optional "create" row), recomputed whenever `filter` changes.
|
|
||||||
pub options: Vec<MoveOption>,
|
pub options: Vec<MoveOption>,
|
||||||
pub cursor: usize,
|
pub cursor: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MoveState {
|
impl MoveState {
|
||||||
/// Rebuild `options` from `projects` + `filter`: `(Unfile)` (when it matches
|
/// Rebuild `options` from `projects` + `filter`: the no-project row
|
||||||
/// or the filter is empty), the fuzzy-matching projects, and — when the text
|
/// (`(Unfile)` / `(Move to root)`), the fuzzy-matching projects, and — in
|
||||||
/// names no existing project — a "create" row. Clamps the cursor.
|
/// task mode, when the text names no existing project — a "create" row.
|
||||||
|
/// Clamps the cursor.
|
||||||
fn recompute(&mut self) {
|
fn recompute(&mut self) {
|
||||||
let f = self.filter.trim();
|
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();
|
let mut opts = Vec::new();
|
||||||
if f.is_empty() || fuzzy_match(f, "Unfile") {
|
if f.is_empty() || fuzzy_match(f, none_label) {
|
||||||
opts.push(MoveOption::Unfile);
|
opts.push(none_opt);
|
||||||
}
|
}
|
||||||
for p in &self.projects {
|
for p in &self.projects {
|
||||||
if f.is_empty() || fuzzy_match(f, &p.title) {
|
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
|
&& !self
|
||||||
.projects
|
.projects
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -289,15 +370,16 @@ fn fuzzy_match(query: &str, cand: &str) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
|
/// Map an attention-chord digit (`1`..`4`) to its band, ordered by intensity:
|
||||||
/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
|
/// 1 = a1 (red), 2 = a2 (orange), 3 = a3 (white), 4 = a4 (blue). Any other
|
||||||
pub fn next_attention(current: Option<Attention>) -> Attention {
|
/// character is not an attention key.
|
||||||
match current {
|
pub fn attention_for_digit(c: char) -> Option<Attention> {
|
||||||
Some(Attention::White) => Attention::Orange,
|
match c {
|
||||||
Some(Attention::Orange) => Attention::Red,
|
'1' => Some(Attention::Red),
|
||||||
Some(Attention::Red) => Attention::Blue,
|
'2' => Some(Attention::Orange),
|
||||||
Some(Attention::Blue) => Attention::White,
|
'3' => Some(Attention::White),
|
||||||
None => Attention::White,
|
'4' => Some(Attention::Blue),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,8 +509,15 @@ pub struct App<B: Backend> {
|
||||||
pub sort_mode: SortMode,
|
pub sort_mode: SortMode,
|
||||||
/// When `Some`, a full-text search overlays the task list.
|
/// When `Some`, a full-text search overlays the task list.
|
||||||
pub search: Option<SearchView>,
|
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.
|
/// When `Some`, a delete is awaiting y/N confirmation.
|
||||||
pub pending_delete: Option<PendingDelete>,
|
pub pending_delete: Option<PendingDelete>,
|
||||||
|
/// When `true`, an attention chord is in progress: `a` was pressed and the
|
||||||
|
/// next `1`..`4` sets the highlighted task's band (any other key cancels).
|
||||||
|
pub pending_attention: bool,
|
||||||
/// Reversible triage history (`u` undoes, Ctrl-z redoes).
|
/// Reversible triage history (`u` undoes, Ctrl-z redoes).
|
||||||
undo_stack: Vec<UndoEntry>,
|
undo_stack: Vec<UndoEntry>,
|
||||||
redo_stack: Vec<UndoEntry>,
|
redo_stack: Vec<UndoEntry>,
|
||||||
|
|
@ -470,7 +559,10 @@ impl<B: Backend> App<B> {
|
||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
sort_mode: SortMode::Default,
|
sort_mode: SortMode::Default,
|
||||||
search: None,
|
search: None,
|
||||||
|
conflicts_view: None,
|
||||||
|
log_view: None,
|
||||||
pending_delete: None,
|
pending_delete: None,
|
||||||
|
pending_attention: false,
|
||||||
undo_stack: Vec::new(),
|
undo_stack: Vec::new(),
|
||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
|
@ -722,47 +814,124 @@ impl<B: Backend> App<B> {
|
||||||
self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id));
|
self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue).
|
/// Begin an attention chord: arm `pending_attention` so the next `1`..`4`
|
||||||
pub fn cycle_attention_selected(&mut self) {
|
/// sets the highlighted task's band directly (§6.2). No-op (with a hint) if
|
||||||
let Some(t) = self.selected_task().cloned() else {
|
/// nothing is highlighted. The chord replaces the old `A` cycle / `b` blue
|
||||||
|
/// gestures — picking a band directly never makes the task vanish out of
|
||||||
|
/// reach the way cycling past blue did.
|
||||||
|
pub fn begin_attention(&mut self) {
|
||||||
|
if self.selected_task().is_none() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let next = next_attention(t.attention);
|
self.pending_attention = true;
|
||||||
self.push_undo((&t).into(), TriageAction::Attention(next));
|
self.status = "attention: 1=a1 2=a2 3=a3 4=a4 (esc cancels)".into();
|
||||||
self.mutate(format!("{}: {}", next.as_str(), t.title), |b| {
|
|
||||||
b.set_attention(&t.node_id, next)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the highlighted task to On Deck (blue) — the pressure-relief valve.
|
/// Resolve an armed attention chord with the pressed key. `1`..`4` set the
|
||||||
pub fn push_to_blue_selected(&mut self) {
|
/// band; anything else cancels. Returns whether the key was consumed.
|
||||||
|
pub fn resolve_attention(&mut self, c: char) {
|
||||||
|
self.pending_attention = false;
|
||||||
|
let Some(att) = attention_for_digit(c) else {
|
||||||
|
self.status = "attention: cancelled".into();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.set_attention_selected(att);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel an armed attention chord (e.g. on Esc / focus change).
|
||||||
|
pub fn cancel_attention(&mut self) {
|
||||||
|
if self.pending_attention {
|
||||||
|
self.pending_attention = false;
|
||||||
|
self.status = "attention: cancelled".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the highlighted task's attention band directly (the `a`+digit chord).
|
||||||
|
pub fn set_attention_selected(&mut self, att: Attention) {
|
||||||
let Some(t) = self.selected_task().cloned() else {
|
let Some(t) = self.selected_task().cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue));
|
self.push_undo((&t).into(), TriageAction::Attention(att));
|
||||||
self.mutate(format!("→ on deck: {}", t.title), |b| {
|
self.mutate(format!("{}: {}", att.ui_label(), t.title), |b| {
|
||||||
b.set_attention(&t.node_id, Attention::Blue)
|
b.set_attention(&t.node_id, att)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- undo / redo (`u` / Ctrl-z) ---
|
// --- 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) {
|
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 {
|
if self.undo_stack.len() > UNDO_CAP {
|
||||||
self.undo_stack.remove(0);
|
self.undo_stack.remove(0);
|
||||||
}
|
}
|
||||||
self.redo_stack.clear();
|
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) {
|
pub fn undo(&mut self) {
|
||||||
let Some(entry) = self.undo_stack.pop() else {
|
let Some(entry) = self.undo_stack.pop() else {
|
||||||
self.status = "nothing to undo".into();
|
self.status = "nothing to undo".into();
|
||||||
return;
|
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);
|
self.redo_stack.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -772,8 +941,52 @@ impl<B: Backend> App<B> {
|
||||||
self.status = "nothing to redo".into();
|
self.status = "nothing to redo".into();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let status = format!("redo: {}", entry.before.title);
|
match &entry {
|
||||||
self.apply_action(entry.before.task_id.clone(), entry.action.clone(), status);
|
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);
|
self.undo_stack.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -837,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) {
|
pub fn confirm_delete(&mut self) {
|
||||||
match self.pending_delete.take() {
|
match self.pending_delete.take() {
|
||||||
Some(PendingDelete::Task { task_id, title }) => {
|
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 }) => {
|
Some(PendingDelete::Project { project_id, title }) => {
|
||||||
self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| {
|
// Snapshot which tasks the delete is about to unfile, so undo
|
||||||
b.delete_project(&project_id)
|
// 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.rebuild_projects();
|
||||||
self.reload();
|
self.reload();
|
||||||
}
|
}
|
||||||
|
|
@ -870,9 +1106,11 @@ impl<B: Backend> App<B> {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut state = MoveState {
|
let mut state = MoveState {
|
||||||
task_id: t.node_id.clone(),
|
subject_id: t.node_id.clone(),
|
||||||
task_title: t.title.clone(),
|
subject_title: t.title.clone(),
|
||||||
|
kind: MoveKind::Task {
|
||||||
before: TaskSnapshot::from(&t),
|
before: TaskSnapshot::from(&t),
|
||||||
|
},
|
||||||
projects: self.project_list(),
|
projects: self.project_list(),
|
||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
options: Vec::new(),
|
options: Vec::new(),
|
||||||
|
|
@ -891,6 +1129,64 @@ impl<B: Backend> App<B> {
|
||||||
self.mode = Mode::MoveToProject(state);
|
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).
|
/// Move the picker cursor by `delta` (clamped).
|
||||||
pub fn move_picker_move(&mut self, delta: isize) {
|
pub fn move_picker_move(&mut self, delta: isize) {
|
||||||
if let Mode::MoveToProject(m) = &mut self.mode {
|
if let Mode::MoveToProject(m) = &mut self.mode {
|
||||||
|
|
@ -917,8 +1213,8 @@ impl<B: Backend> App<B> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the highlighted choice: unfile, re-file, or create-then-file, and
|
/// Apply the highlighted choice and reload. Task mode: unfile, re-file, or
|
||||||
/// reload.
|
/// create-then-file. Project mode: re-parent (or detach to the root).
|
||||||
pub fn move_picker_submit(&mut self) {
|
pub fn move_picker_submit(&mut self) {
|
||||||
let Mode::MoveToProject(m) = &self.mode else {
|
let Mode::MoveToProject(m) = &self.mode else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -926,21 +1222,22 @@ impl<B: Backend> App<B> {
|
||||||
let Some(choice) = m.options.get(m.cursor).cloned() else {
|
let Some(choice) = m.options.get(m.cursor).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let task_id = m.task_id.clone();
|
let subject_id = m.subject_id.clone();
|
||||||
let title = m.task_title.clone();
|
let title = m.subject_title.clone();
|
||||||
let before = m.before.clone();
|
let kind = m.kind.clone();
|
||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
match choice {
|
match kind {
|
||||||
|
MoveKind::Task { before } => match choice {
|
||||||
MoveOption::Unfile => {
|
MoveOption::Unfile => {
|
||||||
self.push_undo(before, TriageAction::Move(None));
|
self.push_undo(before, TriageAction::Move(None));
|
||||||
self.mutate(format!("→ (Unfile): {title}"), |b| {
|
self.mutate(format!("→ (Unfile): {title}"), |b| {
|
||||||
b.set_project(&task_id, None)
|
b.set_project(&subject_id, None)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
MoveOption::Project { id, title: pt } => {
|
MoveOption::Project { id, title: pt } => {
|
||||||
self.push_undo(before, TriageAction::Move(Some(id.clone())));
|
self.push_undo(before, TriageAction::Move(Some(id.clone())));
|
||||||
self.mutate(format!("→ {pt}: {title}"), move |b| {
|
self.mutate(format!("→ {pt}: {title}"), move |b| {
|
||||||
b.set_project(&task_id, Some(&id))
|
b.set_project(&subject_id, Some(&id))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
MoveOption::Create { name } => {
|
MoveOption::Create { name } => {
|
||||||
|
|
@ -948,10 +1245,32 @@ impl<B: Backend> App<B> {
|
||||||
// (we'd have to track the new project's id to replay it).
|
// (we'd have to track the new project's id to replay it).
|
||||||
self.mutate(format!("→ new project \"{name}\": {title}"), move |b| {
|
self.mutate(format!("→ new project \"{name}\": {title}"), move |b| {
|
||||||
let id = b.create_project(&name)?;
|
let id = b.create_project(&name)?;
|
||||||
b.set_project(&task_id, Some(&id))
|
b.set_project(&subject_id, Some(&id))
|
||||||
});
|
});
|
||||||
self.rebuild_projects();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1039,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.
|
/// Open the full-text search prompt.
|
||||||
pub fn begin_search(&mut self) {
|
pub fn begin_search(&mut self) {
|
||||||
self.mode = Mode::Input(InputState {
|
self.mode = Mode::Input(InputState {
|
||||||
|
|
@ -1053,6 +1389,114 @@ impl<B: Backend> App<B> {
|
||||||
self.search = None;
|
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).
|
/// Move the search-results cursor by `delta` (clamped).
|
||||||
pub fn search_move(&mut self, delta: isize) {
|
pub fn search_move(&mut self, delta: isize) {
|
||||||
if let Some(s) = &mut self.search {
|
if let Some(s) = &mut self.search {
|
||||||
|
|
@ -1162,6 +1606,32 @@ impl<B: Backend> App<B> {
|
||||||
Err(e) => self.status = format!("error: {e}"),
|
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 } => {
|
InputKind::Reschedule { task_id } => {
|
||||||
let patch = if buf.is_empty() {
|
let patch = if buf.is_empty() {
|
||||||
SchedulePatch {
|
SchedulePatch {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,18 @@ pub trait Backend {
|
||||||
fn sync_status(&mut self) -> Result<SyncStatus> {
|
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||||
Ok(SyncStatus::default())
|
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) ---
|
// --- triage mutations (T2) ---
|
||||||
|
|
||||||
|
|
@ -100,6 +112,13 @@ pub trait Backend {
|
||||||
/// Tombstone (soft-delete) a task node — removes it from every view,
|
/// Tombstone (soft-delete) a task node — removes it from every view,
|
||||||
/// including recurring roll-forward. Distinct from `done`/`dropped`.
|
/// including recurring roll-forward. Distinct from `done`/`dropped`.
|
||||||
fn tombstone(&mut self, node_id: &str) -> Result<()>;
|
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.
|
/// Set a task's attention band.
|
||||||
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
|
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
|
||||||
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
|
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
|
||||||
|
|
@ -224,6 +243,43 @@ impl Backend for ClientBackend {
|
||||||
Ok(())
|
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<()> {
|
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
|
||||||
self.call(
|
self.call(
|
||||||
"task.set_attention",
|
"task.set_attention",
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,16 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An armed attention chord (`a` then a digit) captures the next key: `1`..`4`
|
||||||
|
// set the band, anything else cancels.
|
||||||
|
if app.pending_attention {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) => app.resolve_attention(c),
|
||||||
|
_ => app.cancel_attention(),
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
// While collecting input, all keys go to the prompt.
|
// While collecting input, all keys go to the prompt.
|
||||||
if matches!(app.mode, Mode::Input(_)) {
|
if matches!(app.mode, Mode::Input(_)) {
|
||||||
match key.code {
|
match key.code {
|
||||||
|
|
@ -149,6 +159,34 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
return None;
|
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.
|
// While search results are shown, the center pane navigates them.
|
||||||
if app.search.is_some() {
|
if app.search.is_some() {
|
||||||
app.status.clear();
|
app.status.clear();
|
||||||
|
|
@ -179,11 +217,12 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(),
|
KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(),
|
||||||
// Enter: drill sidebar→tasks, or open the selected task's context in nvim.
|
// Enter: drill sidebar→tasks, or open the selected task's context in nvim.
|
||||||
KeyCode::Enter => return app.enter().map(Action::EditContext),
|
KeyCode::Enter => return app.enter().map(Action::EditContext),
|
||||||
KeyCode::Char('a') => app.begin_add(),
|
KeyCode::Char('n') => app.begin_add(),
|
||||||
KeyCode::Char('/') => app.begin_search(),
|
KeyCode::Char('/') => app.begin_search(),
|
||||||
KeyCode::Char('s') => app.toggle_sort(),
|
KeyCode::Char('s') => app.toggle_sort(),
|
||||||
KeyCode::Char('u') => app.undo(),
|
KeyCode::Char('u') => app.undo(),
|
||||||
KeyCode::Char('z') if ctrl => app.redo(),
|
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
|
// 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.
|
// project actions — so a stray `d`/`D` in the sidebar can't touch a task.
|
||||||
_ => match app.focus {
|
_ => match app.focus {
|
||||||
|
|
@ -191,18 +230,19 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
KeyCode::Char('x') => app.complete_selected(),
|
KeyCode::Char('x') => app.complete_selected(),
|
||||||
KeyCode::Char('d') => app.drop_selected(),
|
KeyCode::Char('d') => app.drop_selected(),
|
||||||
KeyCode::Char('S') => app.skip_selected(),
|
KeyCode::Char('S') => app.skip_selected(),
|
||||||
KeyCode::Char('A') => app.cycle_attention_selected(),
|
KeyCode::Char('a') => app.begin_attention(),
|
||||||
KeyCode::Char('b') => app.push_to_blue_selected(),
|
|
||||||
KeyCode::Char('e') => app.begin_reschedule(),
|
KeyCode::Char('e') => app.begin_reschedule(),
|
||||||
KeyCode::Char('m') => app.begin_move(),
|
KeyCode::Char('m') => app.begin_move(),
|
||||||
|
KeyCode::Char('R') => app.begin_rename(),
|
||||||
|
KeyCode::Char('L') => app.open_log(),
|
||||||
KeyCode::Char('D') => app.begin_delete(),
|
KeyCode::Char('D') => app.begin_delete(),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Focus::Sidebar => {
|
Focus::Sidebar => match key.code {
|
||||||
if let KeyCode::Char('D') = key.code {
|
KeyCode::Char('m') => app.begin_reparent(),
|
||||||
app.begin_delete_project()
|
KeyCode::Char('D') => app.begin_delete_project(),
|
||||||
}
|
_ => {}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// Task-pane gestures (the focused pane shows its own hints, §8.1).
|
||||||
const HINTS: &str =
|
const HINTS: &str =
|
||||||
" j/k move ⏎ edit x done d drop S skip e date A attn b→blue m move D del u undo / search q quit";
|
" 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).
|
// Sidebar gestures: navigation + per-project actions (no task triage here).
|
||||||
const SIDEBAR_HINTS: &str =
|
const SIDEBAR_HINTS: &str =
|
||||||
" j/k move ⏎ open a 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 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.
|
/// Draw the whole UI for the current frame.
|
||||||
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
||||||
let outer = Layout::default()
|
let outer = Layout::default()
|
||||||
|
|
@ -44,7 +48,11 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
||||||
.split(outer[0]);
|
.split(outer[0]);
|
||||||
|
|
||||||
render_sidebar(frame, app, panes[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]);
|
render_search(frame, app, panes[1]);
|
||||||
} else {
|
} else {
|
||||||
render_tasks(frame, app, panes[1]);
|
render_tasks(frame, app, panes[1]);
|
||||||
|
|
@ -87,7 +95,7 @@ fn render_move(frame: &mut Frame, state: &MoveState) {
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::Cyan))
|
.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]);
|
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);
|
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) {
|
fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
if !app.preview.title.is_empty() {
|
if !app.preview.title.is_empty() {
|
||||||
|
|
@ -521,7 +609,11 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
);
|
);
|
||||||
return;
|
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
|
SEARCH_HINTS
|
||||||
} else if app.focus == Focus::Sidebar {
|
} else if app.focus == Focus::Sidebar {
|
||||||
SIDEBAR_HINTS
|
SIDEBAR_HINTS
|
||||||
|
|
@ -570,7 +662,9 @@ fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec<Span<'static>> {
|
||||||
|
|
||||||
let health = sync.health.clone().unwrap_or_default();
|
let health = sync.health.clone().unwrap_or_default();
|
||||||
let mut spans = vec![if health.auth_failure {
|
let mut spans = vec![if health.auth_failure {
|
||||||
Span::styled("⚠ auth", red)
|
// Point at the recovery command — `heph auth status` prints the exact
|
||||||
|
// `heph auth login …` to run (the full command is too long for the bar).
|
||||||
|
Span::styled("⚠ auth · heph auth status", red)
|
||||||
} else if let Some(ts) = health.last_success_ms {
|
} else if let Some(ts) = health.last_success_ms {
|
||||||
Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim)
|
Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim)
|
||||||
} else if health.last_error.is_some() {
|
} else if health.last_error.is_some() {
|
||||||
|
|
@ -639,7 +733,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
assert_eq!(render(&auth, NOW), "⚠ auth");
|
assert_eq!(render(&auth, NOW), "⚠ auth · heph auth status");
|
||||||
|
|
||||||
// Errored with no prior success → offline.
|
// Errored with no prior success → offline.
|
||||||
let offline = spoke(
|
let offline = spoke(
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,8 @@ fn quick_add_captures_a_task_that_appears_in_the_view() {
|
||||||
assert!(app.tasks.is_empty());
|
assert!(app.tasks.is_empty());
|
||||||
|
|
||||||
app.begin_add();
|
app.begin_add();
|
||||||
// Single-line NL: p1 → red, so it lands in Top of Mind (the default view).
|
// Single-line NL: a1 → red, so it lands in Top of Mind (the default view).
|
||||||
type_and_submit(&mut app, "Call the plumber p1");
|
type_and_submit(&mut app, "Call the plumber a1");
|
||||||
|
|
||||||
assert!(app.status.contains("added"), "status: {}", app.status);
|
assert!(app.status.contains("added"), "status: {}", app.status);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -304,7 +304,11 @@ fn pushing_to_blue_moves_a_task_out_of_top_of_mind() {
|
||||||
|
|
||||||
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||||
assert_eq!(app.tasks.len(), 1);
|
assert_eq!(app.tasks.len(), 1);
|
||||||
app.push_to_blue_selected();
|
// `a` then `4` sets a4 (blue) directly — the chord that replaced push-to-blue.
|
||||||
|
app.begin_attention();
|
||||||
|
assert!(app.pending_attention);
|
||||||
|
app.resolve_attention('4');
|
||||||
|
assert!(!app.pending_attention);
|
||||||
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
|
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
|
||||||
|
|
||||||
// It now appears under On Deck (the last of the five views).
|
// It now appears under On Deck (the last of the five views).
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,13 @@ struct Recorder {
|
||||||
created: Vec<CreatedTask>,
|
created: Vec<CreatedTask>,
|
||||||
scheduled: Vec<(String, SchedulePatch)>,
|
scheduled: Vec<(String, SchedulePatch)>,
|
||||||
tombstoned: Vec<String>,
|
tombstoned: Vec<String>,
|
||||||
|
restored: Vec<String>,
|
||||||
|
renamed: Vec<(String, String)>,
|
||||||
|
reparented: Vec<(String, Option<String>)>,
|
||||||
refiled: Vec<(String, Option<String>)>,
|
refiled: Vec<(String, Option<String>)>,
|
||||||
created_projects: Vec<String>,
|
created_projects: Vec<String>,
|
||||||
states: Vec<(String, String)>,
|
states: Vec<(String, String)>,
|
||||||
|
resolved: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
|
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
|
||||||
|
|
@ -57,6 +61,8 @@ struct Fake {
|
||||||
bodies: HashMap<String, String>,
|
bodies: HashMap<String, String>,
|
||||||
search_hits: Vec<SearchHit>,
|
search_hits: Vec<SearchHit>,
|
||||||
contexts: HashMap<String, String>,
|
contexts: HashMap<String, String>,
|
||||||
|
conflicts: Vec<heph_core::Conflict>,
|
||||||
|
logs: HashMap<String, Vec<String>>,
|
||||||
rec: Rc<RefCell<Recorder>>,
|
rec: Rc<RefCell<Recorder>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,8 +80,23 @@ impl Backend for Fake {
|
||||||
fn node_body(&mut self, id: &str) -> Result<String> {
|
fn node_body(&mut self, id: &str) -> Result<String> {
|
||||||
Ok(self.bodies.get(id).cloned().unwrap_or_default())
|
Ok(self.bodies.get(id).cloned().unwrap_or_default())
|
||||||
}
|
}
|
||||||
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
|
fn log_tail(&mut self, task_id: &str, _n: usize) -> Result<Vec<String>> {
|
||||||
Ok(Vec::new())
|
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>> {
|
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
|
||||||
Ok(self
|
Ok(self
|
||||||
|
|
@ -99,6 +120,24 @@ impl Backend for Fake {
|
||||||
self.rec.borrow_mut().tombstoned.push(node_id.into());
|
self.rec.borrow_mut().tombstoned.push(node_id.into());
|
||||||
Ok(())
|
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<()> {
|
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -218,13 +257,14 @@ fn move_task_clamps_at_the_ends() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attention_cycles_white_orange_red_blue() {
|
fn attention_digits_map_by_intensity() {
|
||||||
use heph_tui::app::next_attention;
|
use heph_tui::app::attention_for_digit;
|
||||||
assert_eq!(next_attention(Some(Attention::White)), Attention::Orange);
|
assert_eq!(attention_for_digit('1'), Some(Attention::Red));
|
||||||
assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red);
|
assert_eq!(attention_for_digit('2'), Some(Attention::Orange));
|
||||||
assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue);
|
assert_eq!(attention_for_digit('3'), Some(Attention::White));
|
||||||
assert_eq!(next_attention(Some(Attention::Blue)), Attention::White);
|
assert_eq!(attention_for_digit('4'), Some(Attention::Blue));
|
||||||
assert_eq!(next_attention(None), Attention::White);
|
assert_eq!(attention_for_digit('5'), None);
|
||||||
|
assert_eq!(attention_for_digit('a'), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) {
|
fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) {
|
||||||
|
|
@ -248,12 +288,12 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() {
|
||||||
assert_eq!(app.task_pane_title(), "Camano");
|
assert_eq!(app.task_pane_title(), "Camano");
|
||||||
|
|
||||||
app.begin_add();
|
app.begin_add();
|
||||||
type_and_submit(&mut app, "Fix the dock p2");
|
type_and_submit(&mut app, "Fix the dock a2");
|
||||||
|
|
||||||
let created = &rec.borrow().created;
|
let created = &rec.borrow().created;
|
||||||
assert_eq!(created.len(), 1);
|
assert_eq!(created.len(), 1);
|
||||||
assert_eq!(created[0].0, "Fix the dock");
|
assert_eq!(created[0].0, "Fix the dock");
|
||||||
assert_eq!(created[0].1, Some(Attention::Orange)); // p2
|
assert_eq!(created[0].1, Some(Attention::Orange)); // a2
|
||||||
assert_eq!(created[0].2, None); // no do-date
|
assert_eq!(created[0].2, None); // no do-date
|
||||||
assert_eq!(created[0].3, None); // no recurrence
|
assert_eq!(created[0].3, None); // no recurrence
|
||||||
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
|
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
|
||||||
|
|
@ -356,7 +396,7 @@ fn move_to_project_picker_refiles_the_selected_task() {
|
||||||
app.begin_move();
|
app.begin_move();
|
||||||
match &app.mode {
|
match &app.mode {
|
||||||
Mode::MoveToProject(m) => {
|
Mode::MoveToProject(m) => {
|
||||||
assert_eq!(m.task_id, "t1");
|
assert_eq!(m.subject_id, "t1");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
|
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
|
||||||
vec!["(Unfile)", "Camano"]
|
vec!["(Unfile)", "Camano"]
|
||||||
|
|
@ -567,3 +607,218 @@ fn reschedule_with_blank_clears_the_do_date() {
|
||||||
// do_date present-and-null = "clear" (the double-option).
|
// do_date present-and-null = "clear" (the double-option).
|
||||||
assert_eq!(scheduled[0].1.do_date, Some(None));
|
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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use anyhow::{bail, Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use heph_core::{Node, RankedTask, Task};
|
use heph_core::{Attention, Node, RankedTask, Task};
|
||||||
use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
|
use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
|
||||||
|
|
||||||
mod service;
|
mod service;
|
||||||
|
|
@ -43,7 +43,7 @@ enum Command {
|
||||||
Task {
|
Task {
|
||||||
/// The task title.
|
/// The task title.
|
||||||
title: String,
|
title: String,
|
||||||
/// Attention-state: white|orange|red|blue.
|
/// Attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
|
||||||
#[arg(short = 'a', long)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
|
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
|
||||||
|
|
@ -71,7 +71,7 @@ enum Command {
|
||||||
/// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
|
/// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
project: Option<String>,
|
project: Option<String>,
|
||||||
/// Only this attention-state: white|orange|red|blue.
|
/// Only this attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
|
||||||
#[arg(short = 'a', long)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Hide on-deck (blue) items.
|
/// Hide on-deck (blue) items.
|
||||||
|
|
@ -105,7 +105,7 @@ enum Command {
|
||||||
Attention {
|
Attention {
|
||||||
/// Task node id.
|
/// Task node id.
|
||||||
id: String,
|
id: String,
|
||||||
/// white|orange|red|blue.
|
/// a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
|
||||||
attention: String,
|
attention: String,
|
||||||
},
|
},
|
||||||
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
|
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
|
||||||
|
|
@ -125,7 +125,7 @@ enum Command {
|
||||||
/// A raw RRULE or `none`.
|
/// A raw RRULE or `none`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
rrule: Option<String>,
|
rrule: Option<String>,
|
||||||
/// Set attention: white|orange|red|blue.
|
/// Set attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
|
||||||
#[arg(short = 'a', long)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Re-file under a project (by name); `none` unfiles the task.
|
/// Re-file under a project (by name); `none` unfiles the task.
|
||||||
|
|
@ -138,7 +138,7 @@ enum Command {
|
||||||
container_id: String,
|
container_id: String,
|
||||||
/// 1-based index of the context item to promote (document order).
|
/// 1-based index of the context item to promote (document order).
|
||||||
item_ref: usize,
|
item_ref: usize,
|
||||||
/// Attention for the new task: white|orange|red|blue.
|
/// Attention for the new task: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
|
||||||
#[arg(short = 'a', long)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Project name to file the new task under.
|
/// Project name to file the new task under.
|
||||||
|
|
@ -281,6 +281,12 @@ enum NodeAction {
|
||||||
/// Node id.
|
/// Node id.
|
||||||
id: String,
|
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)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|
@ -291,7 +297,8 @@ enum LinkAction {
|
||||||
src: String,
|
src: String,
|
||||||
/// Destination node id.
|
/// Destination node id.
|
||||||
dst: String,
|
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,
|
link_type: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -308,6 +315,17 @@ enum ProjectAction {
|
||||||
},
|
},
|
||||||
/// List all projects.
|
/// List all projects.
|
||||||
List,
|
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)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|
@ -344,7 +362,7 @@ enum ConflictAction {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
enum AuthAction {
|
enum AuthAction {
|
||||||
/// Log in via the device-code flow; caches the bearer token for hub sync.
|
/// Log in via the device-code flow; caches the bearer token for hub sync.
|
||||||
Login {
|
Login {
|
||||||
|
|
@ -367,6 +385,9 @@ enum AuthAction {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
hub_url: String,
|
hub_url: String,
|
||||||
},
|
},
|
||||||
|
/// Show this spoke's auth health and, if re-auth is needed, the exact
|
||||||
|
/// `heph auth login` command to run. Queries the daemon.
|
||||||
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the device-code flow (or clear a token) — no daemon needed.
|
/// Run the device-code flow (or clear a token) — no daemon needed.
|
||||||
|
|
@ -396,10 +417,63 @@ fn run_auth(action: AuthAction) -> Result<()> {
|
||||||
KeyringTokenStore::new(hub_url.as_str()).clear()?;
|
KeyringTokenStore::new(hub_url.as_str()).clear()?;
|
||||||
println!("Logged out of {hub_url}.");
|
println!("Logged out of {hub_url}.");
|
||||||
}
|
}
|
||||||
|
AuthAction::Status => unreachable!("auth status is handled via the daemon"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render `heph auth status` from a `sync.status` RPC response: hub/issuer/client
|
||||||
|
/// id, whether auth is healthy or needs re-login, and — when it does — the exact
|
||||||
|
/// command to run (built daemon-side, keyed under the right hub URL).
|
||||||
|
fn print_auth_status(status: &Value) {
|
||||||
|
let Some(hub) = status.get("hub_url").and_then(Value::as_str) else {
|
||||||
|
println!("This instance is standalone (no hub configured); auth does not apply.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let auth = status.get("auth");
|
||||||
|
let issuer = auth.and_then(|a| a.get("issuer")).and_then(Value::as_str);
|
||||||
|
let client_id = auth
|
||||||
|
.and_then(|a| a.get("client_id"))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
let health = status.get("health");
|
||||||
|
let auth_failure = health
|
||||||
|
.and_then(|h| h.get("auth_failure"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let last_error = health
|
||||||
|
.and_then(|h| h.get("last_error"))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
let last_success = health
|
||||||
|
.and_then(|h| h.get("last_success_ms"))
|
||||||
|
.and_then(Value::as_i64);
|
||||||
|
|
||||||
|
println!("hub : {hub}");
|
||||||
|
if let Some(iss) = issuer {
|
||||||
|
println!("issuer : {iss}");
|
||||||
|
}
|
||||||
|
if let Some(cid) = client_id {
|
||||||
|
println!("client id : {cid}");
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"auth : {}",
|
||||||
|
if auth_failure {
|
||||||
|
"FAILED — re-authentication required"
|
||||||
|
} else if last_success.is_some() {
|
||||||
|
"ok"
|
||||||
|
} else {
|
||||||
|
"unknown (no successful sync yet)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if let Some(err) = last_error {
|
||||||
|
println!("last error : {err}");
|
||||||
|
}
|
||||||
|
if auth_failure {
|
||||||
|
if let Some(cmd) = status.get("reauth_command").and_then(Value::as_str) {
|
||||||
|
println!("\nTo re-authenticate, run:\n {cmd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
|
@ -407,9 +481,13 @@ fn main() -> Result<()> {
|
||||||
if let Command::Daemon { action } = &cli.command {
|
if let Command::Daemon { action } = &cli.command {
|
||||||
return service::run(action);
|
return service::run(action);
|
||||||
}
|
}
|
||||||
// `auth` runs locally (device-code flow + keyring); it needs no daemon.
|
// `auth login`/`logout` run locally (device-code flow + keyring); they need
|
||||||
if let Command::Auth { action } = cli.command {
|
// no daemon. `auth status` reads live sync health, so it falls through to the
|
||||||
return run_auth(action);
|
// connected path below.
|
||||||
|
if let Command::Auth { action } = &cli.command {
|
||||||
|
if !matches!(action, AuthAction::Status) {
|
||||||
|
return run_auth(action.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket = cli.socket.unwrap_or_else(default_socket_path);
|
let socket = cli.socket.unwrap_or_else(default_socket_path);
|
||||||
|
|
@ -429,6 +507,7 @@ fn main() -> Result<()> {
|
||||||
recur,
|
recur,
|
||||||
rrule,
|
rrule,
|
||||||
} => {
|
} => {
|
||||||
|
let attention = norm_attention(attention)?;
|
||||||
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
|
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
|
||||||
let project_id = resolve_project(&mut client, project.as_deref())?;
|
let project_id = resolve_project(&mut client, project.as_deref())?;
|
||||||
let result = client.call(
|
let result = client.call(
|
||||||
|
|
@ -455,6 +534,7 @@ fn main() -> Result<()> {
|
||||||
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
|
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
|
||||||
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
|
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
|
||||||
// daemon), a single `--attention` whitelist, and `--no-blue`.
|
// daemon), a single `--attention` whitelist, and `--no-blue`.
|
||||||
|
let attention = norm_attention(attention)?;
|
||||||
let mut filter = json!({});
|
let mut filter = json!({});
|
||||||
if let Some(s) = scope {
|
if let Some(s) = scope {
|
||||||
filter["scope"] = json!([s]);
|
filter["scope"] = json!([s]);
|
||||||
|
|
@ -498,11 +578,12 @@ fn main() -> Result<()> {
|
||||||
println!("Skipped occurrence of {id}");
|
println!("Skipped occurrence of {id}");
|
||||||
}
|
}
|
||||||
Command::Attention { id, attention } => {
|
Command::Attention { id, attention } => {
|
||||||
|
let att = Attention::parse_input(&attention)?;
|
||||||
client.call(
|
client.call(
|
||||||
"task.set_attention",
|
"task.set_attention",
|
||||||
json!({ "id": id, "attention": attention }),
|
json!({ "id": id, "attention": att.as_str() }),
|
||||||
)?;
|
)?;
|
||||||
println!("{id} attention → {attention}");
|
println!("{id} attention → {} ({})", att.ui_label(), att.as_str());
|
||||||
}
|
}
|
||||||
Command::Edit {
|
Command::Edit {
|
||||||
id,
|
id,
|
||||||
|
|
@ -528,7 +609,7 @@ fn main() -> Result<()> {
|
||||||
if patch.len() > 1 {
|
if patch.len() > 1 {
|
||||||
client.call("task.set_schedule", Value::Object(patch))?;
|
client.call("task.set_schedule", Value::Object(patch))?;
|
||||||
}
|
}
|
||||||
if let Some(a) = attention {
|
if let Some(a) = norm_attention(attention)? {
|
||||||
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
|
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
|
||||||
}
|
}
|
||||||
if let Some(spec) = project.as_deref() {
|
if let Some(spec) = project.as_deref() {
|
||||||
|
|
@ -552,6 +633,7 @@ fn main() -> Result<()> {
|
||||||
attention,
|
attention,
|
||||||
project,
|
project,
|
||||||
} => {
|
} => {
|
||||||
|
let attention = norm_attention(attention)?;
|
||||||
let project_id = resolve_project(&mut client, project.as_deref())?;
|
let project_id = resolve_project(&mut client, project.as_deref())?;
|
||||||
let result = client.call(
|
let result = client.call(
|
||||||
"task.promote",
|
"task.promote",
|
||||||
|
|
@ -571,6 +653,16 @@ fn main() -> Result<()> {
|
||||||
if node.get("kind").and_then(Value::as_str) == Some("task") {
|
if node.get("kind").and_then(Value::as_str) == Some("task") {
|
||||||
let task = client.call("task.get", json!({ "id": id }))?;
|
let task = client.call("task.get", json!({ "id": id }))?;
|
||||||
println!("task: {}", serde_json::to_string_pretty(&task)?);
|
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 } => {
|
Command::Log { id, text, n } => {
|
||||||
|
|
@ -651,6 +743,10 @@ fn main() -> Result<()> {
|
||||||
client.call("node.tombstone", json!({ "id": id }))?;
|
client.call("node.tombstone", json!({ "id": id }))?;
|
||||||
println!("Tombstoned {id}");
|
println!("Tombstoned {id}");
|
||||||
}
|
}
|
||||||
|
NodeAction::Restore { id } => {
|
||||||
|
client.call("node.restore", json!({ "id": id }))?;
|
||||||
|
println!("Restored {id}");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Command::Get { id } => {
|
Command::Get { id } => {
|
||||||
let result = client.call("node.get", json!({ "id": id }))?;
|
let result = client.call("node.get", json!({ "id": id }))?;
|
||||||
|
|
@ -724,6 +820,26 @@ fn main() -> Result<()> {
|
||||||
println!("{} {}", n.id, n.title);
|
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 {
|
Command::Tag { action } => match action {
|
||||||
TagAction::Add { node, tag } => {
|
TagAction::Add { node, tag } => {
|
||||||
|
|
@ -790,13 +906,28 @@ fn main() -> Result<()> {
|
||||||
let n = result.as_u64().unwrap_or(0);
|
let n = result.as_u64().unwrap_or(0);
|
||||||
println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s).");
|
println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s).");
|
||||||
}
|
}
|
||||||
Command::Auth { .. } => unreachable!("auth is handled before connecting"),
|
Command::Auth {
|
||||||
|
action: AuthAction::Status,
|
||||||
|
} => {
|
||||||
|
let result = client.call("sync.status", json!({}))?;
|
||||||
|
print_auth_status(&result);
|
||||||
|
}
|
||||||
|
Command::Auth { .. } => unreachable!("auth login/logout handled before connecting"),
|
||||||
Command::Daemon { .. } => unreachable!("daemon is handled before connecting"),
|
Command::Daemon { .. } => unreachable!("daemon is handled before connecting"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
|
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
|
||||||
|
/// Normalize a user-facing `--attention` value to its storage colour string.
|
||||||
|
/// Accepts the `a1`..`a4` labels, a bare digit `1`..`4`, or a colour word
|
||||||
|
/// (`red`/`orange`/`white`/`blue`). `None` passes through unchanged.
|
||||||
|
fn norm_attention(a: Option<String>) -> Result<Option<String>> {
|
||||||
|
a.map(|s| Attention::parse_input(&s).map(|att| att.as_str().to_string()))
|
||||||
|
.transpose()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
|
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
|
||||||
spec.map(datespec::parse_date_ms).transpose()
|
spec.map(datespec::parse_date_ms).transpose()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
|
|
@ -494,6 +495,51 @@ fn launchd_loaded(domain_target: &str) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Block until `target` is no longer loaded, up to `timeout`. `launchctl bootout`
|
||||||
|
/// is asynchronous in effect — it requests teardown and returns, but launchd may
|
||||||
|
/// still be killing/reaping the job and removing its label from the domain.
|
||||||
|
/// Bootstrapping while the label lingers fails with a generic `5: Input/output
|
||||||
|
/// error`, so we wait for the label to actually disappear before re-bootstrapping.
|
||||||
|
fn wait_until_unloaded(target: &str, timeout: Duration) {
|
||||||
|
let start = Instant::now();
|
||||||
|
while launchd_loaded(target) {
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
break; // fall through; bootstrap's own retry covers the residual window
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap the service, retrying briefly. Even once the old instance is gone,
|
||||||
|
/// launchd can momentarily return EIO while the domain settles, so a couple of
|
||||||
|
/// short retries make `start`/`restart` reliable instead of intermittently failing.
|
||||||
|
fn launchd_bootstrap(domain: &str, plist: &str) -> Result<()> {
|
||||||
|
let mut last = String::new();
|
||||||
|
for attempt in 0..5 {
|
||||||
|
if attempt > 0 {
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
let (ok, err) = run_cmd("launchctl", &["bootstrap", domain, plist])?;
|
||||||
|
if ok {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
last = err;
|
||||||
|
}
|
||||||
|
bail!("launchctl bootstrap failed: {}", last.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart an already-loaded job in place (kills it, then launchd's KeepAlive —
|
||||||
|
/// `-k` forces the kill). This restarts the *loaded* job definition, so it does
|
||||||
|
/// not pick up an edited plist — callers use it only when the on-disk plist is
|
||||||
|
/// unchanged, where it sidesteps the bootout→bootstrap race entirely.
|
||||||
|
fn launchd_kickstart(target: &str) -> Result<()> {
|
||||||
|
let (ok, err) = run_cmd("launchctl", &["kickstart", "-k", target])?;
|
||||||
|
if !ok {
|
||||||
|
bail!("launchctl kickstart failed: {}", err.trim());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
let plist = launchd_plist_path()?;
|
let plist = launchd_plist_path()?;
|
||||||
let uid = uid()?;
|
let uid = uid()?;
|
||||||
|
|
@ -512,10 +558,7 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
if launchd_loaded(&target) {
|
if launchd_loaded(&target) {
|
||||||
println!("heph daemon already running ({LABEL}).");
|
println!("heph daemon already running ({LABEL}).");
|
||||||
} else {
|
} else {
|
||||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
launchd_bootstrap(&domain, &plist_str(&plist)?)?;
|
||||||
if !ok {
|
|
||||||
bail!("launchctl bootstrap failed: {}", err.trim());
|
|
||||||
}
|
|
||||||
println!("heph daemon started ({LABEL}).");
|
println!("heph daemon started ({LABEL}).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -527,14 +570,24 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
let cfg = args
|
let cfg = args
|
||||||
.to_config()
|
.to_config()
|
||||||
.fill_from(existing_config(&plist, &Manager::Launchd));
|
.fill_from(existing_config(&plist, &Manager::Launchd));
|
||||||
write_if_changed(
|
let changed = write_if_changed(
|
||||||
&plist,
|
&plist,
|
||||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
|
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
|
||||||
)?;
|
)?;
|
||||||
|
if !launchd_loaded(&target) {
|
||||||
|
// Not currently loaded — nothing to tear down, just bring it up.
|
||||||
|
launchd_bootstrap(&domain, &plist_str(&plist)?)?;
|
||||||
|
} else if changed {
|
||||||
|
// The plist changed, so launchd must re-read it: a full reload is
|
||||||
|
// required. bootout is async, so wait for the label to clear
|
||||||
|
// before bootstrapping (and bootstrap retries the residual EIO).
|
||||||
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
||||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
wait_until_unloaded(&target, Duration::from_secs(5));
|
||||||
if !ok {
|
launchd_bootstrap(&domain, &plist_str(&plist)?)?;
|
||||||
bail!("launchctl bootstrap failed: {}", err.trim());
|
} else {
|
||||||
|
// Same definition (e.g. binary upgraded in place) — restart the
|
||||||
|
// loaded job atomically, sidestepping the bootout→bootstrap race.
|
||||||
|
launchd_kickstart(&target)?;
|
||||||
}
|
}
|
||||||
println!("heph daemon restarted ({LABEL}).");
|
println!("heph daemon restarted ({LABEL}).");
|
||||||
}
|
}
|
||||||
|
|
@ -638,6 +691,86 @@ fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path)
|
||||||
println!("log : {}", p.log.display());
|
println!("log : {}", p.log.display());
|
||||||
if !running {
|
if !running {
|
||||||
println!("\n(start it with `heph daemon start`)");
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,3 +249,78 @@ fn export_writes_markdown_files() {
|
||||||
let text = std::fs::read_to_string(docs[0].path()).unwrap();
|
let text = std::fs::read_to_string(docs[0].path()).unwrap();
|
||||||
assert!(text.contains("title: \"Roof log\""), "{text}");
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ dbus-secret-service-keyring-store.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
proptest = "1"
|
||||||
# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo).
|
# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo).
|
||||||
rsa = "0.9"
|
rsa = "0.9"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|
|
||||||
7
crates/hephd/proptest-regressions/datespec.txt
Normal file
7
crates/hephd/proptest-regressions/datespec.txt
Normal 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"
|
||||||
|
|
@ -38,9 +38,45 @@ pub enum AuthError {
|
||||||
/// The token was present but failed validation.
|
/// The token was present but failed validation.
|
||||||
#[error("invalid token: {0}")]
|
#[error("invalid token: {0}")]
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
/// The identity provider could not be reached to fetch keys.
|
/// The identity provider could not be reached at all (DNS, TLS, connection
|
||||||
|
/// refused, timeout) — a transport failure, distinct from a rejection.
|
||||||
#[error("identity provider unreachable: {0}")]
|
#[error("identity provider unreachable: {0}")]
|
||||||
Provider(String),
|
Unreachable(String),
|
||||||
|
/// The identity provider *was* reached but returned an HTTP error response —
|
||||||
|
/// e.g. `400 invalid_grant` on a refresh, meaning the token was rejected
|
||||||
|
/// (expired/rotated/session-invalidated), not that the IdP was down. The
|
||||||
|
/// distinction matters: "unreachable" sends debugging toward the network;
|
||||||
|
/// this points at the token/authorization.
|
||||||
|
#[error("identity provider rejected the request: {0}")]
|
||||||
|
Rejected(String),
|
||||||
|
/// Some other failure in the auth path that is neither a transport failure
|
||||||
|
/// nor an HTTP rejection — a malformed/unparseable IdP response, or a local
|
||||||
|
/// credential-store (keyring) error. Kept distinct so neither is mislabeled
|
||||||
|
/// as "unreachable".
|
||||||
|
#[error("auth error: {0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthError {
|
||||||
|
/// Build a [`AuthError::Rejected`] from an HTTP status and the OAuth error
|
||||||
|
/// body (RFC 6749 §5.2), e.g. `HTTP 400 (invalid_grant): Token is expired`.
|
||||||
|
pub fn rejected(status: u16, error: Option<&str>, description: Option<&str>) -> AuthError {
|
||||||
|
let mut msg = format!("HTTP {status}");
|
||||||
|
if let Some(e) = error.filter(|e| !e.is_empty()) {
|
||||||
|
msg.push_str(&format!(" ({e})"));
|
||||||
|
}
|
||||||
|
if let Some(d) = description.filter(|d| !d.is_empty()) {
|
||||||
|
msg.push_str(&format!(": {d}"));
|
||||||
|
}
|
||||||
|
AuthError::Rejected(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this is an authorization-level rejection (the IdP refused the
|
||||||
|
/// grant) rather than a transport failure — i.e. re-authentication is the
|
||||||
|
/// likely fix, not network troubleshooting.
|
||||||
|
pub fn is_rejection(&self) -> bool {
|
||||||
|
matches!(self, AuthError::Rejected(_))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be
|
/// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be
|
||||||
|
|
@ -92,16 +128,13 @@ impl OidcVerifier {
|
||||||
.http
|
.http
|
||||||
.get(url)
|
.get(url)
|
||||||
.call()
|
.call()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Unreachable(e.to_string()))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(AuthError::Provider(format!(
|
return Err(AuthError::rejected(resp.status().as_u16(), None, None));
|
||||||
"{url} returned {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
resp.body_mut()
|
resp.body_mut()
|
||||||
.read_json()
|
.read_json()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))
|
.map_err(|e| AuthError::Unreachable(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the JWKS URI from the provider's discovery document.
|
/// Resolve the JWKS URI from the provider's discovery document.
|
||||||
|
|
@ -169,3 +202,38 @@ impl TokenVerifier for OidcVerifier {
|
||||||
Some((&self.issuer, &self.audience))
|
Some((&self.issuer, &self.audience))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AuthError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_formats_status_error_and_description() {
|
||||||
|
let e = AuthError::rejected(400, Some("invalid_grant"), Some("Token is not active"));
|
||||||
|
assert!(e.is_rejection());
|
||||||
|
assert_eq!(
|
||||||
|
e.to_string(),
|
||||||
|
"identity provider rejected the request: HTTP 400 (invalid_grant): Token is not active"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_omits_absent_or_empty_oauth_fields() {
|
||||||
|
// No OAuth body (e.g. a bare 503) → just the status.
|
||||||
|
assert_eq!(
|
||||||
|
AuthError::rejected(503, None, None).to_string(),
|
||||||
|
"identity provider rejected the request: HTTP 503"
|
||||||
|
);
|
||||||
|
// Empty strings are treated as absent, not rendered as "()" / ": ".
|
||||||
|
assert_eq!(
|
||||||
|
AuthError::rejected(400, Some(""), Some("")).to_string(),
|
||||||
|
"identity provider rejected the request: HTTP 400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreachable_is_not_a_rejection() {
|
||||||
|
assert!(!AuthError::Unreachable("connection refused".into()).is_rejection());
|
||||||
|
assert!(!AuthError::Other("keyring locked".into()).is_rejection());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,59 +2,145 @@
|
||||||
//!
|
//!
|
||||||
//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly
|
//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly
|
||||||
//! (tech-spec §3) — they go through the daemon socket, which this wraps.
|
//! (tech-spec §3) — they go through the daemon socket, which this wraps.
|
||||||
|
//!
|
||||||
|
//! The connection self-heals across daemon restarts (opt-in self-update, `heph
|
||||||
|
//! daemon restart`): a [`call`](Client::call) that finds the socket dropped
|
||||||
|
//! reconnects. It only auto-retries when the request provably never reached the
|
||||||
|
//! daemon (a write-side failure); a reply lost *after* sending is surfaced
|
||||||
|
//! rather than retried, so a mutation is never silently double-applied.
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::rpc::Response;
|
use crate::rpc::Response;
|
||||||
|
|
||||||
/// A connected client. One request/response per [`call`](Client::call).
|
/// A connected client. One request/response per [`call`](Client::call).
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
|
socket_path: PathBuf,
|
||||||
reader: BufReader<UnixStream>,
|
reader: BufReader<UnixStream>,
|
||||||
writer: UnixStream,
|
writer: UnixStream,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a single request/response exchange failed — drives the retry decision.
|
||||||
|
enum ExchangeError {
|
||||||
|
/// The request could not be written (broken pipe, reset): it never reached
|
||||||
|
/// the daemon, so retrying on a fresh connection is safe.
|
||||||
|
Send(anyhow::Error),
|
||||||
|
/// The request was sent but no reply came back (the daemon closed mid-flight,
|
||||||
|
/// e.g. it restarted): it may or may not have applied — do not retry.
|
||||||
|
Recv(anyhow::Error),
|
||||||
|
/// A well-formed RPC-level error (or an unparseable reply): the connection is
|
||||||
|
/// fine; nothing to reconnect.
|
||||||
|
Rpc(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExchangeError {
|
||||||
|
fn into_inner(self) -> anyhow::Error {
|
||||||
|
match self {
|
||||||
|
ExchangeError::Send(e) | ExchangeError::Recv(e) | ExchangeError::Rpc(e) => e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// Connect to a daemon listening at `socket_path`.
|
/// Connect to a daemon listening at `socket_path`.
|
||||||
pub fn connect(socket_path: &Path) -> Result<Client> {
|
pub fn connect(socket_path: &Path) -> Result<Client> {
|
||||||
let stream = UnixStream::connect(socket_path)
|
let (reader, writer) = Self::open(socket_path)?;
|
||||||
.with_context(|| format!("connecting to hephd at {}", socket_path.display()))?;
|
|
||||||
let reader = BufReader::new(stream.try_clone()?);
|
|
||||||
Ok(Client {
|
Ok(Client {
|
||||||
|
socket_path: socket_path.to_path_buf(),
|
||||||
reader,
|
reader,
|
||||||
writer: stream,
|
writer,
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a fresh reader/writer pair on the socket.
|
||||||
|
fn open(socket_path: &Path) -> Result<(BufReader<UnixStream>, UnixStream)> {
|
||||||
|
let stream = UnixStream::connect(socket_path)
|
||||||
|
.with_context(|| format!("connecting to hephd at {}", socket_path.display()))?;
|
||||||
|
let reader = BufReader::new(stream.try_clone()?);
|
||||||
|
Ok((reader, stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-establish the connection (after the daemon restarted and dropped it).
|
||||||
|
fn reconnect(&mut self) -> Result<()> {
|
||||||
|
let (reader, writer) = Self::open(&self.socket_path)?;
|
||||||
|
self.reader = reader;
|
||||||
|
self.writer = writer;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Call `method` with `params`, returning the `result` value (or an error
|
/// Call `method` with `params`, returning the `result` value (or an error
|
||||||
/// carrying the RPC error's code and message).
|
/// carrying the RPC error's code and message).
|
||||||
|
///
|
||||||
|
/// If the daemon has restarted and dropped the socket, this reconnects: it
|
||||||
|
/// retries transparently when the request never went out, and otherwise
|
||||||
|
/// reconnects for the next call while surfacing an error for this one (so a
|
||||||
|
/// mutation whose reply was lost is not silently re-applied).
|
||||||
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
|
|
||||||
let mut line = serde_json::to_string(&json!({
|
let mut line = serde_json::to_string(&json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"params": params,
|
"params": params,
|
||||||
}))?;
|
}))?;
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
self.writer.write_all(line.as_bytes())?;
|
|
||||||
self.writer.flush()?;
|
match self.exchange(&line) {
|
||||||
|
Ok(v) => Ok(v),
|
||||||
|
Err(ExchangeError::Rpc(e)) => Err(e),
|
||||||
|
Err(ExchangeError::Send(_)) => {
|
||||||
|
// The request never reached the daemon — reconnect and retry once.
|
||||||
|
self.reconnect()
|
||||||
|
.context("hephd connection lost and reconnect failed")?;
|
||||||
|
self.exchange(&line)
|
||||||
|
.map_err(ExchangeError::into_inner)
|
||||||
|
.with_context(|| format!("retrying `{method}` after reconnect"))
|
||||||
|
}
|
||||||
|
Err(ExchangeError::Recv(e)) => {
|
||||||
|
// Sent but no reply: the daemon likely restarted mid-request. Don't
|
||||||
|
// retry (a mutation may have applied); reconnect for next time and
|
||||||
|
// surface this one.
|
||||||
|
let _ = self.reconnect();
|
||||||
|
Err(e).context(
|
||||||
|
"hephd closed the connection mid-request (it likely restarted); \
|
||||||
|
reconnected — re-run the action if it didn't take effect",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request/response over the current connection, classifying failures.
|
||||||
|
fn exchange(&mut self, line: &str) -> std::result::Result<Value, ExchangeError> {
|
||||||
|
self.writer
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.map_err(|e| ExchangeError::Send(e.into()))?;
|
||||||
|
self.writer
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| ExchangeError::Send(e.into()))?;
|
||||||
|
|
||||||
let mut response_line = String::new();
|
let mut response_line = String::new();
|
||||||
let read = self.reader.read_line(&mut response_line)?;
|
let read = self
|
||||||
|
.reader
|
||||||
|
.read_line(&mut response_line)
|
||||||
|
.map_err(|e| ExchangeError::Recv(e.into()))?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
bail!("hephd closed the connection");
|
return Err(ExchangeError::Recv(anyhow!("hephd closed the connection")));
|
||||||
}
|
}
|
||||||
let response: Response = serde_json::from_str(&response_line)?;
|
let response: Response =
|
||||||
|
serde_json::from_str(&response_line).map_err(|e| ExchangeError::Rpc(e.into()))?;
|
||||||
if let Some(err) = response.error {
|
if let Some(err) = response.error {
|
||||||
bail!("rpc error {}: {}", err.code, err.message);
|
return Err(ExchangeError::Rpc(anyhow!(
|
||||||
|
"rpc error {}: {}",
|
||||||
|
err.code,
|
||||||
|
err.message
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Ok(response.result.unwrap_or(Value::Null))
|
Ok(response.result.unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,20 @@ fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> {
|
||||||
let n: u64 = num
|
let n: u64 = num
|
||||||
.parse()
|
.parse()
|
||||||
.with_context(|| format!("not a relative date offset: +{rest}"))?;
|
.with_context(|| format!("not a relative date offset: +{rest}"))?;
|
||||||
match unit.trim() {
|
// Checked throughout: a large `n` would otherwise overflow chrono's date
|
||||||
"" | "d" | "day" | "days" => Ok(today + Days::new(n)),
|
// arithmetic and panic (the `+` operators do), so an out-of-range offset
|
||||||
"w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)),
|
// must surface as a clean error instead of crashing the parse.
|
||||||
"m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)),
|
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)"),
|
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
|
/// 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;
|
return None;
|
||||||
}
|
}
|
||||||
let month = |t: &str| -> Option<u32> {
|
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),
|
"jan" => Some(1),
|
||||||
"feb" => Some(2),
|
"feb" => Some(2),
|
||||||
"mar" => Some(3),
|
"mar" => Some(3),
|
||||||
|
|
@ -637,4 +648,37 @@ mod tests {
|
||||||
assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,12 @@ struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
oidc_client_id: Option<String>,
|
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
|
/// Opt-in (default off): periodically poll the forge for a newer release and
|
||||||
/// auto-update this daemon. Off unless this flag is given.
|
/// auto-update this daemon. Off unless this flag is given.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -154,7 +160,9 @@ async fn main() -> Result<()> {
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
None,
|
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 => {
|
Mode::Local | Mode::Server => {
|
||||||
|
|
@ -165,11 +173,21 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
// Take the exclusive lock before opening the store (tech-spec §3.1).
|
// Take the exclusive lock before opening the store (tech-spec §3.1).
|
||||||
let lock = LockGuard::acquire(&db)?;
|
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| {
|
let spoke = cli.hub_url.as_deref().and_then(|hub| {
|
||||||
spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref())
|
spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref())
|
||||||
});
|
});
|
||||||
let daemon = Daemon::new(store)
|
let daemon = Daemon::new(store)
|
||||||
|
.with_mode(if cli.mode == Mode::Server {
|
||||||
|
"server"
|
||||||
|
} else {
|
||||||
|
"local"
|
||||||
|
})
|
||||||
.with_hub(cli.hub_url.clone())
|
.with_hub(cli.hub_url.clone())
|
||||||
.with_spoke_auth(spoke)
|
.with_spoke_auth(spoke)
|
||||||
.with_self_update(self_update.clone());
|
.with_self_update(self_update.clone());
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ impl KeyringTokenStore {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
keyring_core::Entry::new(&self.service, &self.account)
|
keyring_core::Entry::new(&self.service, &self.account)
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))
|
.map_err(|e| AuthError::Other(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,16 +119,16 @@ impl TokenStore for KeyringTokenStore {
|
||||||
serde_json::from_str(&secret).ok()
|
serde_json::from_str(&secret).ok()
|
||||||
}
|
}
|
||||||
fn save(&self, token: &StoredToken) -> Result<(), AuthError> {
|
fn save(&self, token: &StoredToken) -> Result<(), AuthError> {
|
||||||
let json = serde_json::to_string(token).map_err(|e| AuthError::Provider(e.to_string()))?;
|
let json = serde_json::to_string(token).map_err(|e| AuthError::Other(e.to_string()))?;
|
||||||
self.entry()?
|
self.entry()?
|
||||||
.set_password(&json)
|
.set_password(&json)
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))
|
.map_err(|e| AuthError::Other(e.to_string()))
|
||||||
}
|
}
|
||||||
fn clear(&self) -> Result<(), AuthError> {
|
fn clear(&self) -> Result<(), AuthError> {
|
||||||
match self.entry()?.delete_credential() {
|
match self.entry()?.delete_credential() {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(keyring_core::Error::NoEntry) => Ok(()),
|
Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(AuthError::Provider(e.to_string())),
|
Err(e) => Err(AuthError::Other(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +187,9 @@ impl TokenResponse {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TokenErrorBody {
|
struct TokenErrorBody {
|
||||||
error: String,
|
error: String,
|
||||||
|
/// Human-readable detail the provider may include (RFC 6749 §5.2).
|
||||||
|
#[serde(default)]
|
||||||
|
error_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drives the OAuth 2.0 device-code flow against one provider.
|
/// Drives the OAuth 2.0 device-code flow against one provider.
|
||||||
|
|
@ -208,17 +211,14 @@ impl DeviceFlow {
|
||||||
let mut resp = http
|
let mut resp = http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.call()
|
.call()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Unreachable(e.to_string()))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(AuthError::Provider(format!(
|
return Err(AuthError::rejected(resp.status().as_u16(), None, None));
|
||||||
"discovery returned {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
let doc: DiscoveryDoc = resp
|
let doc: DiscoveryDoc = resp
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_json()
|
.read_json()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Other(e.to_string()))?;
|
||||||
Ok(DeviceFlow {
|
Ok(DeviceFlow {
|
||||||
client_id: client_id.to_string(),
|
client_id: client_id.to_string(),
|
||||||
http,
|
http,
|
||||||
|
|
@ -233,16 +233,13 @@ impl DeviceFlow {
|
||||||
.http
|
.http
|
||||||
.post(&self.device_authorization_endpoint)
|
.post(&self.device_authorization_endpoint)
|
||||||
.send_form([("client_id", self.client_id.as_str()), ("scope", scope)])
|
.send_form([("client_id", self.client_id.as_str()), ("scope", scope)])
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Unreachable(e.to_string()))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(AuthError::Provider(format!(
|
return Err(AuthError::rejected(resp.status().as_u16(), None, None));
|
||||||
"device authorization returned {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
resp.body_mut()
|
resp.body_mut()
|
||||||
.read_json()
|
.read_json()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))
|
.map_err(|e| AuthError::Other(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the token endpoint until the user authorizes, the code expires, or
|
/// Poll the token endpoint until the user authorizes, the code expires, or
|
||||||
|
|
@ -267,13 +264,13 @@ impl DeviceFlow {
|
||||||
("device_code", auth.device_code.as_str()),
|
("device_code", auth.device_code.as_str()),
|
||||||
("client_id", self.client_id.as_str()),
|
("client_id", self.client_id.as_str()),
|
||||||
])
|
])
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Unreachable(e.to_string()))?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let token: TokenResponse = response
|
let token: TokenResponse = response
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_json()
|
.read_json()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Other(e.to_string()))?;
|
||||||
return Ok(token.into_stored());
|
return Ok(token.into_stored());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +278,7 @@ impl DeviceFlow {
|
||||||
let body: TokenErrorBody = response
|
let body: TokenErrorBody = response
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_json()
|
.read_json()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Other(e.to_string()))?;
|
||||||
match body.error.as_str() {
|
match body.error.as_str() {
|
||||||
"authorization_pending" => {}
|
"authorization_pending" => {}
|
||||||
"slow_down" => interval += 5,
|
"slow_down" => interval += 5,
|
||||||
|
|
@ -301,17 +298,24 @@ impl DeviceFlow {
|
||||||
("refresh_token", refresh_token),
|
("refresh_token", refresh_token),
|
||||||
("client_id", self.client_id.as_str()),
|
("client_id", self.client_id.as_str()),
|
||||||
])
|
])
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?;
|
.map_err(|e| AuthError::Unreachable(e.to_string()))?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(AuthError::Provider(format!(
|
// The IdP was reached and refused the grant (typically a `400
|
||||||
"token refresh returned {}",
|
// invalid_grant` once the refresh token is expired/rotated). Report
|
||||||
response.status()
|
// it as a *rejection* with the OAuth error body — not "unreachable",
|
||||||
)));
|
// which would misdirect debugging toward the network.
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response.body_mut().read_json::<TokenErrorBody>().ok();
|
||||||
|
return Err(AuthError::rejected(
|
||||||
|
status,
|
||||||
|
body.as_ref().map(|b| b.error.as_str()),
|
||||||
|
body.as_ref().and_then(|b| b.error_description.as_deref()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let mut token: StoredToken = response
|
let mut token: StoredToken = response
|
||||||
.body_mut()
|
.body_mut()
|
||||||
.read_json::<TokenResponse>()
|
.read_json::<TokenResponse>()
|
||||||
.map_err(|e| AuthError::Provider(e.to_string()))?
|
.map_err(|e| AuthError::Other(e.to_string()))?
|
||||||
.into_stored();
|
.into_stored();
|
||||||
// Providers may omit the refresh token on refresh — keep the old one.
|
// Providers may omit the refresh token on refresh — keep the old one.
|
||||||
if token.refresh_token.is_none() {
|
if token.refresh_token.is_none() {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style
|
//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style
|
||||||
//! capture: `Water plants tomorrow p2 #Chores every 3 days`.
|
//! capture: `Water plants tomorrow a2 #Chores every 3 days`.
|
||||||
//!
|
//!
|
||||||
//! Pure and deterministic: `today` and the known projects are passed in, so the
|
//! Pure and deterministic: `today` and the known projects are passed in, so the
|
||||||
//! whole parser is unit-testable. Recognized inline tokens are extracted and the
|
//! whole parser is unit-testable. Recognized inline tokens are extracted and the
|
||||||
//! remainder is the title (order preserved). The recognized forms mirror the
|
//! remainder is the title (order preserved). The recognized forms mirror the
|
||||||
//! owner's Todoist usage ([[design]] §6.2.1):
|
//! owner's Todoist usage ([[design]] §6.2.1):
|
||||||
//!
|
//!
|
||||||
//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white).
|
//! - **Attention** `a1`..`a4` → attention band, ordered by intensity
|
||||||
|
//! (a1 red, a2 orange, a3 white, a4 blue).
|
||||||
//! - **Project** `#Name` — resolved against existing projects, greedily matching
|
//! - **Project** `#Name` — resolved against existing projects, greedily matching
|
||||||
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
|
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
|
||||||
//! title verbatim (no surprise project creation).
|
//! title verbatim (no surprise project creation).
|
||||||
|
|
@ -40,12 +41,13 @@ pub struct Parsed {
|
||||||
pub project_id: Option<String>,
|
pub project_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn priority_attention(token: &str) -> Option<Attention> {
|
/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent).
|
||||||
|
fn attention_token(token: &str) -> Option<Attention> {
|
||||||
match token.to_ascii_lowercase().as_str() {
|
match token.to_ascii_lowercase().as_str() {
|
||||||
"p1" => Some(Attention::Red),
|
"a1" => Some(Attention::Red),
|
||||||
"p2" => Some(Attention::Orange),
|
"a2" => Some(Attention::Orange),
|
||||||
"p3" => Some(Attention::Blue),
|
"a3" => Some(Attention::White),
|
||||||
"p4" => Some(Attention::White),
|
"a4" => Some(Attention::Blue),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +64,7 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed {
|
||||||
while i < tokens.len() {
|
while i < tokens.len() {
|
||||||
let tok = &tokens[i];
|
let tok = &tokens[i];
|
||||||
|
|
||||||
if let Some(a) = priority_attention(tok) {
|
if let Some(a) = attention_token(tok) {
|
||||||
out.attention = Some(a);
|
out.attention = Some(a);
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -170,12 +172,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn priority_maps_to_attention() {
|
fn attention_token_maps_to_attention() {
|
||||||
assert_eq!(p("Email boss p1").attention, Some(Attention::Red));
|
assert_eq!(p("Email boss a1").attention, Some(Attention::Red));
|
||||||
assert_eq!(p("Email boss p2").attention, Some(Attention::Orange));
|
assert_eq!(p("Email boss a2").attention, Some(Attention::Orange));
|
||||||
assert_eq!(p("Email boss p3").attention, Some(Attention::Blue));
|
assert_eq!(p("Email boss a3").attention, Some(Attention::White));
|
||||||
assert_eq!(p("Email boss p4").attention, Some(Attention::White));
|
assert_eq!(p("Email boss a4").attention, Some(Attention::Blue));
|
||||||
assert_eq!(p("Email boss p1").title, "Email boss");
|
assert_eq!(p("Email boss a1").title, "Email boss");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_priority_tokens_are_no_longer_recognized() {
|
||||||
|
// p1..p4 are retired in favour of a1..a4 — they stay in the title.
|
||||||
|
let r = p("Email boss p1");
|
||||||
|
assert_eq!(r.attention, None);
|
||||||
|
assert_eq!(r.title, "Email boss p1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -215,7 +225,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn everything_at_once() {
|
fn everything_at_once() {
|
||||||
let r = p("Plan trip p2 friday #Work every week");
|
let r = p("Plan trip a2 friday #Work every week");
|
||||||
assert_eq!(r.title, "Plan trip");
|
assert_eq!(r.title, "Plan trip");
|
||||||
assert_eq!(r.attention, Some(Attention::Orange));
|
assert_eq!(r.attention, Some(Attention::Orange));
|
||||||
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday
|
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday
|
||||||
|
|
@ -230,4 +240,25 @@ mod tests {
|
||||||
assert_eq!(r.title, "Review every report");
|
assert_eq!(r.title, "Review every report");
|
||||||
assert_eq!(r.recurrence, None);
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,23 @@ impl Store for RemoteStore {
|
||||||
self.call("node.tombstone", json!({ "id": id })).map(|_| ())
|
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<()> {
|
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
||||||
self.call("project.delete", json!({ "id": project_id }))
|
self.call("project.delete", json!({ "id": project_id }))
|
||||||
.map(|_| ())
|
.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>> {
|
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
|
||||||
self.call_as("node.resolve", json!({ "title": title }))
|
self.call_as("node.resolve", json!({ "title": title }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,14 @@ struct SetProjectParams {
|
||||||
project_id: Option<String>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct PromoteParams {
|
struct PromoteParams {
|
||||||
container_id: String,
|
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)?;
|
store.tombstone_node(&p.id)?;
|
||||||
json!({ "ok": true })
|
json!({ "ok": true })
|
||||||
}
|
}
|
||||||
|
"node.restore" => {
|
||||||
|
let p: IdParam = parse(params)?;
|
||||||
|
store.restore_node(&p.id)?;
|
||||||
|
json!({ "ok": true })
|
||||||
|
}
|
||||||
"node.resolve" => {
|
"node.resolve" => {
|
||||||
let p: ResolveParams = parse(params)?;
|
let p: ResolveParams = parse(params)?;
|
||||||
json!(store.resolve_node(&p.title)?)
|
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)?;
|
store.delete_project(&p.id)?;
|
||||||
Value::Null
|
Value::Null
|
||||||
}
|
}
|
||||||
|
"project.reparent" => {
|
||||||
|
let p: ReparentParams = parse(params)?;
|
||||||
|
store.reparent_project(&p.id, p.parent_id.as_deref())?;
|
||||||
|
json!({ "ok": true })
|
||||||
|
}
|
||||||
"task.skip" => {
|
"task.skip" => {
|
||||||
let p: IdParam = parse(params)?;
|
let p: IdParam = parse(params)?;
|
||||||
json!(store.skip_recurrence(&p.id)?)
|
json!(store.skip_recurrence(&p.id)?)
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// repo is public, so this is an unauthenticated GET on the canonical public
|
||||||
/// host.
|
/// host.
|
||||||
|
|
@ -225,13 +248,15 @@ pub async fn apply_update(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The background poll loop: tick on `interval`, check for a newer release, and
|
/// 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>(
|
pub async fn run_poll_loop<S: ReleaseSource>(
|
||||||
source: S,
|
source: S,
|
||||||
installer: Arc<dyn Installer>,
|
installer: Arc<dyn Installer>,
|
||||||
restarter: Arc<dyn Restarter>,
|
restarter: Arc<dyn Restarter>,
|
||||||
interval: Duration,
|
interval: Duration,
|
||||||
current: &'static str,
|
current: &'static str,
|
||||||
|
health: Arc<std::sync::Mutex<SelfUpdateHealth>>,
|
||||||
) {
|
) {
|
||||||
let mut tick = tokio::time::interval(interval);
|
let mut tick = tokio::time::interval(interval);
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -239,14 +264,22 @@ pub async fn run_poll_loop<S: ReleaseSource>(
|
||||||
match check_release(&source, current).await {
|
match check_release(&source, current).await {
|
||||||
CheckOutcome::UpdateAvailable(tag) => {
|
CheckOutcome::UpdateAvailable(tag) => {
|
||||||
tracing::info!(%tag, current, "self-update: newer release available, applying");
|
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
|
// On success the restarter exits the process, so this only
|
||||||
// returns on failure — log it and keep polling.
|
// returns on failure — log it and keep polling.
|
||||||
if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await {
|
if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await {
|
||||||
tracing::error!("self-update: failed for {tag}: {e}");
|
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::UpToDate => {
|
||||||
CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"),
|
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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ use tokio::net::{UnixListener, UnixStream};
|
||||||
|
|
||||||
use heph_core::Store;
|
use heph_core::Store;
|
||||||
|
|
||||||
|
use crate::auth::AuthError;
|
||||||
use crate::oauth::{self, TokenStore};
|
use crate::oauth::{self, TokenStore};
|
||||||
use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR};
|
use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR};
|
||||||
use crate::selfupdate::{self, SelfUpdateConfig};
|
use crate::selfupdate::{self, SelfUpdateConfig};
|
||||||
|
|
@ -63,6 +64,12 @@ struct Ctx {
|
||||||
self_update: Option<SelfUpdateConfig>,
|
self_update: Option<SelfUpdateConfig>,
|
||||||
/// Live sync health, shared between the background loop and `sync.status`.
|
/// Live sync health, shared between the background loop and `sync.status`.
|
||||||
sync_health: Arc<Mutex<SyncHealth>>,
|
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).
|
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
|
||||||
|
|
@ -80,10 +87,25 @@ fn is_auth_error(e: &anyhow::Error) -> bool {
|
||||||
.is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
|
.is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold one exchange outcome into the shared [`SyncHealth`].
|
/// The exact `heph auth login …` command that re-authenticates this spoke, built
|
||||||
fn record_sync_outcome(health: &Arc<Mutex<SyncHealth>>, result: &Result<sync::SyncReport>) {
|
/// from the hub URL + issuer + client id the daemon is configured with — so the
|
||||||
|
/// surfaced error tells the user *what to run*, not just that auth failed.
|
||||||
|
/// `None` for an unauthenticated / standalone instance. The hub-URL string must
|
||||||
|
/// match what the credential store is keyed under, which is exactly `hub_url`.
|
||||||
|
fn reauth_command(hub_url: Option<&str>, auth: Option<&SpokeAuth>) -> Option<String> {
|
||||||
|
let (hub, auth) = (hub_url?, auth?);
|
||||||
|
Some(format!(
|
||||||
|
"heph auth login --hub-url {hub} --issuer {} --client-id {}",
|
||||||
|
auth.issuer, auth.client_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one exchange outcome into the shared [`SyncHealth`]. On an auth failure
|
||||||
|
/// (a 401 from the hub) the recorded error carries the actionable re-login
|
||||||
|
/// command, so `heph sync --status` / `heph auth status` / the TUI show the fix.
|
||||||
|
fn record_sync_outcome(ctx: &Ctx, result: &Result<sync::SyncReport>) {
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let mut h = health.lock().expect("sync_health mutex poisoned");
|
let mut h = ctx.sync_health.lock().expect("sync_health mutex poisoned");
|
||||||
h.last_attempt_ms = Some(now);
|
h.last_attempt_ms = Some(now);
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
|
@ -92,28 +114,107 @@ fn record_sync_outcome(health: &Arc<Mutex<SyncHealth>>, result: &Result<sync::Sy
|
||||||
h.auth_failure = false;
|
h.auth_failure = false;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
h.auth_failure = is_auth_error(e);
|
let auth_failure = is_auth_error(e);
|
||||||
h.last_error = Some(e.to_string());
|
h.auth_failure = auth_failure;
|
||||||
|
h.last_error = Some(annotate_reauth(
|
||||||
|
e.to_string(),
|
||||||
|
auth_failure,
|
||||||
|
ctx.hub_url.as_deref(),
|
||||||
|
ctx.auth.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failure to obtain a bearer token (the refresh step, before any hub
|
||||||
|
/// request). A *rejection* (the IdP refused the refresh) is an auth failure and
|
||||||
|
/// gets the re-login hint; a transport failure stays a transient error. Surfacing
|
||||||
|
/// this here means `last_error` reflects the real cause (e.g. `invalid_grant`)
|
||||||
|
/// instead of only the downstream 401 on `/sync/pull`.
|
||||||
|
fn record_bearer_failure(ctx: &Ctx, err: &AuthError) {
|
||||||
|
let now = now_ms();
|
||||||
|
let auth_failure = err.is_rejection();
|
||||||
|
let mut h = ctx.sync_health.lock().expect("sync_health mutex poisoned");
|
||||||
|
h.last_attempt_ms = Some(now);
|
||||||
|
h.auth_failure = auth_failure;
|
||||||
|
h.last_error = Some(annotate_reauth(
|
||||||
|
format!("could not obtain bearer token: {err}"),
|
||||||
|
auth_failure,
|
||||||
|
ctx.hub_url.as_deref(),
|
||||||
|
ctx.auth.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append the actionable re-login command to `msg` when this is an auth failure
|
||||||
|
/// and the spoke has auth configured.
|
||||||
|
fn annotate_reauth(
|
||||||
|
msg: String,
|
||||||
|
auth_failure: bool,
|
||||||
|
hub_url: Option<&str>,
|
||||||
|
auth: Option<&SpokeAuth>,
|
||||||
|
) -> String {
|
||||||
|
match reauth_command(hub_url, auth) {
|
||||||
|
Some(cmd) if auth_failure => format!("{msg} — re-authenticate: {cmd}"),
|
||||||
|
_ => msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl Ctx {
|
||||||
/// The current bearer token for hub sync (refreshing if expired), or `None`
|
/// The current bearer token for hub sync (refreshing if expired). `Ok(None)`
|
||||||
/// if this spoke has no auth configured / no usable token.
|
/// means this spoke has no auth configured / no token stored (it syncs
|
||||||
async fn bearer(&self) -> Option<String> {
|
/// unauthenticated); `Err` means token acquisition genuinely failed (the
|
||||||
let auth = self.auth.clone()?;
|
/// caller records it and skips the attempt rather than 401ing the hub).
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
async fn bearer(&self) -> Result<Option<String>, AuthError> {
|
||||||
|
let Some(auth) = self.auth.clone() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
match tokio::task::spawn_blocking(move || {
|
||||||
oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id)
|
oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id)
|
||||||
})
|
})
|
||||||
.await;
|
.await
|
||||||
match result {
|
{
|
||||||
Ok(Ok(token)) => token,
|
Ok(res) => res,
|
||||||
Ok(Err(e)) => {
|
Err(_join) => Ok(None), // the blocking task panicked; treat as no token
|
||||||
tracing::warn!("could not obtain bearer token: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,10 +242,19 @@ impl Daemon {
|
||||||
auth: None,
|
auth: None,
|
||||||
self_update: None,
|
self_update: None,
|
||||||
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
|
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).
|
/// Configure the hub this device syncs with (`sync.now` targets it).
|
||||||
pub fn with_hub(mut self, hub_url: Option<String>) -> Daemon {
|
pub fn with_hub(mut self, hub_url: Option<String>) -> Daemon {
|
||||||
self.ctx.hub_url = hub_url;
|
self.ctx.hub_url = hub_url;
|
||||||
|
|
@ -199,6 +309,7 @@ impl Daemon {
|
||||||
current = heph_core::VERSION,
|
current = heph_core::VERSION,
|
||||||
"self-update enabled"
|
"self-update enabled"
|
||||||
);
|
);
|
||||||
|
let health = self.ctx.self_update_health.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
selfupdate::run_poll_loop(
|
selfupdate::run_poll_loop(
|
||||||
source,
|
source,
|
||||||
|
|
@ -206,6 +317,7 @@ impl Daemon {
|
||||||
restarter,
|
restarter,
|
||||||
cfg.interval,
|
cfg.interval,
|
||||||
heph_core::VERSION,
|
heph_core::VERSION,
|
||||||
|
health,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
@ -218,18 +330,67 @@ impl Daemon {
|
||||||
let Some(hub) = self.ctx.hub_url.clone() else {
|
let Some(hub) = self.ctx.hub_url.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
*self
|
||||||
|
.ctx
|
||||||
|
.sync_interval_secs
|
||||||
|
.lock()
|
||||||
|
.expect("sync interval mutex poisoned") = Some(interval.as_secs());
|
||||||
let ctx = self.ctx.clone();
|
let ctx = self.ctx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(interval);
|
let mut tick = tokio::time::interval(interval);
|
||||||
|
let mut log = SyncLoopLog::default();
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
let bearer = ctx.bearer().await;
|
let bearer = match ctx.bearer().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
// Couldn't get a token — record the real cause (e.g. a
|
||||||
|
// rejected refresh) and skip; sending an unauthenticated
|
||||||
|
// request would only 401 and mask it.
|
||||||
|
record_bearer_failure(&ctx, &e);
|
||||||
|
let msg = format!("could not obtain bearer token: {e}");
|
||||||
|
if log.on_failure(&msg) {
|
||||||
|
tracing::warn!(
|
||||||
|
consecutive = log.consecutive_failures,
|
||||||
|
"background sync: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let result =
|
let result =
|
||||||
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
|
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
|
||||||
record_sync_outcome(&ctx.sync_health, &result);
|
record_sync_outcome(&ctx, &result);
|
||||||
match result {
|
match result {
|
||||||
Ok(report) => tracing::debug!(?report, "background sync"),
|
Ok(report) => {
|
||||||
Err(e) => tracing::warn!("background sync failed: {e}"),
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -321,9 +482,25 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||||
message: "no hub_url configured; this instance is standalone".into(),
|
message: "no hub_url configured; this instance is standalone".into(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let bearer = ctx.bearer().await;
|
let bearer = match ctx.bearer().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
// Token acquisition failed — record the real cause (with a re-login
|
||||||
|
// hint when it's a rejection) and surface it instead of a downstream 401.
|
||||||
|
record_bearer_failure(ctx, &e);
|
||||||
|
return Err(RpcError {
|
||||||
|
code: INTERNAL_ERROR,
|
||||||
|
message: annotate_reauth(
|
||||||
|
format!("sync failed: could not obtain bearer token: {e}"),
|
||||||
|
e.is_rejection(),
|
||||||
|
ctx.hub_url.as_deref(),
|
||||||
|
ctx.auth.as_ref(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await;
|
let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await;
|
||||||
record_sync_outcome(&ctx.sync_health, &result);
|
record_sync_outcome(ctx, &result);
|
||||||
match result {
|
match result {
|
||||||
Ok(report) => Ok(json!(report)),
|
Ok(report) => Ok(json!(report)),
|
||||||
Err(e) => Err(RpcError {
|
Err(e) => Err(RpcError {
|
||||||
|
|
@ -334,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
|
/// `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
|
/// health (last-success time / last error / auth-failure flag), the pending
|
||||||
/// merge-conflict count. A spoke that is silently failing is visible here (and,
|
/// merge-conflict count, and the daemon's runtime config (version, mode, sync
|
||||||
/// via it, in the TUI status line).
|
/// 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> {
|
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||||
// Conflict count is meaningful even on a hub / standalone instance.
|
// Conflict count is meaningful even on a hub / standalone instance.
|
||||||
let store = ctx.store.clone();
|
let store = ctx.store.clone();
|
||||||
|
|
@ -351,8 +529,35 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||||
})?
|
})?
|
||||||
.map_err(RpcError::from)?;
|
.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 {
|
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();
|
let store = ctx.store.clone();
|
||||||
|
|
@ -374,10 +579,59 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||||
.expect("sync_health mutex poisoned")
|
.expect("sync_health mutex poisoned")
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
|
// Non-secret OIDC params (issuer/client-id) + the exact re-login command, so
|
||||||
|
// `heph auth status` can show the fix without reconstructing it client-side
|
||||||
|
// (and keyed under the right hub URL — see the per-URL token-keying gotcha).
|
||||||
|
let auth = ctx.auth.as_ref().map(|a| {
|
||||||
|
json!({
|
||||||
|
"issuer": a.issuer,
|
||||||
|
"client_id": a.client_id,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"hub_url": hub_url,
|
"hub_url": hub_url,
|
||||||
"cursors": cursors,
|
"cursors": cursors,
|
||||||
"conflicts": conflicts,
|
"conflicts": conflicts,
|
||||||
"health": health,
|
"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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use axum::extract::{Query, Request, State};
|
use axum::extract::{Query, Request, State};
|
||||||
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
|
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
|
|
@ -72,6 +72,12 @@ pub struct SyncReport {
|
||||||
pub applied: usize,
|
pub applied: usize,
|
||||||
/// Ops sent to the hub.
|
/// Ops sent to the hub.
|
||||||
pub pushed: usize,
|
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
|
/// Run `f` against the locked store on the blocking pool (DB calls never run on
|
||||||
|
|
@ -261,8 +267,14 @@ async fn require_auth(
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
AuthError::Provider(_) => StatusCode::SERVICE_UNAVAILABLE,
|
// The token itself is missing/bad → tell the client it's unauthorized.
|
||||||
_ => StatusCode::UNAUTHORIZED,
|
AuthError::Missing | AuthError::Invalid(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
// We couldn't reach/process the IdP to fetch verification keys — a
|
||||||
|
// transient hub-side problem, not the client's token. Ask them to
|
||||||
|
// retry rather than claiming their token is invalid.
|
||||||
|
AuthError::Unreachable(_) | AuthError::Rejected(_) | AuthError::Other(_) => {
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Multi-tenancy seam: resolve the token's identity to the owner it may act
|
// Multi-tenancy seam: resolve the token's identity to the owner it may act
|
||||||
|
|
@ -385,13 +397,22 @@ pub async fn sync_once(
|
||||||
if let Some(token) = bearer {
|
if let Some(token) = bearer {
|
||||||
req = req.bearer_auth(token);
|
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();
|
report.pulled = pulled.ops.len();
|
||||||
if !pulled.ops.is_empty() {
|
if !pulled.ops.is_empty() {
|
||||||
let (applied, max_pulled) = with_store(&store, move |s| apply_batch(s, pulled.ops)).await?;
|
let (applied, max_pulled) = with_store(&store, move |s| apply_batch(s, pulled.ops)).await?;
|
||||||
report.applied = applied;
|
report.applied = applied;
|
||||||
if let Some(cursor) = max_pulled {
|
if let Some(cursor) = max_pulled {
|
||||||
let hub = hub_url.to_string();
|
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?;
|
with_store(&store, move |s| s.record_sync(&hub, None, Some(&cursor))).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -411,9 +432,14 @@ pub async fn sync_once(
|
||||||
if let Some(token) = bearer {
|
if let Some(token) = bearer {
|
||||||
req = req.bearer_auth(token);
|
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 {
|
if let Some(cursor) = max_pushed {
|
||||||
let hub = hub_url.to_string();
|
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?;
|
with_store(&store, move |s| s.record_sync(&hub, Some(&cursor), None)).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
crates/hephd/tests/client_reconnect.rs
Normal file
96
crates/hephd/tests/client_reconnect.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! [`Client`] survives the daemon dropping the socket (opt-in self-update, `heph
|
||||||
|
//! daemon restart`). A mock daemon serves exactly one request per connection
|
||||||
|
//! then closes it, forcing the client to reconnect — without auto-reconnect,
|
||||||
|
//! every call after the first would fail forever.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use hephd::Client;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// A mock daemon that handles ONE request per connection then closes it, looping
|
||||||
|
/// to accept the next connection. `served` counts total requests answered.
|
||||||
|
fn spawn_one_shot_daemon(socket: PathBuf, served: Arc<AtomicUsize>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
for conn in listener.incoming() {
|
||||||
|
let Ok(mut stream) = conn else { continue };
|
||||||
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||||
|
continue; // client opened then went away; wait for the next one
|
||||||
|
}
|
||||||
|
let req: Value = serde_json::from_str(&line).unwrap();
|
||||||
|
let n = served.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let mut out = serde_json::to_string(&json!({
|
||||||
|
"id": req["id"],
|
||||||
|
"result": { "served": n },
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
out.push('\n');
|
||||||
|
let _ = stream.write_all(out.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
// `stream` drops here → the connection closes after one request.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for(socket: &std::path::Path) {
|
||||||
|
for _ in 0..400 {
|
||||||
|
if socket.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
panic!("mock daemon socket never appeared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_reconnects_after_the_daemon_drops_the_socket() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let socket = dir.path().join("d.sock");
|
||||||
|
let served = Arc::new(AtomicUsize::new(0));
|
||||||
|
spawn_one_shot_daemon(socket.clone(), served.clone());
|
||||||
|
wait_for(&socket);
|
||||||
|
|
||||||
|
let mut c = Client::connect(&socket).unwrap();
|
||||||
|
|
||||||
|
// First call works on the initial connection.
|
||||||
|
let r1 = c.call("ping", json!({})).unwrap();
|
||||||
|
assert_eq!(r1["served"], 1);
|
||||||
|
|
||||||
|
// The daemon has now closed that connection. With reconnect, the client
|
||||||
|
// recovers within a call or two (depending on whether the dead socket fails
|
||||||
|
// on write or on read); without it, every further call would fail forever.
|
||||||
|
let mut recovered = None;
|
||||||
|
for _ in 0..2 {
|
||||||
|
if let Ok(v) = c.call("ping", json!({})) {
|
||||||
|
recovered = Some(v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let r = recovered.expect("client should reconnect after the socket was dropped");
|
||||||
|
// The recovered call was served exactly once on the new connection — no
|
||||||
|
// double-serve from a spurious retry.
|
||||||
|
assert_eq!(r["served"], 2);
|
||||||
|
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
// And it keeps working across subsequent drops.
|
||||||
|
let r3 = {
|
||||||
|
let mut got = None;
|
||||||
|
for _ in 0..2 {
|
||||||
|
if let Ok(v) = c.call("ping", json!({})) {
|
||||||
|
got = Some(v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got.expect("client should keep reconnecting")
|
||||||
|
};
|
||||||
|
assert_eq!(r3["served"], 3);
|
||||||
|
}
|
||||||
|
|
@ -90,11 +90,25 @@ async fn token(State(s): State<IdpState>, Form(form): Form<HashMap<String, Strin
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Some("refresh_token") => Json(json!({
|
Some("refresh_token") => {
|
||||||
|
// A rotated/expired refresh token is refused with `400 invalid_grant`
|
||||||
|
// (RFC 6749 §5.2) — the case that used to be mislabeled "unreachable".
|
||||||
|
if form.get("refresh_token").map(String::as_str) == Some("refresh-expired") {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Token is not active",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Json(json!({
|
||||||
"access_token": "access-2",
|
"access_token": "access-2",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
}))
|
}))
|
||||||
.into_response(),
|
.into_response()
|
||||||
|
}
|
||||||
_ => (
|
_ => (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(json!({ "error": "unsupported_grant_type" })),
|
Json(json!({ "error": "unsupported_grant_type" })),
|
||||||
|
|
@ -129,6 +143,48 @@ fn refresh_keeps_the_old_refresh_token_when_omitted() {
|
||||||
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-1"));
|
assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_rejected_by_idp_is_a_rejection_not_unreachable() {
|
||||||
|
let issuer = start_idp();
|
||||||
|
let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap();
|
||||||
|
let err = flow.refresh("refresh-expired").unwrap_err();
|
||||||
|
// The whole point of the fix: a reachable IdP that returns 400 is a
|
||||||
|
// *rejection*, carrying the OAuth error body — not "unreachable".
|
||||||
|
assert!(err.is_rejection(), "expected a rejection, got: {err}");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("rejected"),
|
||||||
|
"message should say rejected: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("invalid_grant"),
|
||||||
|
"should include the OAuth error: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("Token is not active"),
|
||||||
|
"should include error_description: {msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!msg.contains("unreachable"),
|
||||||
|
"must NOT claim the IdP was unreachable: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_against_a_dead_idp_is_unreachable_not_a_rejection() {
|
||||||
|
use hephd::AuthError;
|
||||||
|
// Port 1 refuses the connection → a genuine transport failure.
|
||||||
|
let err = match DeviceFlow::discover("http://127.0.0.1:1/application/o/heph/", "heph-cli") {
|
||||||
|
Ok(_) => panic!("discovery should fail against a dead IdP"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
matches!(err, AuthError::Unreachable(_)),
|
||||||
|
"a connection failure must be Unreachable, got: {err}"
|
||||||
|
);
|
||||||
|
assert!(!err.is_rejection());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_token_store_round_trips_and_reports_expiry() {
|
fn memory_token_store_round_trips_and_reports_expiry() {
|
||||||
let store = MemoryTokenStore::default();
|
let store = MemoryTokenStore::default();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// An unknown view name is a reported RPC error.
|
||||||
assert!(c.call("view", json!({ "name": "bogus" })).is_err());
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,3 +204,54 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() {
|
||||||
"B recorded no 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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
`heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config.
|
|
||||||
1
docs/changelog.d/feature-fuzz-testing.bugfix.md
Normal file
1
docs/changelog.d/feature-fuzz-testing.bugfix.md
Normal 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.
|
||||||
1
docs/changelog.d/feature-fuzz-testing.feature.md
Normal file
1
docs/changelog.d/feature-fuzz-testing.feature.md
Normal 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]].
|
||||||
1
docs/changelog.d/feature-fuzz-testing.infra.md
Normal file
1
docs/changelog.d/feature-fuzz-testing.infra.md
Normal 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
125
docs/how-to/fuzz-testing.md
Normal 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
|
||||||
|
|
@ -71,7 +71,7 @@ into preview chips before you submit:
|
||||||
|
|
||||||
| Token | Example | Effect |
|
| Token | Example | Effect |
|
||||||
|-------|---------|--------|
|
|-------|---------|--------|
|
||||||
| `p1`–`p4` | `p1` | attention: red / orange / blue / white |
|
| `a1`–`a4` | `a1` | attention band by intensity: a1=red, a2=orange, a3=white, a4=blue |
|
||||||
| `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) |
|
| `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) |
|
||||||
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
|
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
|
||||||
| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) |
|
| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) |
|
||||||
|
|
@ -96,8 +96,9 @@ platform. A server-side transcription proxy could be added later if needed.)
|
||||||
## Triage
|
## Triage
|
||||||
|
|
||||||
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
|
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
|
||||||
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`),
|
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (pick a band a1–a4,
|
||||||
**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete**
|
the TUI's `a` then a digit), **Date** (reschedule, `e`), **Move** (project
|
||||||
|
picker, `m`), **Delete**
|
||||||
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
|
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
|
||||||
task's canonical-context body + recent log tail (read-only).
|
task's canonical-context body + recent log tail (read-only).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- [[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)
|
- [[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
|
- [[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
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ still the old binary until you restart it:
|
||||||
heph daemon restart
|
heph daemon restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A restart (or an opt-in self-update) drops the daemon's unix socket out from
|
||||||
|
under any connected surface. The CLI and `heph-tui` **reconnect automatically**:
|
||||||
|
a read transparently retries on a fresh connection, and a long-running TUI
|
||||||
|
self-heals on its next tick — so a daemon restart no longer leaves the agenda
|
||||||
|
view stuck on errors. (A mutating action whose reply is lost mid-restart reports
|
||||||
|
"reconnected — re-run the action if it didn't take effect" rather than risk
|
||||||
|
applying twice.)
|
||||||
|
|
||||||
## Self-update (opt-in)
|
## Self-update (opt-in)
|
||||||
|
|
||||||
`hephd` can keep itself current: `heph daemon start --self-update` generates a
|
`hephd` can keep itself current: `heph daemon start --self-update` generates a
|
||||||
|
|
|
||||||
|
|
@ -73,23 +73,37 @@ avoid re-authenticating often, set generous validities on the heph provider:
|
||||||
|
|
||||||
## 2. Bring up the hub on `indri`
|
## 2. Bring up the hub on `indri`
|
||||||
|
|
||||||
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
|
**Seed it from `gilbert` (Path A) with `--owner-id`.** No snapshot copy: start
|
||||||
copy its store to `indri`, and give `indri` its **own device origin** so the two
|
the hub on a **fresh, empty store** that adopts `gilbert`'s `owner_id`, and the
|
||||||
replicas don't share one (see *Current gaps* — this seeding step is the bit the
|
spoke's first sync replays its entire op-log into the hub (sync is op-based, so
|
||||||
blumeops deployment finalizes). `indri` now holds `gilbert`'s data under the same
|
a hub that shares the owner rebuilds completely from ops). The hub gets its own
|
||||||
`owner_id`.
|
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;
|
Find the device's owner id on `gilbert` (any node row carries it):
|
||||||
omit both only for local dev):
|
|
||||||
|
```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
|
```bash
|
||||||
hephd --mode server \
|
hephd --mode server \
|
||||||
--http-addr 0.0.0.0:8787 \
|
--http-addr 0.0.0.0:8787 \
|
||||||
--db /var/lib/heph/heph.db \
|
--db /var/lib/heph/heph.db \
|
||||||
|
--owner-id <gilbert-owner-id> \
|
||||||
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
|
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
|
||||||
--oidc-audience <heph-client-id>
|
--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
|
The first identity to authenticate **claims** the hub's owner; thereafter only
|
||||||
that identity is served (single-owner today — see [[design]] and the
|
that identity is served (single-owner today — see [[design]] and the
|
||||||
`Adoption + multi-tenant` task for the multi-tenancy seam).
|
`Adoption + multi-tenant` task for the multi-tenancy seam).
|
||||||
|
|
@ -130,18 +144,31 @@ spoke is visible at a glance rather than buried in the daemon log.
|
||||||
|
|
||||||
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
|
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
|
||||||
|
|
||||||
## Current gaps (finalized by the blumeops deployment)
|
### When sync stops authenticating
|
||||||
|
|
||||||
The flag-level flow above works today; two enablers make it a clean, managed
|
A spoke's refresh token can expire or be rotated (e.g. the IdP session lapses).
|
||||||
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
|
The spoke then can't refresh on its own and needs a re-login — but this is
|
||||||
|
**visible, not silent**:
|
||||||
|
|
||||||
- **`heph daemon` only generates a `--mode local` service** (no `--hub-url` /
|
- `heph-tui` shows a red `⚠ auth · heph auth status` chip in the status line.
|
||||||
`--oidc-*`). So for now the hub and the spoke config are expressed as `hephd`
|
- `heph auth status` prints the auth health and the **exact** re-login command,
|
||||||
flags (run directly, or via the blumeops-managed systemd unit), not via
|
pre-filled with this spoke's hub URL / issuer / client id:
|
||||||
`heph daemon start`.
|
|
||||||
- **Path A seeding is manual** (copy the store + reset the device origin). A
|
```bash
|
||||||
small enabler — seed a hub from a snapshot with a fresh origin, or
|
heph auth status
|
||||||
`hephd --owner-id` — would make this one step.
|
```
|
||||||
|
|
||||||
|
- `heph sync --status`'s `last_error` names the real cause — a refresh
|
||||||
|
*rejection* (e.g. `HTTP 400 (invalid_grant)`), not a misleading "identity
|
||||||
|
provider unreachable" — and carries the same `heph auth login …` hint.
|
||||||
|
|
||||||
|
Run the printed `heph auth login …` command to restore sync.
|
||||||
|
|
||||||
|
> `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
|
||||||
|
> blumeops-managed systemd unit by deployment choice, not because `heph daemon`
|
||||||
|
> can't express it.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-check-links` | Validate wiki-links against existing doc filenames |
|
||||||
| `mise run docs-mikado` | Inspect active Mikado chains and resume C2 work |
|
| `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 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 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 mikado-branch-invariant-check` | Validate `mikado/*` branch commit discipline |
|
||||||
| `mise run pr-comments <pr_number>` | List unresolved PR comments |
|
| `mise run pr-comments <pr_number>` | List unresolved PR comments |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
|
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
|
||||||
// views and projects, triage tasks, and (the primary use case) capture new
|
// views and projects, triage tasks, and (the primary use case) capture new
|
||||||
// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover.
|
// tasks fast with the same quick-add syntax as the TUI's `n` / Cmd-' popover.
|
||||||
//
|
//
|
||||||
// Online-only thin client: every action is an RPC to the configured hub (see
|
// Online-only thin client: every action is an RPC to the configured hub (see
|
||||||
// rpc.js). Context/KB is read-only here (no nvim editing surface).
|
// rpc.js). Context/KB is read-only here (no nvim editing surface).
|
||||||
|
|
@ -10,11 +10,12 @@ import * as oauth from "./oauth.js";
|
||||||
import { parse as quickParse } from "./quickadd.js";
|
import { parse as quickParse } from "./quickadd.js";
|
||||||
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
||||||
import {
|
import {
|
||||||
|
ATTENTION_BANDS,
|
||||||
ATTENTION_COLORS,
|
ATTENTION_COLORS,
|
||||||
|
attentionLabel,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
hasFlag,
|
hasFlag,
|
||||||
isOverdue,
|
isOverdue,
|
||||||
nextAttention,
|
|
||||||
projectColor,
|
projectColor,
|
||||||
} from "./fmt.js";
|
} from "./fmt.js";
|
||||||
|
|
||||||
|
|
@ -231,7 +232,7 @@ function taskDetail(t) {
|
||||||
actionBtn("✓ Done", () => triage(t, "done")),
|
actionBtn("✓ Done", () => triage(t, "done")),
|
||||||
actionBtn("⤓ Drop", () => triage(t, "dropped")),
|
actionBtn("⤓ Drop", () => triage(t, "dropped")),
|
||||||
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
|
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
|
||||||
actionBtn("⚑ Attn", () => cycleAttention(t)),
|
actionBtn("⚑ Attn", () => openAttention(t)),
|
||||||
actionBtn("📅 Date", () => openReschedule(t)),
|
actionBtn("📅 Date", () => openReschedule(t)),
|
||||||
actionBtn("📁 Move", () => openMove(t)),
|
actionBtn("📁 Move", () => openMove(t)),
|
||||||
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
|
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
|
||||||
|
|
@ -353,7 +354,7 @@ function openQuickAdd() {
|
||||||
const input = h("input", {
|
const input = h("input", {
|
||||||
class: "qa-input",
|
class: "qa-input",
|
||||||
type: "text",
|
type: "text",
|
||||||
placeholder: "Buy milk tomorrow p2 #Work every week",
|
placeholder: "Buy milk tomorrow a2 #Work every week",
|
||||||
autocomplete: "off",
|
autocomplete: "off",
|
||||||
autocapitalize: "sentences",
|
autocapitalize: "sentences",
|
||||||
enterkeyhint: "done",
|
enterkeyhint: "done",
|
||||||
|
|
@ -364,12 +365,12 @@ function openQuickAdd() {
|
||||||
const parsed = quickParse(input.value, today(), state.projects);
|
const parsed = quickParse(input.value, today(), state.projects);
|
||||||
preview.innerHTML = "";
|
preview.innerHTML = "";
|
||||||
if (!input.value.trim()) {
|
if (!input.value.trim()) {
|
||||||
preview.append(h("span", { class: "qa-hint" }, "p1–p4 · #Project · today/+3d/fri · every week"));
|
preview.append(h("span", { class: "qa-hint" }, "a1–a4 · #Project · today/+3d/fri · every week"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
|
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
|
||||||
if (parsed.attention) {
|
if (parsed.attention) {
|
||||||
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention));
|
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + attentionLabel(parsed.attention)));
|
||||||
}
|
}
|
||||||
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
|
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
|
||||||
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
|
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
|
||||||
|
|
@ -610,11 +611,22 @@ async function doSkip(t) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cycleAttention(t) {
|
// Pick an attention band directly (a1–a4) rather than cycling — cycling could
|
||||||
const next = nextAttention(t.attention);
|
// skip past the band you wanted, and pushing to a4 (blue) used to drop the task
|
||||||
|
// out of the view you were on with no way back. Mirrors the TUI's `a`+digit chord.
|
||||||
|
function openAttention(t) {
|
||||||
|
const list = h("div", { class: "picker-list" });
|
||||||
|
for (const band of ATTENTION_BANDS) {
|
||||||
|
list.append(pickerItem(attentionLabel(band), () => setAttention(t, band), ATTENTION_COLORS[band]));
|
||||||
|
}
|
||||||
|
openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Attention for "${t.title}"`), list));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAttention(t, band) {
|
||||||
|
closeModal();
|
||||||
try {
|
try {
|
||||||
await state.client.setAttention(t.node_id, next);
|
await state.client.setAttention(t.node_id, band);
|
||||||
toast(`Attention: ${next}`);
|
toast(`Attention: ${attentionLabel(band)}`);
|
||||||
reload();
|
reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(`Failed: ${e.message}`);
|
toast(`Failed: ${e.message}`);
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,16 @@ export const ATTENTION_COLORS = {
|
||||||
white: "var(--att-white)",
|
white: "var(--att-white)",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The cycle order used by the attention toggle (matches the TUI's `A` key). */
|
/**
|
||||||
export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"];
|
* The attention bands a user can pick, in `a1`..`a4` order (by intensity).
|
||||||
|
* Each entry is the storage color string; the label is its index + 1.
|
||||||
|
*/
|
||||||
|
export const ATTENTION_BANDS = ["red", "orange", "white", "blue"];
|
||||||
|
|
||||||
/** Next attention in the cycle: none → white → orange → red → blue → white. */
|
/** Attention color string → its `a1`..`a4` UI label (or "" if unset). */
|
||||||
export function nextAttention(att) {
|
export function attentionLabel(att) {
|
||||||
const i = ATTENTION_CYCLE.indexOf(att ?? null);
|
const i = ATTENTION_BANDS.indexOf(att);
|
||||||
// After blue (last), wrap to white (index 1), not back to none.
|
return i < 0 ? "" : `a${i + 1}`;
|
||||||
const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length;
|
|
||||||
return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
|
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// Single-line natural-language quick-add — a faithful JS port of hephd's
|
// Single-line natural-language quick-add — a faithful JS port of hephd's
|
||||||
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
|
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
|
||||||
// `Water plants tomorrow p2 #Chores every 3 days`
|
// `Water plants tomorrow a2 #Chores every 3 days`
|
||||||
//
|
//
|
||||||
// Recognized inline tokens are extracted and the remainder is the title (order
|
// Recognized inline tokens are extracted and the remainder is the title (order
|
||||||
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
|
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
|
||||||
// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white)
|
// - Attention a1..a4 → attention band, ordered by intensity
|
||||||
|
// (a1 red, a2 orange, a3 white, a4 blue)
|
||||||
// - Project #Name → resolved against existing projects, greedily matching
|
// - Project #Name → resolved against existing projects, greedily matching
|
||||||
// multi-word titles (#Camano Chores). Unresolved #tags
|
// multi-word titles (#Camano Chores). Unresolved #tags
|
||||||
// stay in the title verbatim (no surprise project).
|
// stay in the title verbatim (no surprise project).
|
||||||
|
|
@ -13,13 +14,13 @@
|
||||||
|
|
||||||
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
|
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
|
||||||
|
|
||||||
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
|
/** a1..a4 → attention color string (matching the RPC serialization), or null. */
|
||||||
function priorityAttention(token) {
|
function attentionToken(token) {
|
||||||
switch (token.toLowerCase()) {
|
switch (token.toLowerCase()) {
|
||||||
case "p1": return "red";
|
case "a1": return "red";
|
||||||
case "p2": return "orange";
|
case "a2": return "orange";
|
||||||
case "p3": return "blue";
|
case "a3": return "white";
|
||||||
case "p4": return "white";
|
case "a4": return "blue";
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +77,7 @@ export function parse(input, todayDate, projects = []) {
|
||||||
while (i < tokens.length) {
|
while (i < tokens.length) {
|
||||||
const tok = tokens[i];
|
const tok = tokens[i];
|
||||||
|
|
||||||
const att = priorityAttention(tok);
|
const att = attentionToken(tok);
|
||||||
if (att !== null) {
|
if (att !== null) {
|
||||||
out.attention = att;
|
out.attention = att;
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Service worker: cache the app shell so heph launches offline. Data is never
|
// Service worker: cache the app shell so heph launches offline. Data is never
|
||||||
// cached — every /rpc call must hit the live hub (and POSTs aren't cacheable
|
// cached — every /rpc call must hit the live hub (and POSTs aren't cacheable
|
||||||
// anyway). Bump CACHE when shell assets change to evict the old set.
|
// anyway). Bump CACHE when shell assets change to evict the old set.
|
||||||
const CACHE = "heph-pwa-v4";
|
const CACHE = "heph-pwa-v5";
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,19 @@ test("plain title", () => {
|
||||||
assert.equal(r.projectId, null);
|
assert.equal(r.projectId, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("priority maps to attention", () => {
|
test("attention token maps to attention", () => {
|
||||||
assert.equal(p("Email boss p1").attention, "red");
|
assert.equal(p("Email boss a1").attention, "red");
|
||||||
assert.equal(p("Email boss p2").attention, "orange");
|
assert.equal(p("Email boss a2").attention, "orange");
|
||||||
assert.equal(p("Email boss p3").attention, "blue");
|
assert.equal(p("Email boss a3").attention, "white");
|
||||||
assert.equal(p("Email boss p4").attention, "white");
|
assert.equal(p("Email boss a4").attention, "blue");
|
||||||
assert.equal(p("Email boss p1").title, "Email boss");
|
assert.equal(p("Email boss a1").title, "Email boss");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("old priority tokens are no longer recognized", () => {
|
||||||
|
// p1..p4 are retired in favour of a1..a4 — they stay in the title.
|
||||||
|
const r = p("Email boss p1");
|
||||||
|
assert.equal(r.attention, null);
|
||||||
|
assert.equal(r.title, "Email boss p1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("relative date is extracted", () => {
|
test("relative date is extracted", () => {
|
||||||
|
|
@ -169,7 +176,7 @@ test("recurrence phrase is extracted", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("everything at once", () => {
|
test("everything at once", () => {
|
||||||
const r = p("Plan trip p2 friday #Work every week");
|
const r = p("Plan trip a2 friday #Work every week");
|
||||||
assert.equal(r.title, "Plan trip");
|
assert.equal(r.title, "Plan trip");
|
||||||
assert.equal(r.attention, "orange");
|
assert.equal(r.attention, "orange");
|
||||||
assert.equal(r.doDate, ms(2026, 6, 5));
|
assert.equal(r.doDate, ms(2026, 6, 5));
|
||||||
|
|
|
||||||
43
mise-tasks/fuzz
Executable file
43
mise-tasks/fuzz
Executable 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue