Compare commits

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

34 commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 10:33:59 -07:00
Forgejo Actions
19ababc57f Update changelog for v1.4.2 [skip ci] 2026-06-09 09:16:12 -07:00
2911f418a5 Merge pull request 'Attention as a1–a4: set bands directly, retire cycling' (#17) from feature/attention-a1-a4 into main
Some checks failed
Build / validate (push) Failing after 6m30s
Reviewed-on: #17
2026-06-09 09:15:32 -07:00
730863b832 feat(heph): accept a1-a4, 1-4, or colour words for -a/--attention
All checks were successful
Build / validate (pull_request) Successful in 8m24s
The CLI's attention flag (on task/list/attention/edit/promote) now takes the
a1–a4 labels, a bare digit 1–4, or a colour word, normalizing to the storage
colour before the RPC. Adds Attention::parse_input() in heph-core (lenient
human input) alongside the strict storage parse(), with a clear error listing
the accepted forms. `heph attention` now echoes the band as `a1 (red)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:29:46 -07:00
ebb2366236 feat(attention): set bands directly as a1–a4 instead of cycling
Retire the `A` attention cycle and the duplicate `b` push-to-blue gesture
in heph-tui. Attention is now picked directly: press `a` then `1`–`4`
(a1=red, a2=orange, a3=white, a4=blue, ordered by intensity). Cycling past
blue used to make a task vanish from the current view with no way back —
direct selection never does. Quick-add moves from `a` to `n`.

Surface the a1–a4 nomenclature everywhere instead of colour words or the
old p1–p4 priorities: heph-tui status/legend, the heph-quickadd chip + hint,
and the PWA chip/hint plus a new band-picker (replacing its cycle button).
The shared quick-add parser now accepts `a1`–`a4` (a1=red … a4=blue) and no
longer recognizes `p1`–`p4`. Colour mappings are unchanged; only the words.

Add Attention::ui_label() in heph-core so both Rust surfaces share the
mapping; bump the PWA service-worker cache; update the PWA how-to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:50:53 -07:00
Forgejo Actions
b34371af87 Update changelog for v1.4.1 [skip ci] 2026-06-08 20:24:38 -07:00
17dab0e281 Merge pull request 'fix(quickadd): return focus to the previous app when the ⌘' popover hides' (#16) from feature/quickadd-focus-return into main
All checks were successful
Build / validate (push) Successful in 9m38s
2026-06-08 20:22:05 -07:00
470ef1de0e fix(quickadd): return focus to the previous app when the popover hides
All checks were successful
Build / validate (pull_request) Successful in 5m52s
The global ⌘' quick-add overlay is a borderless, transparent, always-on-top
accessory window that winit hides with `Visible(false)`. That orders the window
out visually but leaves heph-quickadd the *active* application — so after a
capture (or Esc / toggle) keyboard focus never returns to the app the user was
in, and the lingering overlay can keep intercepting clicks where it used to sit.

Hide at the application level instead via `NSApplication.hide:`, which fully
orders our windows out and activates the next app in line (the previously
focused one). On re-show, `unhide:` clears that hidden flag before the existing
viewport `Focus` command makes the field key again. Both are macOS-only no-ops
elsewhere, wired through new `app_yield_focus`/`app_take_focus` helpers backed by
objc2 / objc2-app-kit (unified to the 0.6/0.3 line global-hotkey already pulls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:08:07 -07:00
aec807fd28 Merge pull request 'Reconnect the socket client across daemon restarts (heph-tui survives self-update)' (#15) from feature/client-reconnect into main
All checks were successful
Build / validate (push) Successful in 13m7s
2026-06-08 15:22:05 -07:00
b04a71421e fix(hephd): reconnect the socket client across daemon restarts
All checks were successful
Build / validate (pull_request) Successful in 8m7s
`Client` connected to the unix socket once and never reconnected, so after an
opt-in self-update or `heph daemon restart` dropped the socket, every later
`call()` failed — `heph-tui` would sit on errors until relaunched (and the work
we just shipped makes restarts more frequent).

`Client` now stores the socket path and reconnects on a dropped connection,
classifying the failure to stay safe:
- write-side failure (request never reached the daemon) → reconnect + retry once;
- reply lost after sending (daemon closed mid-request) → reconnect for next time
  but surface this one, so a mutation is never silently double-applied;
- genuine RPC errors are passed through untouched.

heph-tui and the CLI use `Client` unchanged, so the TUI self-heals on its next
refresh tick. Adds an integration test driving a mock daemon that drops the
connection after each request.

Closes the "heph-tui: reconnect on a dropped daemon socket" backlog task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:19:10 -07:00
5c2b4bde2c Relabel changelog v1.3.0 section as v1.4.0 [skip ci]
A double workflow_dispatch produced both v1.3.0 and an empty duplicate v1.4.0
(the version actually deployed via self-update). Move the release notes onto
v1.4.0 to match what shipped; v1.3.0 release+tag are being removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:35:10 -07:00
Forgejo Actions
2ca1e246f0 Update changelog for v1.3.0 [skip ci] 2026-06-08 14:15:03 -07:00
9a4f18fbd5 Merge pull request 'Auth errors: distinguish IdP rejection from unreachable + actionable re-auth recovery' (#14) from feature/auth-error-clarity into main
All checks were successful
Build / validate (push) Successful in 11m58s
2026-06-08 14:10:35 -07:00
e943a940f1 feat(hephd,heph,heph-tui): distinguish IdP rejection from unreachable + actionable re-auth
All checks were successful
Build / validate (pull_request) Successful in 6m12s
The spoke OAuth path funneled every failure into one `AuthError::Provider`
whose Display was hardcoded "identity provider unreachable". So a reachable IdP
returning `400 invalid_grant` on a refresh was reported as "unreachable",
misdirecting incident response toward the network when the fix is re-auth. The
real refresh cause was also swallowed — `bearer()` logged it and returned None,
so sync health only ever showed the downstream 401 on /sync/pull.

Wording fix (auth.rs / oauth.rs):
- Split AuthError into Unreachable (transport), Rejected (IdP returned an HTTP
  error — carries the RFC 6749 §5.2 error/error_description), and Other
  (keyring / malformed response, previously mislabeled too).
- refresh()/discover()/start()/poll() classify transport vs status; refresh
  reads the OAuth error body on a non-2xx.
- Hub-side token verify maps IdP-infra failures → 503, token failures → 401.

Recovery UX (server.rs / heph / heph-tui):
- bearer() returns Result; the sync paths record the real acquisition failure
  (with a re-login hint when it's a rejection) instead of a masked 401.
- sync health's last_error carries the exact `heph auth login --hub-url …
  --issuer … --client-id …` command (keyed to the configured hub); sync.status
  also returns issuer/client_id + the command.
- New `heph auth status` prints auth health and the re-login command.
- heph-tui's auth chip points at it: `⚠ auth · heph auth status`.

Closes the duplicate "misleading identity provider unreachable" tasks and the
"actionable re-auth guidance" task. Also corrects a now-stale set-up-sync-hub
gap note (daemon config baking landed in the prior PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:06:08 -07:00
b82264892f Merge pull request 'Fix macOS heph daemon restart bootout→bootstrap race (5: Input/output error)' (#13) from feature/daemon-restart-race into main
All checks were successful
Build / validate (push) Successful in 11m52s
2026-06-08 13:43:55 -07:00
f6b27414a8 fix(heph): make macOS heph daemon restart race-free
All checks were successful
Build / validate (pull_request) Successful in 8m39s
`restart` bootstrapped immediately after `bootout`, but `launchctl bootout` is
asynchronous: launchd may still be killing/reaping the job and removing its
label when the command returns. Bootstrapping into that transitional domain
fails with a generic `5: Input/output error`, intermittently — the odds depend
on how fast hephd (sync client + SQLite + a heph-quickadd child) shuts down.

- Wait for the label to actually clear (poll `launchctl print`, bounded) before
  re-bootstrapping, and retry the bootstrap to cover the residual settle window.
- When the plist is unchanged (the common binary-upgrade restart), use
  `launchctl kickstart -k` to restart the loaded job atomically — no
  bootout/bootstrap, no race. The full reload path is reserved for genuine
  config changes, where launchd must re-read the plist.

Start's bootstrap shares the same retry helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:38:47 -07:00
5535cc7127 Merge pull request 'heph daemon: bake mode/hub/oidc/self-update-interval into the service' (#12) from feature/daemon-self-update-interval into main
All checks were successful
Build / validate (push) Successful in 7m44s
2026-06-08 13:32:46 -07:00
75 changed files with 4156 additions and 401 deletions

View file

@ -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
View file

@ -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"

View file

@ -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"

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -109,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.
}
} }

View file

@ -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());
}
}
} }

View file

@ -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());
}
}
}
} }

View file

@ -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);
}
} }
} }

View file

@ -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;

View file

@ -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());
}
}

View file

@ -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.

View file

@ -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);
}
} }
} }

View file

@ -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(())
} }

View file

@ -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 {

View file

@ -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)

View file

@ -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)
} }
} }

View file

@ -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(())
} }

View file

@ -33,7 +33,7 @@ fn scalar_payload(t: &Task) -> serde_json::Value {
/// Bump the task node's hlc/modified_at and record a `task.set` op snapshotting /// 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` =

View file

@ -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<()>;
} }

View file

@ -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)");
}
}
} }

View file

@ -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)
);
}

View file

@ -56,3 +56,24 @@ fn export_excludes_tombstoned_nodes() {
assert!(dir.path().join(format!("doc/{}.md", keep.id)).exists()); assert!(dir.path().join(format!("doc/{}.md", 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]]"));
}

View file

@ -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();
}

View file

@ -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",
] }

View file

@ -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 { // a1a4 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 p1p4 · #project · a date · every …") egui::RichText::new("type a1a4 · #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> {

View file

@ -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.

View file

@ -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 {

View file

@ -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",

View file

@ -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

View file

@ -19,14 +19,18 @@ use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
// Task-pane gestures (the focused pane shows its own hints, §8.1). // 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(

View file

@ -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).

View file

@ -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());
}

View file

@ -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()
} }

View file

@ -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),
} }
} }

View file

@ -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}"
);
}

View file

@ -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"

View file

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

View file

@ -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());
}
}

View file

@ -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))
} }

View file

@ -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);
}
}
} }

View file

@ -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());

View file

@ -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() {

View file

@ -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:?}");
}
}
}
} }

View file

@ -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 }))
} }

View file

@ -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)?)

View file

@ -32,6 +32,29 @@ impl SelfUpdateConfig {
} }
} }
/// Observed poller state, shared with the daemon so `sync.status` (and through
/// it `heph daemon status`) can report what self-update last did instead of
/// only logging it. All times epoch ms; `None` means "no check yet".
#[derive(Clone, Default, serde::Serialize)]
pub struct SelfUpdateHealth {
/// When the last release check completed.
pub last_check_ms: Option<i64>,
/// Human-readable outcome of that check.
pub last_outcome: Option<String>,
}
impl SelfUpdateHealth {
fn record(health: &std::sync::Mutex<Self>, outcome: String) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let mut h = health.lock().expect("self-update health mutex poisoned");
h.last_check_ms = Some(now);
h.last_outcome = Some(outcome);
}
}
/// The forge releases feed for this project — the latest tagged release. The /// 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}"));
}
} }
} }
} }

View file

@ -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"));
}
}

View file

@ -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?;
} }
} }

View 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);
}

View file

@ -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();

View file

@ -658,3 +658,88 @@ fn list_takes_a_filter_and_view_runs_a_builtin_over_socket() {
// An unknown view name is a reported RPC error. // 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);
}

View file

@ -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");
}

View file

@ -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.

View file

@ -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.

View file

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

View file

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

View file

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

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

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

View file

@ -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 a1a4,
**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).

View file

@ -23,3 +23,4 @@ Task-oriented guides for common operations.
- [[self-update]] — Opt-in `hephd` self-update: poll the forge for new releases and auto-update - [[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

View file

@ -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

View file

@ -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

View file

@ -58,6 +58,7 @@ Technical reference material for the repository tooling that ships with this pro
| `mise run docs-check-links` | Validate wiki-links against existing doc filenames | | `mise run docs-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 |

View file

@ -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" }, "p1p4 · #Project · today/+3d/fri · every week")); preview.append(h("span", { class: "qa-hint" }, "a1a4 · #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 (a1a4) 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}`);

View file

@ -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). */

View file

@ -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;

View file

@ -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",

View file

@ -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
View file

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