generated from eblume/project-template
Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34371af87 | ||
| 17dab0e281 | |||
| 470ef1de0e | |||
| aec807fd28 | |||
| b04a71421e | |||
| 5c2b4bde2c | |||
|
|
2ca1e246f0 | ||
| 9a4f18fbd5 | |||
| e943a940f1 | |||
| b82264892f | |||
| f6b27414a8 | |||
| 5535cc7127 | |||
| 626c796e6c | |||
| c9bb2cbe64 | |||
|
|
1a8752f124 | ||
| 02a8dd5180 | |||
| 11aa25c9f4 | |||
|
|
4bf255b211 | ||
| b2ddb41a46 | |||
| 9a487cbe3b | |||
| 00da36c637 | |||
|
|
c8512b2b50 | ||
| 36bd27226f | |||
| 1f81a2e6d9 | |||
| a0be0f1085 |
42 changed files with 2942 additions and 253 deletions
51
CHANGELOG.md
51
CHANGELOG.md
|
|
@ -12,6 +12,57 @@ 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.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
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it.
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.2.2] - 2026-06-06
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Recurring tasks now show their schedule in plain language (`every other week`, `weekdays`, `yearly on Apr 15`) instead of a raw RRULE — in both the TUI detail pane and the mobile PWA. The TUI's project sidebar gained subproject indentation, per-project outstanding-task counts, a wider pane, and scrolling when the list overflows.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- New explanation card [[hub-spoke-data-evolution]] covering why heph's op-based sync lets most new features ship without a coordinated migration, and the narrow case (a new required SQLite column) that does need a hub-first rollout.
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.2.1] - 2026-06-05
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider.
|
||||||
|
|
||||||
|
|
||||||
## [v1.2.0] - 2026-06-04
|
## [v1.2.0] - 2026-06-04
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -2196,7 +2196,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heph"
|
name = "heph"
|
||||||
version = "1.2.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -2210,7 +2210,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heph-core"
|
name = "heph-core"
|
||||||
version = "1.2.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
|
@ -2227,7 +2227,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heph-quickadd"
|
name = "heph-quickadd"
|
||||||
version = "1.2.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -2237,13 +2237,15 @@ 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",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heph-tui"
|
name = "heph-tui"
|
||||||
version = "1.2.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -2259,7 +2261,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hephd"
|
name = "hephd"
|
||||||
version = "1.2.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"apple-native-keyring-store",
|
"apple-native-keyring-store",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ members = [
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.2.0"
|
version = "0.0.0"
|
||||||
license = "LicenseRef-Proprietary"
|
license = "LicenseRef-Proprietary"
|
||||||
publish = false
|
publish = false
|
||||||
authors = ["Erich Blume <blume.erich@gmail.com>"]
|
authors = ["Erich Blume <blume.erich@gmail.com>"]
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS};
|
||||||
pub use hlc::{Hlc, HlcClock};
|
pub use hlc::{Hlc, HlcClock};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node,
|
deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node,
|
||||||
NodeKind, SchedulePatch, SyncCursors, Task, TaskState,
|
NodeKind, ProjectOverview, SchedulePatch, SyncCursors, Task, TaskState,
|
||||||
};
|
};
|
||||||
pub use oplog::Op;
|
pub use oplog::Op;
|
||||||
pub use ranking::{rank, Dimension, RankedTask, RANKING};
|
pub use ranking::{rank, Dimension, RankedTask, RANKING};
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,24 @@ pub struct Health {
|
||||||
pub sync_status: String,
|
pub sync_status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A project plus the two facts a sidebar needs to render it as a counted,
|
||||||
|
/// indented tree (§8.1): its parent project (via a `parent` link, if any) and
|
||||||
|
/// the number of outstanding tasks filed **directly** under it. Pure read-side —
|
||||||
|
/// both derive from existing data, so this carries no schema or sync change (see
|
||||||
|
/// [[hub-spoke-data-evolution]]).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectOverview {
|
||||||
|
/// The project node id.
|
||||||
|
pub id: String,
|
||||||
|
/// The project's title.
|
||||||
|
pub title: String,
|
||||||
|
/// The parent project's node id, or `None` for a top-level project.
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
/// Outstanding tasks filed directly in this project (children counted under
|
||||||
|
/// their own row, not summed here).
|
||||||
|
pub outstanding: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec
|
/// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec
|
||||||
/// §12). The winning value is already in the store; this records what was
|
/// §12). The winning value is already in the store; this records what was
|
||||||
/// dropped so `heph conflicts` can show and settle it.
|
/// dropped so `heph conflicts` can show and settle it.
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ use crate::error::{Error, Result};
|
||||||
use crate::filter::ListFilter;
|
use crate::filter::ListFilter;
|
||||||
use crate::hlc::Hlc;
|
use crate::hlc::Hlc;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
|
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
|
||||||
SyncCursors, Task, TaskState,
|
SchedulePatch, SyncCursors, Task, TaskState,
|
||||||
};
|
};
|
||||||
use crate::oplog::Op;
|
use crate::oplog::Op;
|
||||||
use crate::ranking::RankedTask;
|
use crate::ranking::RankedTask;
|
||||||
|
|
@ -297,6 +297,10 @@ impl Store for LocalStore {
|
||||||
tasks::health(&self.conn, &self.owner_id)
|
tasks::health(&self.conn, &self.owner_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project_overview(&self) -> Result<Vec<ProjectOverview>> {
|
||||||
|
tasks::project_overview(&self.conn, &self.owner_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||||
nodes::search(&self.conn, &self.owner_id, query)
|
nodes::search(&self.conn, &self.owner_id, query)
|
||||||
}
|
}
|
||||||
|
|
@ -498,6 +502,67 @@ mod tests {
|
||||||
assert!(store.project_scope("Nope").is_err());
|
assert!(store.project_scope("Nope").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_overview_carries_parent_and_direct_outstanding_count() {
|
||||||
|
use crate::model::{LinkType, NewNode, NewTask, NodeKind, TaskState};
|
||||||
|
let mut store = store_at(1);
|
||||||
|
let mk_proj = |store: &mut LocalStore, title: &str| {
|
||||||
|
store
|
||||||
|
.create_node(NewNode {
|
||||||
|
kind: NodeKind::Project,
|
||||||
|
title: title.into(),
|
||||||
|
body: None,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
};
|
||||||
|
let mk_task = |store: &mut LocalStore, title: &str, project: Option<&str>| {
|
||||||
|
store
|
||||||
|
.create_task(NewTask {
|
||||||
|
title: title.into(),
|
||||||
|
attention: None,
|
||||||
|
do_date: None,
|
||||||
|
late_on: None,
|
||||||
|
recurrence: None,
|
||||||
|
project_id: project.map(String::from),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.node_id
|
||||||
|
};
|
||||||
|
|
||||||
|
let work = mk_proj(&mut store, "Work");
|
||||||
|
let sub = mk_proj(&mut store, "Work Sub");
|
||||||
|
mk_proj(&mut store, "Garden");
|
||||||
|
// Work Sub is a child of Work (child holds the `parent` link → parent).
|
||||||
|
store.add_link(&sub, &work, LinkType::Parent).unwrap();
|
||||||
|
|
||||||
|
// Two outstanding + one done in Work; one outstanding in the subproject.
|
||||||
|
mk_task(&mut store, "ship", Some(&work));
|
||||||
|
mk_task(&mut store, "review", Some(&work));
|
||||||
|
let done = mk_task(&mut store, "archived", Some(&work));
|
||||||
|
store.set_task_state(&done, TaskState::Done).unwrap();
|
||||||
|
mk_task(&mut store, "nested", Some(&sub));
|
||||||
|
// An unfiled task counts toward no project.
|
||||||
|
mk_task(&mut store, "loose", None);
|
||||||
|
|
||||||
|
let overview = store.project_overview().unwrap();
|
||||||
|
// Title-sorted.
|
||||||
|
let titles: Vec<_> = overview.iter().map(|p| p.title.as_str()).collect();
|
||||||
|
assert_eq!(titles, ["Garden", "Work", "Work Sub"]);
|
||||||
|
|
||||||
|
let by_title = |t: &str| overview.iter().find(|p| p.title == t).unwrap();
|
||||||
|
assert_eq!(by_title("Work").outstanding, 2, "done task excluded");
|
||||||
|
assert_eq!(by_title("Work").parent_id, None);
|
||||||
|
assert_eq!(
|
||||||
|
by_title("Work Sub").outstanding,
|
||||||
|
1,
|
||||||
|
"direct only, not summed"
|
||||||
|
);
|
||||||
|
assert_eq!(by_title("Work Sub").parent_id, Some(work.clone()));
|
||||||
|
assert_eq!(by_title("Garden").outstanding, 0);
|
||||||
|
assert_eq!(by_title("Garden").parent_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_project_is_fuzzy_only_when_unambiguous() {
|
fn resolve_project_is_fuzzy_only_when_unambiguous() {
|
||||||
use crate::model::{NewNode, NodeKind};
|
use crate::model::{NewNode, NodeKind};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
//! A committed task is a `task` node plus a `tasks` row. On creation it also
|
//! A committed task is a `task` node plus a `tasks` row. On creation it also
|
||||||
//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
|
//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::{Connection, OptionalExtension, Row};
|
use rusqlite::{Connection, OptionalExtension, Row};
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -12,7 +14,7 @@ use crate::error::{Error, Result};
|
||||||
use crate::extract;
|
use crate::extract;
|
||||||
use crate::filter::ListFilter;
|
use crate::filter::ListFilter;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState,
|
Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState,
|
||||||
};
|
};
|
||||||
use crate::oplog::op_type;
|
use crate::oplog::op_type;
|
||||||
use crate::ranking::{self, RankedTask};
|
use crate::ranking::{self, RankedTask};
|
||||||
|
|
@ -482,6 +484,57 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every project (owner-scoped, non-tombstoned) with its parent project (via a
|
||||||
|
/// `parent` link) and its direct outstanding-task count — the shape a sidebar
|
||||||
|
/// renders as a counted, indented tree (§8.1). Pure read-side: counts come from
|
||||||
|
/// a single `GROUP BY` over the `in-project` links, parents from the `parent`
|
||||||
|
/// links, both already in the store. Title-sorted for a stable sibling order.
|
||||||
|
pub(super) fn project_overview(conn: &Connection, owner: &str) -> Result<Vec<ProjectOverview>> {
|
||||||
|
// Direct outstanding count per project: each task's project is its first
|
||||||
|
// `in-project` link target (mirrors `list`/`load_candidates`).
|
||||||
|
let mut count_stmt = conn.prepare(
|
||||||
|
"SELECT (SELECT dst_id FROM links
|
||||||
|
WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0
|
||||||
|
ORDER BY created_at, id LIMIT 1) AS project_id,
|
||||||
|
COUNT(*)
|
||||||
|
FROM nodes n JOIN tasks t ON t.node_id = n.id
|
||||||
|
WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'
|
||||||
|
GROUP BY project_id",
|
||||||
|
)?;
|
||||||
|
let mut counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
let rows = count_stmt.query_map([owner], |r| {
|
||||||
|
Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
|
||||||
|
})?;
|
||||||
|
for row in rows {
|
||||||
|
let (project_id, count) = row?;
|
||||||
|
if let Some(pid) = project_id {
|
||||||
|
counts.insert(pid, count as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent of each project: the dst of its (first) `parent` link.
|
||||||
|
let mut parent_stmt = conn.prepare(
|
||||||
|
"SELECT dst_id FROM links
|
||||||
|
WHERE src_id = ?1 AND type = 'parent' AND tombstoned = 0
|
||||||
|
ORDER BY created_at, id LIMIT 1",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for node in nodes::list(conn, owner, Some(NodeKind::Project))? {
|
||||||
|
let parent_id = parent_stmt
|
||||||
|
.query_row([&node.id], |r| r.get::<_, String>(0))
|
||||||
|
.optional()?;
|
||||||
|
out.push(ProjectOverview {
|
||||||
|
outstanding: counts.get(&node.id).copied().unwrap_or(0),
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
parent_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.title.cmp(&b.title));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
|
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
|
||||||
/// joining in its project and canonical-context link targets.
|
/// joining in its project and canonical-context link targets.
|
||||||
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {
|
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::filter::ListFilter;
|
use crate::filter::ListFilter;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
|
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
|
||||||
SyncCursors, Task, TaskState,
|
SchedulePatch, SyncCursors, Task, TaskState,
|
||||||
};
|
};
|
||||||
use crate::oplog::Op;
|
use crate::oplog::Op;
|
||||||
use crate::ranking::RankedTask;
|
use crate::ranking::RankedTask;
|
||||||
|
|
@ -142,6 +142,12 @@ pub trait Store {
|
||||||
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
|
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
|
||||||
fn health(&self) -> Result<Health>;
|
fn health(&self) -> Result<Health>;
|
||||||
|
|
||||||
|
/// Every project with its parent (via a `parent` link) and its direct
|
||||||
|
/// outstanding-task count — the shape a sidebar renders as a counted,
|
||||||
|
/// indented tree (§8.1). Read-only over existing data; no schema or sync
|
||||||
|
/// change (see [[hub-spoke-data-evolution]]).
|
||||||
|
fn project_overview(&self) -> Result<Vec<ProjectOverview>>;
|
||||||
|
|
||||||
/// Full-text search over title + body (FTS5), owner-scoped, best-match
|
/// Full-text search over title + body (FTS5), owner-scoped, best-match
|
||||||
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
|
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
|
||||||
fn search(&self, query: &str) -> Result<Vec<Node>>;
|
fn search(&self, query: &str) -> Result<Vec<Node>>;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
] }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -596,6 +606,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> {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
|
use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
|
||||||
|
|
||||||
use crate::backend::{Backend, Project, SearchHit};
|
use crate::backend::{Backend, Project, SearchHit, SyncStatus};
|
||||||
use crate::fmt::{days_overdue, today_local};
|
use crate::fmt::{days_overdue, today_local};
|
||||||
|
|
||||||
/// How the task list is ordered (toggled in the UI, §8.1).
|
/// How the task list is ordered (toggled in the UI, §8.1).
|
||||||
|
|
@ -313,8 +313,18 @@ pub enum Focus {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SidebarEntry {
|
pub enum SidebarEntry {
|
||||||
Header(String),
|
Header(String),
|
||||||
View { name: String, title: String },
|
View {
|
||||||
Project { id: String, title: String },
|
name: String,
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
|
/// A project row. `depth` is its nesting level (0 = top-level) for indent;
|
||||||
|
/// `count` is its direct outstanding-task count, shown as a trailing chip.
|
||||||
|
Project {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
depth: u16,
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SidebarEntry {
|
impl SidebarEntry {
|
||||||
|
|
@ -323,6 +333,70 @@ impl SidebarEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn the daemon's flat (title-sorted) project overview into sidebar rows in
|
||||||
|
/// tree order — each project followed by its descendants, carrying the nesting
|
||||||
|
/// `depth` and outstanding `count` the renderer needs.
|
||||||
|
fn project_entries(overview: Vec<ProjectOverview>) -> Vec<SidebarEntry> {
|
||||||
|
let order = order_projects(&overview);
|
||||||
|
let mut overview: Vec<Option<ProjectOverview>> = overview.into_iter().map(Some).collect();
|
||||||
|
order
|
||||||
|
.into_iter()
|
||||||
|
.map(|(i, depth)| {
|
||||||
|
let p = overview[i].take().expect("each index visited once");
|
||||||
|
SidebarEntry::Project {
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
depth,
|
||||||
|
count: p.outstanding,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-first display order over the project forest: returns `(index, depth)`
|
||||||
|
/// pairs, each project ahead of its children, siblings in the input's title
|
||||||
|
/// order. A project whose parent is missing (tombstoned, or not in the set)
|
||||||
|
/// renders at the top level; cycles can't loop (each node is emitted once).
|
||||||
|
fn order_projects(overview: &[ProjectOverview]) -> Vec<(usize, u16)> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let ids: HashSet<&str> = overview.iter().map(|p| p.id.as_str()).collect();
|
||||||
|
let mut children: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||||
|
let mut roots: Vec<usize> = Vec::new();
|
||||||
|
for (i, p) in overview.iter().enumerate() {
|
||||||
|
match &p.parent_id {
|
||||||
|
Some(pid) if ids.contains(pid.as_str()) => {
|
||||||
|
children.entry(pid.as_str()).or_default().push(i);
|
||||||
|
}
|
||||||
|
_ => roots.push(i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(overview.len());
|
||||||
|
let mut visited = vec![false; overview.len()];
|
||||||
|
// Stack of (index, depth); push siblings reversed so we pop in title order.
|
||||||
|
let mut stack: Vec<(usize, u16)> = roots.iter().rev().map(|&i| (i, 0)).collect();
|
||||||
|
while let Some((i, depth)) = stack.pop() {
|
||||||
|
if visited[i] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited[i] = true;
|
||||||
|
out.push((i, depth));
|
||||||
|
if let Some(kids) = children.get(overview[i].id.as_str()) {
|
||||||
|
for &k in kids.iter().rev() {
|
||||||
|
if !visited[k] {
|
||||||
|
stack.push((k, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defensive: any node trapped in a parent-cycle still gets one top-level row.
|
||||||
|
for (i, seen) in visited.iter().enumerate() {
|
||||||
|
if !seen {
|
||||||
|
out.push((i, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// The selected sidebar source, as owned values (so reloads don't hold a borrow
|
/// The selected sidebar source, as owned values (so reloads don't hold a borrow
|
||||||
/// on `self.sidebar` while calling the backend).
|
/// on `self.sidebar` while calling the backend).
|
||||||
enum Target {
|
enum Target {
|
||||||
|
|
@ -359,6 +433,8 @@ pub struct App<B: Backend> {
|
||||||
undo_stack: Vec<UndoEntry>,
|
undo_stack: Vec<UndoEntry>,
|
||||||
redo_stack: Vec<UndoEntry>,
|
redo_stack: Vec<UndoEntry>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
/// Latest sync health for the status-line indicator (refreshed on a tick).
|
||||||
|
pub sync: SyncStatus,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,9 +452,7 @@ impl<B: Backend> App<B> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sidebar.push(SidebarEntry::Header("Projects".into()));
|
sidebar.push(SidebarEntry::Header("Projects".into()));
|
||||||
for Project { id, title } in backend.projects()? {
|
sidebar.extend(project_entries(backend.project_overview()?));
|
||||||
sidebar.push(SidebarEntry::Project { id, title });
|
|
||||||
}
|
|
||||||
|
|
||||||
let sidebar_cursor = sidebar
|
let sidebar_cursor = sidebar
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -400,12 +474,23 @@ impl<B: Backend> App<B> {
|
||||||
undo_stack: Vec::new(),
|
undo_stack: Vec::new(),
|
||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
sync: SyncStatus::default(),
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
};
|
};
|
||||||
app.reload();
|
app.reload();
|
||||||
|
app.refresh_sync();
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh the sync-health snapshot for the status line. Best-effort: a
|
||||||
|
/// failed read leaves the previous snapshot in place (a stale indicator
|
||||||
|
/// beats a flicker), so this never disrupts navigation.
|
||||||
|
pub fn refresh_sync(&mut self) {
|
||||||
|
if let Ok(status) = self.backend.sync_status() {
|
||||||
|
self.sync = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The title shown above the task list (the selected source).
|
/// The title shown above the task list (the selected source).
|
||||||
pub fn task_pane_title(&self) -> String {
|
pub fn task_pane_title(&self) -> String {
|
||||||
match self.sidebar.get(self.sidebar_cursor) {
|
match self.sidebar.get(self.sidebar_cursor) {
|
||||||
|
|
@ -423,7 +508,7 @@ impl<B: Backend> App<B> {
|
||||||
/// The title of a project node id, resolved from the sidebar.
|
/// The title of a project node id, resolved from the sidebar.
|
||||||
pub fn project_name(&self, id: &str) -> Option<String> {
|
pub fn project_name(&self, id: &str) -> Option<String> {
|
||||||
self.sidebar.iter().find_map(|e| match e {
|
self.sidebar.iter().find_map(|e| match e {
|
||||||
SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()),
|
SidebarEntry::Project { id: pid, title, .. } if pid == id => Some(title.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -469,7 +554,7 @@ impl<B: Backend> App<B> {
|
||||||
self.sidebar
|
self.sidebar
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|e| match e {
|
||||||
SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())),
|
SidebarEntry::Project { id, title, .. } => Some((id.clone(), title.clone())),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -742,7 +827,7 @@ impl<B: Backend> App<B> {
|
||||||
/// become unfiled (they move to the Inbox), not deleted.
|
/// become unfiled (they move to the Inbox), not deleted.
|
||||||
pub fn begin_delete_project(&mut self) {
|
pub fn begin_delete_project(&mut self) {
|
||||||
match self.sidebar.get(self.sidebar_cursor) {
|
match self.sidebar.get(self.sidebar_cursor) {
|
||||||
Some(SidebarEntry::Project { id, title }) => {
|
Some(SidebarEntry::Project { id, title, .. }) => {
|
||||||
self.pending_delete = Some(PendingDelete::Project {
|
self.pending_delete = Some(PendingDelete::Project {
|
||||||
project_id: id.clone(),
|
project_id: id.clone(),
|
||||||
title: title.clone(),
|
title: title.clone(),
|
||||||
|
|
@ -881,10 +966,8 @@ impl<B: Backend> App<B> {
|
||||||
.filter(|e| !matches!(e, SidebarEntry::Project { .. }))
|
.filter(|e| !matches!(e, SidebarEntry::Project { .. }))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
if let Ok(projects) = self.backend.projects() {
|
if let Ok(overview) = self.backend.project_overview() {
|
||||||
for Project { id, title } in projects {
|
rebuilt.extend(project_entries(overview));
|
||||||
rebuilt.push(SidebarEntry::Project { id, title });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.sidebar = rebuilt;
|
self.sidebar = rebuilt;
|
||||||
// Restore the cursor: same entry if present, else the nearest selectable
|
// Restore the cursor: same entry if present, else the nearest selectable
|
||||||
|
|
@ -923,7 +1006,7 @@ impl<B: Backend> App<B> {
|
||||||
self.sidebar
|
self.sidebar
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|e| match e {
|
||||||
SidebarEntry::Project { id, title } => Some(Project {
|
SidebarEntry::Project { id, title, .. } => Some(Project {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
title: title.clone(),
|
title: title.clone(),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1213,4 +1296,67 @@ mod sort_tests {
|
||||||
// Alpha group (red before blue), then Beta, then project-less tasks last.
|
// Alpha group (red before blue), then Beta, then project-less tasks last.
|
||||||
assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]);
|
assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn po(id: &str, title: &str, parent: Option<&str>, outstanding: usize) -> ProjectOverview {
|
||||||
|
ProjectOverview {
|
||||||
|
id: id.into(),
|
||||||
|
title: title.into(),
|
||||||
|
parent_id: parent.map(str::to_string),
|
||||||
|
outstanding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_entries_nest_children_under_parents_with_depth_and_count() {
|
||||||
|
// Input arrives title-sorted from the daemon.
|
||||||
|
let overview = vec![
|
||||||
|
po("g", "Garden", None, 0),
|
||||||
|
po("w", "Work", None, 2),
|
||||||
|
po("ws", "Work Sub", Some("w"), 1),
|
||||||
|
po("wsx", "Work Sub Sub", Some("ws"), 5),
|
||||||
|
];
|
||||||
|
let rows: Vec<(String, u16, usize)> = project_entries(overview)
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| match e {
|
||||||
|
SidebarEntry::Project {
|
||||||
|
title,
|
||||||
|
depth,
|
||||||
|
count,
|
||||||
|
..
|
||||||
|
} => (title, depth, count),
|
||||||
|
_ => unreachable!("project_entries yields only Project rows"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![
|
||||||
|
("Garden".into(), 0, 0),
|
||||||
|
("Work".into(), 0, 2),
|
||||||
|
("Work Sub".into(), 1, 1),
|
||||||
|
("Work Sub Sub".into(), 2, 5),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_entries_treat_a_missing_parent_as_top_level() {
|
||||||
|
// A child whose parent isn't in the set (e.g. tombstoned) still shows.
|
||||||
|
let overview = vec![po("orphan", "Orphan", Some("gone"), 3)];
|
||||||
|
let rows = project_entries(overview);
|
||||||
|
assert!(matches!(
|
||||||
|
rows.as_slice(),
|
||||||
|
[SidebarEntry::Project {
|
||||||
|
depth: 0,
|
||||||
|
count: 3,
|
||||||
|
..
|
||||||
|
}]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_projects_does_not_loop_on_a_parent_cycle() {
|
||||||
|
// a→b→a is pathological but must still terminate, each row once.
|
||||||
|
let overview = vec![po("a", "A", Some("b"), 0), po("b", "B", Some("a"), 0)];
|
||||||
|
assert_eq!(order_projects(&overview).len(), 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,55 @@ pub struct SearchHit {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync health for the status line (the `sync.status` RPC). On a standalone
|
||||||
|
/// instance `hub_url` is `None` and `health` is absent; the conflict count is
|
||||||
|
/// always present.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
pub struct SyncStatus {
|
||||||
|
/// The hub this device syncs with, or `None` if standalone (no indicator).
|
||||||
|
pub hub_url: Option<String>,
|
||||||
|
/// Pending merge conflicts awaiting resolution.
|
||||||
|
#[serde(default)]
|
||||||
|
pub conflicts: usize,
|
||||||
|
/// Observed health of the background sync loop (spoke only).
|
||||||
|
#[serde(default)]
|
||||||
|
pub health: Option<SyncHealth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spoke's observed sync health (mirrors `hephd`'s `SyncHealth`).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
pub struct SyncHealth {
|
||||||
|
/// Epoch ms of the last successful exchange ("last synced"), if any.
|
||||||
|
pub last_success_ms: Option<i64>,
|
||||||
|
/// Epoch ms of the last attempt (success or failure), if any.
|
||||||
|
pub last_attempt_ms: Option<i64>,
|
||||||
|
/// The last error message, cleared on the next success.
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// Whether the most recent attempt failed authentication (needs re-login).
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_failure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the agenda surface asks of the daemon.
|
/// Everything the agenda surface asks of the daemon.
|
||||||
pub trait Backend {
|
pub trait Backend {
|
||||||
/// All project nodes (for the sidebar), title-sorted.
|
/// All project nodes (for the sidebar), title-sorted.
|
||||||
fn projects(&mut self) -> Result<Vec<Project>>;
|
fn projects(&mut self) -> Result<Vec<Project>>;
|
||||||
|
/// Projects enriched with parent + direct outstanding-task count, for the
|
||||||
|
/// indented, counted sidebar tree (§8.1). The default derives a flat list
|
||||||
|
/// from [`projects`](Self::projects); the real backend forwards the
|
||||||
|
/// dedicated `project.overview` RPC.
|
||||||
|
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||||
|
Ok(self
|
||||||
|
.projects()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| heph_core::ProjectOverview {
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
parent_id: None,
|
||||||
|
outstanding: 0,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
|
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
|
||||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
|
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
|
||||||
/// Run a raw [`ListFilter`] (used for per-project scope).
|
/// Run a raw [`ListFilter`] (used for per-project scope).
|
||||||
|
|
@ -40,6 +85,11 @@ pub trait Backend {
|
||||||
/// A task's canonical-context doc id (where its description/checklist live),
|
/// A task's canonical-context doc id (where its description/checklist live),
|
||||||
/// for opening a task search-hit at the useful node. `None` if it has none.
|
/// for opening a task search-hit at the useful node. `None` if it has none.
|
||||||
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
|
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
|
||||||
|
/// Sync health for the status line. The default is a standalone instance
|
||||||
|
/// (no hub, no conflicts); the real backend forwards `sync.status`.
|
||||||
|
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||||
|
Ok(SyncStatus::default())
|
||||||
|
}
|
||||||
|
|
||||||
// --- triage mutations (T2) ---
|
// --- triage mutations (T2) ---
|
||||||
|
|
||||||
|
|
@ -103,6 +153,11 @@ impl Backend for ClientBackend {
|
||||||
Ok(projects)
|
Ok(projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||||
|
let v = self.call("project.overview", json!({}))?;
|
||||||
|
Ok(serde_json::from_value(v)?)
|
||||||
|
}
|
||||||
|
|
||||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
||||||
let v = self.call("view", json!({ "name": name }))?;
|
let v = self.call("view", json!({ "name": name }))?;
|
||||||
Ok(serde_json::from_value(v)?)
|
Ok(serde_json::from_value(v)?)
|
||||||
|
|
@ -149,6 +204,11 @@ impl Backend for ClientBackend {
|
||||||
.map(|l| l.dst_id))
|
.map(|l| l.dst_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||||
|
let v = self.call("sync.status", json!({}))?;
|
||||||
|
Ok(serde_json::from_value(v)?)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
||||||
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,29 @@ pub fn today_local() -> NaiveDate {
|
||||||
Local::now().date_naive()
|
Local::now().date_naive()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Now, in epoch milliseconds (the reference for [`fmt_age`]).
|
||||||
|
pub fn now_ms() -> i64 {
|
||||||
|
Local::now().timestamp_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact "how long ago" for the sync indicator: `Ns` under a minute, then
|
||||||
|
/// `Nm` / `Nh` / `Nd`. Second-granularity under a minute makes the chip a visible
|
||||||
|
/// heartbeat (the sync loop runs every 30s) and surfaces a missed beat as the age
|
||||||
|
/// climbing, rather than hiding under a flat "just now". Clamped at zero so a
|
||||||
|
/// little clock skew never shows a negative age.
|
||||||
|
pub fn fmt_age(now_ms: i64, then_ms: i64) -> String {
|
||||||
|
let secs = (now_ms - then_ms).max(0) / 1000;
|
||||||
|
if secs < 60 {
|
||||||
|
format!("{secs}s")
|
||||||
|
} else if secs < 3_600 {
|
||||||
|
format!("{}m", secs / 60)
|
||||||
|
} else if secs < 86_400 {
|
||||||
|
format!("{}h", secs / 3_600)
|
||||||
|
} else {
|
||||||
|
format!("{}d", secs / 86_400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
|
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
|
||||||
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
|
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
|
||||||
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
||||||
|
|
@ -102,6 +125,19 @@ mod tests {
|
||||||
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn age_is_compact_and_clamped() {
|
||||||
|
let now = 1_000_000_000_000;
|
||||||
|
assert_eq!(fmt_age(now, now), "0s");
|
||||||
|
assert_eq!(fmt_age(now, now - 30_000), "30s");
|
||||||
|
assert_eq!(fmt_age(now, now - 59_000), "59s");
|
||||||
|
assert_eq!(fmt_age(now, now - 5 * 60_000), "5m");
|
||||||
|
assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h");
|
||||||
|
assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d");
|
||||||
|
// Clock skew (then in the future) never shows a negative age.
|
||||||
|
assert_eq!(fmt_age(now, now + 10_000), "0s");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
||||||
assert_eq!(project_color(None), Color::DarkGray);
|
assert_eq!(project_color(None), Color::DarkGray);
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,22 @@ fn run<B: heph_tui::Backend>(
|
||||||
mut app: App<B>,
|
mut app: App<B>,
|
||||||
socket: &std::path::Path,
|
socket: &std::path::Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// Poll with a timeout so the sync indicator's age advances and a sync
|
||||||
|
// failure surfaces within a couple of seconds even while the user is idle.
|
||||||
|
let tick = std::time::Duration::from_secs(2);
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| ui::render(f, &app))?;
|
terminal.draw(|f| ui::render(f, &app))?;
|
||||||
if let Event::Key(key) = event::read()? {
|
if event::poll(tick)? {
|
||||||
if key.kind == KeyEventKind::Press {
|
if let Event::Key(key) = event::read()? {
|
||||||
if let Some(action) = handle_key(&mut app, key) {
|
if key.kind == KeyEventKind::Press {
|
||||||
perform(terminal, &mut app, socket, action)?;
|
if let Some(action) = handle_key(&mut app, key) {
|
||||||
|
perform(terminal, &mut app, socket, action)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Idle tick: refresh the sync-health snapshot for the status line.
|
||||||
|
app.refresh_sync();
|
||||||
}
|
}
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use heph_core::Attention;
|
use heph_core::Attention;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{
|
widgets::{
|
||||||
|
|
@ -14,8 +14,8 @@ use ratatui::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
|
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
|
||||||
use crate::backend::Backend;
|
use crate::backend::{Backend, SyncStatus};
|
||||||
use crate::fmt::{fmt_date, project_color, today_local};
|
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 =
|
||||||
|
|
@ -37,7 +37,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
||||||
let panes = Layout::default()
|
let panes = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(22),
|
Constraint::Length(28),
|
||||||
Constraint::Min(28),
|
Constraint::Min(28),
|
||||||
Constraint::Length(38),
|
Constraint::Length(38),
|
||||||
])
|
])
|
||||||
|
|
@ -151,8 +151,25 @@ fn pane_border(focused: bool) -> Style {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The (label, trailing-count) styles for a sidebar row given its selection
|
||||||
|
/// state: a full-width cyan bar when focus-selected, reversed when selected in
|
||||||
|
/// the unfocused pane, otherwise plain with a dimmed count.
|
||||||
|
fn sidebar_row_styles(selected: bool, focused: bool) -> (Style, Style) {
|
||||||
|
if selected {
|
||||||
|
let s = if focused {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
|
};
|
||||||
|
(s, s)
|
||||||
|
} else {
|
||||||
|
(Style::default(), Style::default().fg(Color::DarkGray))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
let focused = app.focus == Focus::Sidebar;
|
let focused = app.focus == Focus::Sidebar;
|
||||||
|
let width = area.width.saturating_sub(2) as usize; // inside borders
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.sidebar
|
.sidebar
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -166,17 +183,38 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
.fg(Color::DarkGray)
|
.fg(Color::DarkGray)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
))),
|
))),
|
||||||
SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => {
|
SidebarEntry::View { title, .. } => {
|
||||||
let mut style = Style::default();
|
let (style, _) = sidebar_row_styles(selected, focused);
|
||||||
if selected {
|
|
||||||
style = if focused {
|
|
||||||
style.fg(Color::Black).bg(Color::Cyan)
|
|
||||||
} else {
|
|
||||||
style.add_modifier(Modifier::REVERSED)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ListItem::new(Line::from(Span::styled(format!(" {title}"), style)))
|
ListItem::new(Line::from(Span::styled(format!(" {title}"), style)))
|
||||||
}
|
}
|
||||||
|
SidebarEntry::Project {
|
||||||
|
title,
|
||||||
|
depth,
|
||||||
|
count,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Indent two columns per nesting level (one base level so a
|
||||||
|
// top-level project still clears the pane border).
|
||||||
|
let indent = " ".repeat(1 + *depth as usize);
|
||||||
|
// A right-aligned outstanding-task count (blank when zero).
|
||||||
|
let count_str = if *count > 0 {
|
||||||
|
format!(" {count}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let label_w = width.saturating_sub(count_str.chars().count());
|
||||||
|
let title_room = label_w.saturating_sub(indent.chars().count());
|
||||||
|
let title_trunc: String = title.chars().take(title_room).collect();
|
||||||
|
let mut label = format!("{indent}{title_trunc}");
|
||||||
|
let pad = label_w.saturating_sub(label.chars().count());
|
||||||
|
label.push_str(&" ".repeat(pad));
|
||||||
|
|
||||||
|
let (label_style, count_style) = sidebar_row_styles(selected, focused);
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(label, label_style),
|
||||||
|
Span::styled(count_str, count_style),
|
||||||
|
]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -187,7 +225,29 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
.border_style(pane_border(focused))
|
.border_style(pane_border(focused))
|
||||||
.title(" Views "),
|
.title(" Views "),
|
||||||
);
|
);
|
||||||
frame.render_widget(list, area);
|
// Drive scroll-to-visible off the cursor so projects below the fold stay
|
||||||
|
// reachable; the row's own highlight remains the selection cue.
|
||||||
|
let mut state = ListState::default();
|
||||||
|
state.select(Some(app.sidebar_cursor));
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
|
||||||
|
// A scrollbar once the entries can't all fit at once (position tracks the
|
||||||
|
// cursor — an honest "where am I in the list" signal).
|
||||||
|
let inner_h = area.height.saturating_sub(2) as usize;
|
||||||
|
if app.sidebar.len() > inner_h {
|
||||||
|
let mut sb = ScrollbarState::new(app.sidebar.len()).position(app.sidebar_cursor);
|
||||||
|
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||||
|
.begin_symbol(None)
|
||||||
|
.end_symbol(None);
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
bar,
|
||||||
|
area.inner(Margin {
|
||||||
|
vertical: 1,
|
||||||
|
horizontal: 0,
|
||||||
|
}),
|
||||||
|
&mut sb,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A dimmed `──── Project ────` group header for the project sort mode, padded
|
/// A dimmed `──── Project ────` group header for the project sort mode, padded
|
||||||
|
|
@ -239,7 +299,7 @@ fn task_detail_lines<B: Backend>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(rrule) = &t.recurrence {
|
if let Some(rrule) = &t.recurrence {
|
||||||
field("recurs:", rrule.clone());
|
field("recurs:", hephd::datespec::humanize_rrule(rrule));
|
||||||
}
|
}
|
||||||
if let Some(d) = t.do_date {
|
if let Some(d) = t.do_date {
|
||||||
field("do:", fmt_date(d, today));
|
field("do:", fmt_date(d, today));
|
||||||
|
|
@ -478,5 +538,132 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
|
let left = Paragraph::new(Line::from(Span::styled(text, style)));
|
||||||
|
|
||||||
|
// A right-aligned sync indicator (spoke only); the hints take the rest.
|
||||||
|
let indicator = sync_indicator(&app.sync, now_ms());
|
||||||
|
if indicator.is_empty() {
|
||||||
|
frame.render_widget(left, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum();
|
||||||
|
let cols =
|
||||||
|
Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area);
|
||||||
|
frame.render_widget(left, cols[0]);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(indicator)).alignment(Alignment::Right),
|
||||||
|
cols[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status-line sync indicator (empty on a standalone instance): a sync-state
|
||||||
|
/// chip — `⚠ auth` when re-login is needed, `⟳ <age>` since the last successful
|
||||||
|
/// sync, `⚠ offline` when erroring, `⟳ …` before the first sync — plus a
|
||||||
|
/// conflict chip when any merge conflicts are pending.
|
||||||
|
fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec<Span<'static>> {
|
||||||
|
if sync.hub_url.is_none() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let dim = Style::default().fg(Color::DarkGray);
|
||||||
|
let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
||||||
|
let yellow = Style::default().fg(Color::Yellow);
|
||||||
|
|
||||||
|
let health = sync.health.clone().unwrap_or_default();
|
||||||
|
let mut spans = vec![if health.auth_failure {
|
||||||
|
// 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 {
|
||||||
|
Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim)
|
||||||
|
} else if health.last_error.is_some() {
|
||||||
|
Span::styled("⚠ offline", yellow)
|
||||||
|
} else {
|
||||||
|
Span::styled("⟳ …", dim)
|
||||||
|
}];
|
||||||
|
|
||||||
|
if sync.conflicts > 0 {
|
||||||
|
let label = if sync.conflicts == 1 {
|
||||||
|
"1 conflict".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} conflicts", sync.conflicts)
|
||||||
|
};
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(Span::styled(format!("⚠ {label}"), red));
|
||||||
|
}
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::backend::SyncHealth;
|
||||||
|
|
||||||
|
fn render(sync: &SyncStatus, now: i64) -> String {
|
||||||
|
sync_indicator(sync, now)
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.content.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW: i64 = 1_000_000_000_000;
|
||||||
|
|
||||||
|
fn spoke(health: SyncHealth, conflicts: usize) -> SyncStatus {
|
||||||
|
SyncStatus {
|
||||||
|
hub_url: Some("http://hub:8787".into()),
|
||||||
|
conflicts,
|
||||||
|
health: Some(health),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standalone_shows_no_indicator() {
|
||||||
|
assert!(sync_indicator(&SyncStatus::default(), NOW).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indicator_reflects_each_sync_state() {
|
||||||
|
// Recently synced → a dim age chip.
|
||||||
|
let ok = spoke(
|
||||||
|
SyncHealth {
|
||||||
|
last_success_ms: Some(NOW - 5 * 60_000),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(render(&ok, NOW), "⟳ 5m");
|
||||||
|
|
||||||
|
// Auth failure wins over age (it's the actionable state).
|
||||||
|
let auth = spoke(
|
||||||
|
SyncHealth {
|
||||||
|
last_success_ms: Some(NOW - 60_000),
|
||||||
|
auth_failure: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(render(&auth, NOW), "⚠ auth · heph auth status");
|
||||||
|
|
||||||
|
// Errored with no prior success → offline.
|
||||||
|
let offline = spoke(
|
||||||
|
SyncHealth {
|
||||||
|
last_error: Some("error sending request".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(render(&offline, NOW), "⚠ offline");
|
||||||
|
|
||||||
|
// Before the first sync.
|
||||||
|
assert_eq!(render(&spoke(SyncHealth::default(), 0), NOW), "⟳ …");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflicts_chip_appends_and_pluralizes() {
|
||||||
|
let h = SyncHealth {
|
||||||
|
last_success_ms: Some(NOW),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(render(&spoke(h.clone(), 1), NOW), "⟳ 0s ⚠ 1 conflict");
|
||||||
|
assert_eq!(render(&spoke(h, 3), NOW), "⟳ 0s ⚠ 3 conflicts");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,12 @@ fn agenda_renders_views_projects_and_tasks() {
|
||||||
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
|
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
|
||||||
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
|
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
|
||||||
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
|
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
|
||||||
|
// A standalone daemon (no hub) shows no sync indicator — the `sync.status`
|
||||||
|
// RPC round-trips and reports `hub_url: null`.
|
||||||
|
assert!(
|
||||||
|
!s.contains('⟳'),
|
||||||
|
"sync indicator should be hidden without a hub:\n{s}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -206,7 +212,12 @@ fn recurring_task_shows_glyph_and_selected_detail_block() {
|
||||||
assert!(s.contains('↻'), "recurrence glyph missing:\n{s}");
|
assert!(s.contains('↻'), "recurrence glyph missing:\n{s}");
|
||||||
// ...and the selected task's inline detail block (cursor starts on row 0).
|
// ...and the selected task's inline detail block (cursor starts on row 0).
|
||||||
assert!(s.contains("recurs:"), "no recurrence detail:\n{s}");
|
assert!(s.contains("recurs:"), "no recurrence detail:\n{s}");
|
||||||
assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}");
|
// The RRULE is humanized for display (§8.1), not shown raw.
|
||||||
|
assert!(s.contains("daily"), "recurrence not humanized:\n{s}");
|
||||||
|
assert!(
|
||||||
|
!s.contains("FREQ=DAILY"),
|
||||||
|
"raw rrule leaked into detail:\n{s}"
|
||||||
|
);
|
||||||
assert!(s.contains("project:"), "no project detail:\n{s}");
|
assert!(s.contains("project:"), "no project detail:\n{s}");
|
||||||
assert!(s.contains("Routines"), "project name missing:\n{s}");
|
assert!(s.contains("Routines"), "project name missing:\n{s}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,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 +367,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 +399,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 +463,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);
|
||||||
|
|
@ -790,7 +850,13 @@ 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(())
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,19 @@
|
||||||
//! be shared by the CLI, TUI, and `heph.nvim` without any one of them owning its
|
//! be shared by the CLI, TUI, and `heph.nvim` without any one of them owning its
|
||||||
//! lifecycle. macOS uses a launchd **LaunchAgent**, Linux a **systemd user
|
//! lifecycle. macOS uses a launchd **LaunchAgent**, Linux a **systemd user
|
||||||
//! service**. All verbs are idempotent.
|
//! service**. All verbs are idempotent.
|
||||||
|
//!
|
||||||
|
//! The service generator bakes the daemon's runtime config — mode, sync hub,
|
||||||
|
//! and OIDC — into the unit so a spoke/hub can run under the managed service
|
||||||
|
//! instead of a hand-written plist/unit. Regenerating (`start`/`restart`)
|
||||||
|
//! **preserves any config already baked into the on-disk file**, so a bare
|
||||||
|
//! invocation never silently drops flags a previous one set.
|
||||||
|
|
||||||
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::Subcommand;
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use hephd::{default_db_path, default_socket_path};
|
use hephd::{default_db_path, default_socket_path};
|
||||||
|
|
||||||
|
|
@ -19,28 +26,106 @@ const LABEL: &str = "org.hephaestus.hephd";
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum DaemonAction {
|
pub enum DaemonAction {
|
||||||
/// Install (if needed) and start the daemon service.
|
/// Install (if needed) and start the daemon service.
|
||||||
Start {
|
Start(ServiceArgs),
|
||||||
/// Generate a service that runs with opt-in self-update enabled
|
|
||||||
/// (default off). The service gets a PATH that can find cargo.
|
|
||||||
#[arg(long)]
|
|
||||||
self_update: bool,
|
|
||||||
},
|
|
||||||
/// Stop the daemon now (it may restart at next login; use `uninstall` to
|
/// Stop the daemon now (it may restart at next login; use `uninstall` to
|
||||||
/// stop it for good).
|
/// stop it for good).
|
||||||
Stop,
|
Stop,
|
||||||
/// Restart the daemon — run this after upgrading the binary. Preserves the
|
/// Restart the daemon — run this after upgrading the binary. Preserves the
|
||||||
/// existing self-update setting unless `--self-update` re-enables it.
|
/// config already baked into the service file (pass flags to add/override).
|
||||||
Restart {
|
Restart(ServiceArgs),
|
||||||
/// Force self-update on when regenerating the service definition.
|
|
||||||
#[arg(long)]
|
|
||||||
self_update: bool,
|
|
||||||
},
|
|
||||||
/// Show whether the service is installed and running.
|
/// Show whether the service is installed and running.
|
||||||
Status,
|
Status,
|
||||||
/// Stop and remove the service entirely.
|
/// Stop and remove the service entirely.
|
||||||
Uninstall,
|
Uninstall,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Config flags baked into the generated service, shared by `start`/`restart`.
|
||||||
|
/// Anything omitted falls back to what the on-disk service file already has, so
|
||||||
|
/// regenerating is non-destructive.
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct ServiceArgs {
|
||||||
|
/// Runtime mode baked into the service (default `local`). Use `server` for a
|
||||||
|
/// sync hub, `client` for an online-only proxy.
|
||||||
|
#[arg(long, value_parser = ["local", "server", "client"])]
|
||||||
|
mode: Option<String>,
|
||||||
|
/// Hub to background-sync this replica's op-log with (makes it a spoke) —
|
||||||
|
/// bakes `--hub-url`.
|
||||||
|
#[arg(long)]
|
||||||
|
hub_url: Option<String>,
|
||||||
|
/// Hub HTTP listen address (server mode) — bakes `--http-addr`.
|
||||||
|
#[arg(long)]
|
||||||
|
http_addr: Option<String>,
|
||||||
|
/// OIDC issuer used to verify (server) or obtain (spoke) hub tokens — bakes
|
||||||
|
/// `--oidc-issuer`.
|
||||||
|
#[arg(long)]
|
||||||
|
oidc_issuer: Option<String>,
|
||||||
|
/// OIDC audience hub tokens must carry (server mode) — bakes
|
||||||
|
/// `--oidc-audience`.
|
||||||
|
#[arg(long)]
|
||||||
|
oidc_audience: Option<String>,
|
||||||
|
/// OIDC client id this device authenticates as (spoke) — bakes
|
||||||
|
/// `--oidc-client-id`.
|
||||||
|
#[arg(long)]
|
||||||
|
oidc_client_id: Option<String>,
|
||||||
|
/// Generate a service that runs with opt-in self-update enabled (default
|
||||||
|
/// off). The service gets a PATH that can find cargo.
|
||||||
|
#[arg(long)]
|
||||||
|
self_update: bool,
|
||||||
|
/// Override the self-update poll interval, in seconds (default: 6h). Only
|
||||||
|
/// meaningful with --self-update.
|
||||||
|
#[arg(long)]
|
||||||
|
self_update_interval_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hephd flags the service generator bakes beyond the fixed `--db`/`--socket`.
|
||||||
|
#[derive(Default, Clone, PartialEq, Debug)]
|
||||||
|
struct DaemonConfig {
|
||||||
|
mode: Option<String>,
|
||||||
|
hub_url: Option<String>,
|
||||||
|
http_addr: Option<String>,
|
||||||
|
oidc_issuer: Option<String>,
|
||||||
|
oidc_audience: Option<String>,
|
||||||
|
oidc_client_id: Option<String>,
|
||||||
|
self_update: bool,
|
||||||
|
self_update_interval_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceArgs {
|
||||||
|
fn to_config(&self) -> DaemonConfig {
|
||||||
|
DaemonConfig {
|
||||||
|
mode: self.mode.clone(),
|
||||||
|
hub_url: self.hub_url.clone(),
|
||||||
|
http_addr: self.http_addr.clone(),
|
||||||
|
oidc_issuer: self.oidc_issuer.clone(),
|
||||||
|
oidc_audience: self.oidc_audience.clone(),
|
||||||
|
oidc_client_id: self.oidc_client_id.clone(),
|
||||||
|
self_update: self.self_update,
|
||||||
|
self_update_interval_secs: self.self_update_interval_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonConfig {
|
||||||
|
/// CLI-provided values win; anything omitted falls back to `base` (the flags
|
||||||
|
/// already baked into the on-disk service file), so regenerating the service
|
||||||
|
/// never drops config a previous invocation set. `self_update` is sticky —
|
||||||
|
/// it stays on if either the CLI or the existing file enabled it.
|
||||||
|
fn fill_from(self, base: DaemonConfig) -> DaemonConfig {
|
||||||
|
DaemonConfig {
|
||||||
|
mode: self.mode.or(base.mode),
|
||||||
|
hub_url: self.hub_url.or(base.hub_url),
|
||||||
|
http_addr: self.http_addr.or(base.http_addr),
|
||||||
|
oidc_issuer: self.oidc_issuer.or(base.oidc_issuer),
|
||||||
|
oidc_audience: self.oidc_audience.or(base.oidc_audience),
|
||||||
|
oidc_client_id: self.oidc_client_id.or(base.oidc_client_id),
|
||||||
|
self_update: self.self_update || base.self_update,
|
||||||
|
self_update_interval_secs: self
|
||||||
|
.self_update_interval_secs
|
||||||
|
.or(base.self_update_interval_secs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolved locations the service definition needs.
|
/// Resolved locations the service definition needs.
|
||||||
struct Paths {
|
struct Paths {
|
||||||
hephd: PathBuf,
|
hephd: PathBuf,
|
||||||
|
|
@ -114,6 +199,105 @@ pub fn run(action: &DaemonAction) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// hephd argument vector (pure — shared by both renderers and unit-tested)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The full `hephd …` argument vector the service runs, given the resolved paths
|
||||||
|
/// and baked config. `--mode` defaults to `local`; the optional flags appear in
|
||||||
|
/// a stable order so regenerating an unchanged config produces an identical file.
|
||||||
|
fn hephd_args(hephd: &Path, db: &Path, socket: &Path, cfg: &DaemonConfig) -> Vec<String> {
|
||||||
|
let mut a = vec![
|
||||||
|
hephd.to_string_lossy().into_owned(),
|
||||||
|
"--mode".into(),
|
||||||
|
cfg.mode.clone().unwrap_or_else(|| "local".into()),
|
||||||
|
"--db".into(),
|
||||||
|
db.to_string_lossy().into_owned(),
|
||||||
|
"--socket".into(),
|
||||||
|
socket.to_string_lossy().into_owned(),
|
||||||
|
];
|
||||||
|
push_opt(&mut a, "--hub-url", &cfg.hub_url);
|
||||||
|
push_opt(&mut a, "--http-addr", &cfg.http_addr);
|
||||||
|
push_opt(&mut a, "--oidc-issuer", &cfg.oidc_issuer);
|
||||||
|
push_opt(&mut a, "--oidc-audience", &cfg.oidc_audience);
|
||||||
|
push_opt(&mut a, "--oidc-client-id", &cfg.oidc_client_id);
|
||||||
|
// Interval is only meaningful with --self-update, so it's nested under it.
|
||||||
|
if cfg.self_update {
|
||||||
|
a.push("--self-update".into());
|
||||||
|
if let Some(secs) = cfg.self_update_interval_secs {
|
||||||
|
a.push("--self-update-interval-secs".into());
|
||||||
|
a.push(secs.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_opt(args: &mut Vec<String>, flag: &str, val: &Option<String>) {
|
||||||
|
if let Some(v) = val {
|
||||||
|
args.push(flag.to_string());
|
||||||
|
args.push(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `hephd` argument vector back into a [`DaemonConfig`] — the inverse of
|
||||||
|
/// [`hephd_args`], used to recover config already baked into an on-disk service
|
||||||
|
/// file. Unrecognized args (the binary path, `--db`, `--socket`) are ignored.
|
||||||
|
fn parse_hephd_args(args: &[String]) -> DaemonConfig {
|
||||||
|
let mut c = DaemonConfig::default();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
let next = || args.get(i + 1).cloned();
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--mode" => {
|
||||||
|
c.mode = next();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--hub-url" => {
|
||||||
|
c.hub_url = next();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--http-addr" => {
|
||||||
|
c.http_addr = next();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--oidc-issuer" => {
|
||||||
|
c.oidc_issuer = next();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--oidc-audience" => {
|
||||||
|
c.oidc_audience = next();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--oidc-client-id" => {
|
||||||
|
c.oidc_client_id = next();
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
"--self-update" => {
|
||||||
|
c.self_update = true;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
"--self-update-interval-secs" => {
|
||||||
|
c.self_update_interval_secs = next().and_then(|s| s.parse().ok());
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
_ => i += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the config baked into an existing service file (empty if absent).
|
||||||
|
fn existing_config(path: &Path, mgr: &Manager) -> DaemonConfig {
|
||||||
|
let Ok(s) = std::fs::read_to_string(path) else {
|
||||||
|
return DaemonConfig::default();
|
||||||
|
};
|
||||||
|
let args = match mgr {
|
||||||
|
Manager::Launchd => launchd_program_args(&s),
|
||||||
|
Manager::Systemd => systemd_exec_args(&s),
|
||||||
|
};
|
||||||
|
parse_hephd_args(&args)
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Rendering (pure — unit-tested)
|
// Rendering (pure — unit-tested)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
@ -124,17 +308,22 @@ fn xml_escape(s: &str) -> String {
|
||||||
.replace('>', ">")
|
.replace('>', ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update: bool) -> String {
|
fn xml_unescape(s: &str) -> String {
|
||||||
let arg = |p: &Path| xml_escape(&p.to_string_lossy());
|
s.replace("<", "<")
|
||||||
// Opt-in self-update: pass the flag, and give the service a PATH/HOME that
|
.replace(">", ">")
|
||||||
// can find cargo + the toolchain (a LaunchAgent's default env can't), since
|
.replace("&", "&")
|
||||||
// the apply path shells out to `cargo install`.
|
}
|
||||||
let self_update_arg = if self_update {
|
|
||||||
"\n <string>--self-update</string>".to_string()
|
fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, cfg: &DaemonConfig) -> String {
|
||||||
} else {
|
let args_xml = hephd_args(hephd, db, socket, cfg)
|
||||||
String::new()
|
.iter()
|
||||||
};
|
.map(|a| format!(" <string>{}</string>", xml_escape(a)))
|
||||||
let cargo_env = if self_update {
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
// Opt-in self-update needs a PATH/HOME that can find cargo + the toolchain
|
||||||
|
// (a LaunchAgent's default env can't), since the apply path shells out to
|
||||||
|
// `cargo install`.
|
||||||
|
let cargo_env = if cfg.self_update {
|
||||||
let (path, home) = cargo_env();
|
let (path, home) = cargo_env();
|
||||||
format!(
|
format!(
|
||||||
"\n <key>PATH</key>\n <string>{}</string>\n <key>HOME</key>\n <string>{}</string>",
|
"\n <key>PATH</key>\n <string>{}</string>\n <key>HOME</key>\n <string>{}</string>",
|
||||||
|
|
@ -153,13 +342,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update
|
||||||
<string>{label}</string>
|
<string>{label}</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{hephd}</string>
|
{args_xml}
|
||||||
<string>--mode</string>
|
|
||||||
<string>local</string>
|
|
||||||
<string>--db</string>
|
|
||||||
<string>{db}</string>
|
|
||||||
<string>--socket</string>
|
|
||||||
<string>{socket}</string>{self_update_arg}
|
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
@ -181,10 +364,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update
|
||||||
</plist>
|
</plist>
|
||||||
"#,
|
"#,
|
||||||
label = LABEL,
|
label = LABEL,
|
||||||
hephd = arg(hephd),
|
log = xml_escape(&log.to_string_lossy()),
|
||||||
db = arg(db),
|
|
||||||
socket = arg(socket),
|
|
||||||
log = arg(log),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,20 +379,34 @@ fn cargo_env() -> (String, String) {
|
||||||
(path, home)
|
(path, home)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an already-installed service file opted into self-update — so
|
/// Extract the `ProgramArguments` strings from an existing launchd plist.
|
||||||
/// `restart` (which regenerates the file) preserves the setting instead of
|
fn launchd_program_args(plist: &str) -> Vec<String> {
|
||||||
/// silently turning it off.
|
let Some(k) = plist.find("<key>ProgramArguments</key>") else {
|
||||||
fn file_opts_into_self_update(path: &Path) -> bool {
|
return vec![];
|
||||||
std::fs::read_to_string(path)
|
};
|
||||||
.map(|s| s.contains("--self-update"))
|
let rest = &plist[k..];
|
||||||
.unwrap_or(false)
|
let (Some(start), Some(end)) = (rest.find("<array>"), rest.find("</array>")) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let block = &rest[start..end];
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut cur = block;
|
||||||
|
while let Some(o) = cur.find("<string>") {
|
||||||
|
let after = &cur[o + "<string>".len()..];
|
||||||
|
let Some(c) = after.find("</string>") else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
out.push(xml_unescape(&after[..c]));
|
||||||
|
cur = &after[c + "</string>".len()..];
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> String {
|
fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, cfg: &DaemonConfig) -> String {
|
||||||
// Opt-in self-update: pass the flag and give the unit a PATH/HOME that can
|
let exec = hephd_args(hephd, db, socket, cfg).join(" ");
|
||||||
// find cargo + the toolchain, since the apply path runs `cargo install`.
|
// Opt-in self-update needs a PATH/HOME that can find cargo + the toolchain,
|
||||||
let su_arg = if self_update { " --self-update" } else { "" };
|
// since the apply path runs `cargo install`.
|
||||||
let cargo_env = if self_update {
|
let cargo_env = if cfg.self_update {
|
||||||
let (path, home) = cargo_env();
|
let (path, home) = cargo_env();
|
||||||
format!("Environment=PATH={path}\nEnvironment=HOME={home}\n")
|
format!("Environment=PATH={path}\nEnvironment=HOME={home}\n")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -224,19 +418,24 @@ fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> St
|
||||||
After=default.target\n\
|
After=default.target\n\
|
||||||
\n\
|
\n\
|
||||||
[Service]\n\
|
[Service]\n\
|
||||||
ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\
|
ExecStart={exec}\n\
|
||||||
{cargo_env}\
|
{cargo_env}\
|
||||||
Restart=always\n\
|
Restart=always\n\
|
||||||
RestartSec=1\n\
|
RestartSec=1\n\
|
||||||
\n\
|
\n\
|
||||||
[Install]\n\
|
[Install]\n\
|
||||||
WantedBy=default.target\n",
|
WantedBy=default.target\n",
|
||||||
hephd = hephd.display(),
|
|
||||||
db = db.display(),
|
|
||||||
socket = socket.display(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the `ExecStart=` argument vector from an existing systemd unit.
|
||||||
|
fn systemd_exec_args(unit: &str) -> Vec<String> {
|
||||||
|
unit.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("ExecStart="))
|
||||||
|
.map(|rest| rest.split_whitespace().map(str::to_string).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Shared helpers
|
// Shared helpers
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
@ -296,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()?;
|
||||||
|
|
@ -303,18 +547,18 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
let target = format!("gui/{uid}/{LABEL}");
|
let target = format!("gui/{uid}/{LABEL}");
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
DaemonAction::Start { self_update } => {
|
DaemonAction::Start(args) => {
|
||||||
|
let cfg = args
|
||||||
|
.to_config()
|
||||||
|
.fill_from(existing_config(&plist, &Manager::Launchd));
|
||||||
write_if_changed(
|
write_if_changed(
|
||||||
&plist,
|
&plist,
|
||||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, *self_update),
|
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
|
||||||
)?;
|
)?;
|
||||||
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}).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -322,16 +566,28 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?;
|
let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?;
|
||||||
println!("heph daemon stopped (still installed; `uninstall` to remove).");
|
println!("heph daemon stopped (still installed; `uninstall` to remove).");
|
||||||
}
|
}
|
||||||
DaemonAction::Restart { self_update } => {
|
DaemonAction::Restart(args) => {
|
||||||
let su = *self_update || file_opts_into_self_update(&plist);
|
let cfg = args
|
||||||
write_if_changed(
|
.to_config()
|
||||||
|
.fill_from(existing_config(&plist, &Manager::Launchd));
|
||||||
|
let changed = write_if_changed(
|
||||||
&plist,
|
&plist,
|
||||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, su),
|
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
|
||||||
)?;
|
)?;
|
||||||
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
if !launchd_loaded(&target) {
|
||||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
// Not currently loaded — nothing to tear down, just bring it up.
|
||||||
if !ok {
|
launchd_bootstrap(&domain, &plist_str(&plist)?)?;
|
||||||
bail!("launchctl bootstrap failed: {}", err.trim());
|
} 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])?;
|
||||||
|
wait_until_unloaded(&target, Duration::from_secs(5));
|
||||||
|
launchd_bootstrap(&domain, &plist_str(&plist)?)?;
|
||||||
|
} 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}).");
|
||||||
}
|
}
|
||||||
|
|
@ -380,11 +636,11 @@ fn sc(args: &[&str]) -> Result<(bool, String)> {
|
||||||
fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
let unit = systemd_unit_path()?;
|
let unit = systemd_unit_path()?;
|
||||||
match action {
|
match action {
|
||||||
DaemonAction::Start { self_update } => {
|
DaemonAction::Start(args) => {
|
||||||
write_if_changed(
|
let cfg = args
|
||||||
&unit,
|
.to_config()
|
||||||
&systemd_unit(&p.hephd, &p.db, &p.socket, *self_update),
|
.fill_from(existing_config(&unit, &Manager::Systemd));
|
||||||
)?;
|
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, &cfg))?;
|
||||||
sc(&["daemon-reload"])?;
|
sc(&["daemon-reload"])?;
|
||||||
let (ok, err) = sc(&["enable", "--now", UNIT])?;
|
let (ok, err) = sc(&["enable", "--now", UNIT])?;
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -396,9 +652,11 @@ fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||||
sc(&["stop", UNIT])?;
|
sc(&["stop", UNIT])?;
|
||||||
println!("heph daemon stopped (still enabled; `uninstall` to remove).");
|
println!("heph daemon stopped (still enabled; `uninstall` to remove).");
|
||||||
}
|
}
|
||||||
DaemonAction::Restart { self_update } => {
|
DaemonAction::Restart(args) => {
|
||||||
let su = *self_update || file_opts_into_self_update(&unit);
|
let cfg = args
|
||||||
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, su))?;
|
.to_config()
|
||||||
|
.fill_from(existing_config(&unit, &Manager::Systemd));
|
||||||
|
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, &cfg))?;
|
||||||
sc(&["daemon-reload"])?;
|
sc(&["daemon-reload"])?;
|
||||||
let (ok, err) = sc(&["restart", UNIT])?;
|
let (ok, err) = sc(&["restart", UNIT])?;
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -440,6 +698,18 @@ fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path)
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn spoke_cfg() -> DaemonConfig {
|
||||||
|
DaemonConfig {
|
||||||
|
mode: Some("local".into()),
|
||||||
|
hub_url: Some("http://hub.example:8787".into()),
|
||||||
|
oidc_issuer: Some("https://idp.example/o/heph/".into()),
|
||||||
|
oidc_client_id: Some("heph".into()),
|
||||||
|
self_update: true,
|
||||||
|
self_update_interval_secs: Some(600),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn launchd_plist_has_label_args_and_paths() {
|
fn launchd_plist_has_label_args_and_paths() {
|
||||||
let plist = launchd_plist(
|
let plist = launchd_plist(
|
||||||
|
|
@ -447,19 +717,21 @@ mod tests {
|
||||||
Path::new("/home/e/.local/share/heph/heph.db"),
|
Path::new("/home/e/.local/share/heph/heph.db"),
|
||||||
Path::new("/tmp/heph/hephd.sock"),
|
Path::new("/tmp/heph/hephd.sock"),
|
||||||
Path::new("/home/e/.local/share/heph/hephd.log"),
|
Path::new("/home/e/.local/share/heph/hephd.log"),
|
||||||
false,
|
&DaemonConfig::default(),
|
||||||
);
|
);
|
||||||
assert!(plist.contains("<string>org.hephaestus.hephd</string>"));
|
assert!(plist.contains("<string>org.hephaestus.hephd</string>"));
|
||||||
assert!(plist.contains("<string>/usr/local/bin/hephd</string>"));
|
assert!(plist.contains("<string>/usr/local/bin/hephd</string>"));
|
||||||
assert!(plist.contains("<string>--mode</string>"));
|
assert!(plist.contains("<string>--mode</string>"));
|
||||||
|
assert!(plist.contains("<string>local</string>"));
|
||||||
assert!(plist.contains("<string>/home/e/.local/share/heph/heph.db</string>"));
|
assert!(plist.contains("<string>/home/e/.local/share/heph/heph.db</string>"));
|
||||||
assert!(plist.contains("<string>/tmp/heph/hephd.sock</string>"));
|
assert!(plist.contains("<string>/tmp/heph/hephd.sock</string>"));
|
||||||
assert!(plist.contains("<key>RunAtLoad</key>"));
|
assert!(plist.contains("<key>RunAtLoad</key>"));
|
||||||
assert!(plist.contains("<key>KeepAlive</key>"));
|
assert!(plist.contains("<key>KeepAlive</key>"));
|
||||||
assert!(plist.contains("hephd.log"));
|
assert!(plist.contains("hephd.log"));
|
||||||
// Default (no self-update): no flag, no cargo PATH baked in.
|
// Default (no self-update, no spoke/hub config): none of those flags.
|
||||||
assert!(!plist.contains("--self-update"));
|
assert!(!plist.contains("--self-update"));
|
||||||
assert!(!plist.contains(".cargo/bin"));
|
assert!(!plist.contains(".cargo/bin"));
|
||||||
|
assert!(!plist.contains("--hub-url"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -469,12 +741,64 @@ mod tests {
|
||||||
Path::new("/db"),
|
Path::new("/db"),
|
||||||
Path::new("/sock"),
|
Path::new("/sock"),
|
||||||
Path::new("/log"),
|
Path::new("/log"),
|
||||||
true,
|
&DaemonConfig {
|
||||||
|
self_update: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
assert!(plist.contains("<string>--self-update</string>"));
|
assert!(plist.contains("<string>--self-update</string>"));
|
||||||
assert!(plist.contains("<key>PATH</key>"));
|
assert!(plist.contains("<key>PATH</key>"));
|
||||||
assert!(plist.contains(".cargo/bin"));
|
assert!(plist.contains(".cargo/bin"));
|
||||||
assert!(plist.contains("<key>HOME</key>"));
|
assert!(plist.contains("<key>HOME</key>"));
|
||||||
|
// No interval given → no interval flag.
|
||||||
|
assert!(!plist.contains("--self-update-interval-secs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchd_plist_self_update_interval_is_baked_under_self_update() {
|
||||||
|
let with = launchd_plist(
|
||||||
|
Path::new("/hephd"),
|
||||||
|
Path::new("/db"),
|
||||||
|
Path::new("/sock"),
|
||||||
|
Path::new("/log"),
|
||||||
|
&DaemonConfig {
|
||||||
|
self_update: true,
|
||||||
|
self_update_interval_secs: Some(3600),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(with.contains("<string>--self-update-interval-secs</string>"));
|
||||||
|
assert!(with.contains("<string>3600</string>"));
|
||||||
|
// Interval is meaningless without --self-update, so it's not emitted.
|
||||||
|
let without = launchd_plist(
|
||||||
|
Path::new("/hephd"),
|
||||||
|
Path::new("/db"),
|
||||||
|
Path::new("/sock"),
|
||||||
|
Path::new("/log"),
|
||||||
|
&DaemonConfig {
|
||||||
|
self_update: false,
|
||||||
|
self_update_interval_secs: Some(3600),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!without.contains("--self-update-interval-secs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchd_plist_bakes_spoke_config() {
|
||||||
|
let plist = launchd_plist(
|
||||||
|
Path::new("/hephd"),
|
||||||
|
Path::new("/db"),
|
||||||
|
Path::new("/sock"),
|
||||||
|
Path::new("/log"),
|
||||||
|
&spoke_cfg(),
|
||||||
|
);
|
||||||
|
assert!(plist.contains("<string>--hub-url</string>"));
|
||||||
|
assert!(plist.contains("<string>http://hub.example:8787</string>"));
|
||||||
|
assert!(plist.contains("<string>--oidc-issuer</string>"));
|
||||||
|
assert!(plist.contains("<string>https://idp.example/o/heph/</string>"));
|
||||||
|
assert!(plist.contains("<string>--oidc-client-id</string>"));
|
||||||
|
assert!(plist.contains("<string>heph</string>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -483,7 +807,7 @@ mod tests {
|
||||||
Path::new("/usr/local/bin/hephd"),
|
Path::new("/usr/local/bin/hephd"),
|
||||||
Path::new("/home/e/.local/share/heph/heph.db"),
|
Path::new("/home/e/.local/share/heph/heph.db"),
|
||||||
Path::new("/run/user/1000/heph/hephd.sock"),
|
Path::new("/run/user/1000/heph/hephd.sock"),
|
||||||
false,
|
&DaemonConfig::default(),
|
||||||
);
|
);
|
||||||
assert!(unit.contains(
|
assert!(unit.contains(
|
||||||
"ExecStart=/usr/local/bin/hephd --mode local \
|
"ExecStart=/usr/local/bin/hephd --mode local \
|
||||||
|
|
@ -507,17 +831,96 @@ mod tests {
|
||||||
Path::new("/usr/local/bin/hephd"),
|
Path::new("/usr/local/bin/hephd"),
|
||||||
Path::new("/db"),
|
Path::new("/db"),
|
||||||
Path::new("/sock"),
|
Path::new("/sock"),
|
||||||
true,
|
&DaemonConfig {
|
||||||
|
self_update: true,
|
||||||
|
self_update_interval_secs: Some(3600),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
assert!(unit.contains("--self-update"));
|
assert!(unit.contains("--self-update --self-update-interval-secs 3600"));
|
||||||
assert!(unit.contains("Environment=PATH="));
|
assert!(unit.contains("Environment=PATH="));
|
||||||
assert!(unit.contains(".cargo/bin"));
|
assert!(unit.contains(".cargo/bin"));
|
||||||
assert!(unit.contains("Environment=HOME="));
|
assert!(unit.contains("Environment=HOME="));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn xml_escape_escapes_markup() {
|
fn systemd_unit_bakes_hub_config() {
|
||||||
|
let unit = systemd_unit(
|
||||||
|
Path::new("/hephd"),
|
||||||
|
Path::new("/db"),
|
||||||
|
Path::new("/sock"),
|
||||||
|
&DaemonConfig {
|
||||||
|
mode: Some("server".into()),
|
||||||
|
http_addr: Some("0.0.0.0:8787".into()),
|
||||||
|
oidc_issuer: Some("https://idp.example/o/heph/".into()),
|
||||||
|
oidc_audience: Some("heph".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(unit.contains("--mode server"));
|
||||||
|
assert!(unit.contains("--http-addr 0.0.0.0:8787"));
|
||||||
|
assert!(unit.contains("--oidc-issuer https://idp.example/o/heph/"));
|
||||||
|
assert!(unit.contains("--oidc-audience heph"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchd_config_round_trips_through_the_plist() {
|
||||||
|
let cfg = spoke_cfg();
|
||||||
|
let plist = launchd_plist(
|
||||||
|
Path::new("/hephd"),
|
||||||
|
Path::new("/db"),
|
||||||
|
Path::new("/sock"),
|
||||||
|
Path::new("/log"),
|
||||||
|
&cfg,
|
||||||
|
);
|
||||||
|
let parsed = parse_hephd_args(&launchd_program_args(&plist));
|
||||||
|
assert_eq!(parsed, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn systemd_config_round_trips_through_the_unit() {
|
||||||
|
let cfg = DaemonConfig {
|
||||||
|
mode: Some("server".into()),
|
||||||
|
http_addr: Some("0.0.0.0:8787".into()),
|
||||||
|
oidc_issuer: Some("https://idp.example/o/heph/".into()),
|
||||||
|
oidc_audience: Some("heph".into()),
|
||||||
|
self_update: true,
|
||||||
|
self_update_interval_secs: Some(600),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let unit = systemd_unit(
|
||||||
|
Path::new("/hephd"),
|
||||||
|
Path::new("/db"),
|
||||||
|
Path::new("/sock"),
|
||||||
|
&cfg,
|
||||||
|
);
|
||||||
|
let parsed = parse_hephd_args(&systemd_exec_args(&unit));
|
||||||
|
assert_eq!(parsed, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fill_from_preserves_existing_and_lets_cli_override() {
|
||||||
|
let existing = spoke_cfg();
|
||||||
|
// A bare invocation (no flags) preserves everything baked in the file.
|
||||||
|
assert_eq!(
|
||||||
|
DaemonConfig::default().fill_from(existing.clone()),
|
||||||
|
existing
|
||||||
|
);
|
||||||
|
// A CLI-provided value overrides; self_update stays sticky.
|
||||||
|
let overridden = DaemonConfig {
|
||||||
|
self_update_interval_secs: Some(60),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.fill_from(existing.clone());
|
||||||
|
assert_eq!(overridden.self_update_interval_secs, Some(60));
|
||||||
|
assert_eq!(overridden.hub_url, existing.hub_url);
|
||||||
|
assert!(overridden.self_update);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xml_escape_round_trips() {
|
||||||
assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d");
|
assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d");
|
||||||
|
assert_eq!(xml_unescape("a & b < c > d"), "a & b < c > d");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -49,6 +85,13 @@ pub trait TokenVerifier: Send + Sync {
|
||||||
/// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its
|
/// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its
|
||||||
/// claims, or an [`AuthError`].
|
/// claims, or an [`AuthError`].
|
||||||
fn verify(&self, bearer: &str) -> Result<Claims, AuthError>;
|
fn verify(&self, bearer: &str) -> Result<Claims, AuthError>;
|
||||||
|
|
||||||
|
/// The public OIDC parameters a browser client (the `heph-pwa`) needs to
|
||||||
|
/// start a login: `(issuer, client_id)`. Neither is a secret. `None` for
|
||||||
|
/// non-OIDC verifiers (e.g. test stubs).
|
||||||
|
fn oidc_config(&self) -> Option<(&str, &str)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What an OIDC provider's discovery document tells us (we only need the JWKS).
|
/// What an OIDC provider's discovery document tells us (we only need the JWKS).
|
||||||
|
|
@ -85,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.
|
||||||
|
|
@ -156,4 +196,44 @@ impl TokenVerifier for OidcVerifier {
|
||||||
.map_err(|e| AuthError::Invalid(e.to_string()))?;
|
.map_err(|e| AuthError::Invalid(e.to_string()))?;
|
||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn oidc_config(&self) -> Option<(&str, &str)> {
|
||||||
|
// The audience is the OIDC client id (Authentik sets `aud` to it).
|
||||||
|
Some((&self.issuer, &self.audience))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AuthError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_formats_status_error_and_description() {
|
||||||
|
let e = AuthError::rejected(400, Some("invalid_grant"), Some("Token is not active"));
|
||||||
|
assert!(e.is_rejection());
|
||||||
|
assert_eq!(
|
||||||
|
e.to_string(),
|
||||||
|
"identity provider rejected the request: HTTP 400 (invalid_grant): Token is not active"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_omits_absent_or_empty_oauth_fields() {
|
||||||
|
// No OAuth body (e.g. a bare 503) → just the status.
|
||||||
|
assert_eq!(
|
||||||
|
AuthError::rejected(503, None, None).to_string(),
|
||||||
|
"identity provider rejected the request: HTTP 503"
|
||||||
|
);
|
||||||
|
// Empty strings are treated as absent, not rendered as "()" / ": ".
|
||||||
|
assert_eq!(
|
||||||
|
AuthError::rejected(400, Some(""), Some("")).to_string(),
|
||||||
|
"identity provider rejected the request: HTTP 400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreachable_is_not_a_rejection() {
|
||||||
|
assert!(!AuthError::Unreachable("connection refused".into()).is_rejection());
|
||||||
|
assert!(!AuthError::Other("keyring locked".into()).is_rejection());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,59 +2,145 @@
|
||||||
//!
|
//!
|
||||||
//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly
|
//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly
|
||||||
//! (tech-spec §3) — they go through the daemon socket, which this wraps.
|
//! (tech-spec §3) — they go through the daemon socket, which this wraps.
|
||||||
|
//!
|
||||||
|
//! The connection self-heals across daemon restarts (opt-in self-update, `heph
|
||||||
|
//! daemon restart`): a [`call`](Client::call) that finds the socket dropped
|
||||||
|
//! reconnects. It only auto-retries when the request provably never reached the
|
||||||
|
//! daemon (a write-side failure); a reply lost *after* sending is surfaced
|
||||||
|
//! rather than retried, so a mutation is never silently double-applied.
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::rpc::Response;
|
use crate::rpc::Response;
|
||||||
|
|
||||||
/// A connected client. One request/response per [`call`](Client::call).
|
/// A connected client. One request/response per [`call`](Client::call).
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
|
socket_path: PathBuf,
|
||||||
reader: BufReader<UnixStream>,
|
reader: BufReader<UnixStream>,
|
||||||
writer: UnixStream,
|
writer: UnixStream,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a single request/response exchange failed — drives the retry decision.
|
||||||
|
enum ExchangeError {
|
||||||
|
/// The request could not be written (broken pipe, reset): it never reached
|
||||||
|
/// the daemon, so retrying on a fresh connection is safe.
|
||||||
|
Send(anyhow::Error),
|
||||||
|
/// The request was sent but no reply came back (the daemon closed mid-flight,
|
||||||
|
/// e.g. it restarted): it may or may not have applied — do not retry.
|
||||||
|
Recv(anyhow::Error),
|
||||||
|
/// A well-formed RPC-level error (or an unparseable reply): the connection is
|
||||||
|
/// fine; nothing to reconnect.
|
||||||
|
Rpc(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExchangeError {
|
||||||
|
fn into_inner(self) -> anyhow::Error {
|
||||||
|
match self {
|
||||||
|
ExchangeError::Send(e) | ExchangeError::Recv(e) | ExchangeError::Rpc(e) => e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// Connect to a daemon listening at `socket_path`.
|
/// Connect to a daemon listening at `socket_path`.
|
||||||
pub fn connect(socket_path: &Path) -> Result<Client> {
|
pub fn connect(socket_path: &Path) -> Result<Client> {
|
||||||
let stream = UnixStream::connect(socket_path)
|
let (reader, writer) = Self::open(socket_path)?;
|
||||||
.with_context(|| format!("connecting to hephd at {}", socket_path.display()))?;
|
|
||||||
let reader = BufReader::new(stream.try_clone()?);
|
|
||||||
Ok(Client {
|
Ok(Client {
|
||||||
|
socket_path: socket_path.to_path_buf(),
|
||||||
reader,
|
reader,
|
||||||
writer: stream,
|
writer,
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a fresh reader/writer pair on the socket.
|
||||||
|
fn open(socket_path: &Path) -> Result<(BufReader<UnixStream>, UnixStream)> {
|
||||||
|
let stream = UnixStream::connect(socket_path)
|
||||||
|
.with_context(|| format!("connecting to hephd at {}", socket_path.display()))?;
|
||||||
|
let reader = BufReader::new(stream.try_clone()?);
|
||||||
|
Ok((reader, stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-establish the connection (after the daemon restarted and dropped it).
|
||||||
|
fn reconnect(&mut self) -> Result<()> {
|
||||||
|
let (reader, writer) = Self::open(&self.socket_path)?;
|
||||||
|
self.reader = reader;
|
||||||
|
self.writer = writer;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Call `method` with `params`, returning the `result` value (or an error
|
/// Call `method` with `params`, returning the `result` value (or an error
|
||||||
/// carrying the RPC error's code and message).
|
/// carrying the RPC error's code and message).
|
||||||
|
///
|
||||||
|
/// If the daemon has restarted and dropped the socket, this reconnects: it
|
||||||
|
/// retries transparently when the request never went out, and otherwise
|
||||||
|
/// reconnects for the next call while surfacing an error for this one (so a
|
||||||
|
/// mutation whose reply was lost is not silently re-applied).
|
||||||
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
|
|
||||||
let mut line = serde_json::to_string(&json!({
|
let mut line = serde_json::to_string(&json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"params": params,
|
"params": params,
|
||||||
}))?;
|
}))?;
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
self.writer.write_all(line.as_bytes())?;
|
|
||||||
self.writer.flush()?;
|
match self.exchange(&line) {
|
||||||
|
Ok(v) => Ok(v),
|
||||||
|
Err(ExchangeError::Rpc(e)) => Err(e),
|
||||||
|
Err(ExchangeError::Send(_)) => {
|
||||||
|
// The request never reached the daemon — reconnect and retry once.
|
||||||
|
self.reconnect()
|
||||||
|
.context("hephd connection lost and reconnect failed")?;
|
||||||
|
self.exchange(&line)
|
||||||
|
.map_err(ExchangeError::into_inner)
|
||||||
|
.with_context(|| format!("retrying `{method}` after reconnect"))
|
||||||
|
}
|
||||||
|
Err(ExchangeError::Recv(e)) => {
|
||||||
|
// Sent but no reply: the daemon likely restarted mid-request. Don't
|
||||||
|
// retry (a mutation may have applied); reconnect for next time and
|
||||||
|
// surface this one.
|
||||||
|
let _ = self.reconnect();
|
||||||
|
Err(e).context(
|
||||||
|
"hephd closed the connection mid-request (it likely restarted); \
|
||||||
|
reconnected — re-run the action if it didn't take effect",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request/response over the current connection, classifying failures.
|
||||||
|
fn exchange(&mut self, line: &str) -> std::result::Result<Value, ExchangeError> {
|
||||||
|
self.writer
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.map_err(|e| ExchangeError::Send(e.into()))?;
|
||||||
|
self.writer
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| ExchangeError::Send(e.into()))?;
|
||||||
|
|
||||||
let mut response_line = String::new();
|
let mut response_line = String::new();
|
||||||
let read = self.reader.read_line(&mut response_line)?;
|
let read = self
|
||||||
|
.reader
|
||||||
|
.read_line(&mut response_line)
|
||||||
|
.map_err(|e| ExchangeError::Recv(e.into()))?;
|
||||||
if read == 0 {
|
if read == 0 {
|
||||||
bail!("hephd closed the connection");
|
return Err(ExchangeError::Recv(anyhow!("hephd closed the connection")));
|
||||||
}
|
}
|
||||||
let response: Response = serde_json::from_str(&response_line)?;
|
let response: Response =
|
||||||
|
serde_json::from_str(&response_line).map_err(|e| ExchangeError::Rpc(e.into()))?;
|
||||||
if let Some(err) = response.error {
|
if let Some(err) = response.error {
|
||||||
bail!("rpc error {}: {}", err.code, err.message);
|
return Err(ExchangeError::Rpc(anyhow!(
|
||||||
|
"rpc error {}: {}",
|
||||||
|
err.code,
|
||||||
|
err.message
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Ok(response.result.unwrap_or(Value::Null))
|
Ok(response.result.unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,172 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reverse datespec: humanize an RRULE for display (§8.1).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render an RFC-5545 RRULE back into the compact human phrasing the owner would
|
||||||
|
/// have typed — the inverse of [`parse_recurrence`] for the forms it produces:
|
||||||
|
/// `daily`, `every 3 days`, `every other day`, `weekly`, `every other week`,
|
||||||
|
/// `weekdays`, `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`,
|
||||||
|
/// `monthly on the 5th`, `yearly on Apr 15`. Any rule that uses parts we don't
|
||||||
|
/// model (`COUNT`, `UNTIL`, `BYSETPOS`, ordinal `BYDAY` like `2MO`, …) is
|
||||||
|
/// returned **verbatim** so nothing is silently hidden from the reader.
|
||||||
|
pub fn humanize_rrule(rrule: &str) -> String {
|
||||||
|
humanize_known(rrule).unwrap_or_else(|| rrule.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallible core: `None` whenever the rule contains anything we don't model,
|
||||||
|
/// so [`humanize_rrule`] can fall back to the raw text.
|
||||||
|
fn humanize_known(rrule: &str) -> Option<String> {
|
||||||
|
let mut freq: Option<String> = None;
|
||||||
|
let mut interval: u32 = 1;
|
||||||
|
let mut byday: Option<String> = None;
|
||||||
|
let mut bymonth: Option<u32> = None;
|
||||||
|
let mut bymonthday: Option<i32> = None;
|
||||||
|
for part in rrule.trim().split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (k, v) = part.split_once('=')?;
|
||||||
|
match k.trim().to_uppercase().as_str() {
|
||||||
|
"FREQ" => freq = Some(v.trim().to_uppercase()),
|
||||||
|
"INTERVAL" => interval = v.trim().parse().ok()?,
|
||||||
|
"BYDAY" => byday = Some(v.trim().to_uppercase()),
|
||||||
|
"BYMONTH" => bymonth = Some(v.trim().parse().ok()?),
|
||||||
|
"BYMONTHDAY" => bymonthday = Some(v.trim().parse().ok()?),
|
||||||
|
// A part we don't render → don't risk a misleading summary.
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match freq?.as_str() {
|
||||||
|
"DAILY" => {
|
||||||
|
if byday.is_some() || bymonth.is_some() || bymonthday.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(every_unit(interval, "day", "days", "daily"))
|
||||||
|
}
|
||||||
|
"WEEKLY" => {
|
||||||
|
if bymonth.is_some() || bymonthday.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match byday {
|
||||||
|
None => Some(every_unit(interval, "week", "weeks", "weekly")),
|
||||||
|
Some(days) => {
|
||||||
|
if interval == 1 && is_weekday_set(&days) {
|
||||||
|
return Some("weekdays".into());
|
||||||
|
}
|
||||||
|
let names = weekday_names(&days)?;
|
||||||
|
if names.len() == 1 {
|
||||||
|
let day = names[0];
|
||||||
|
Some(match interval {
|
||||||
|
1 => format!("every {day}"),
|
||||||
|
2 => format!("every other {day}"),
|
||||||
|
n => format!("every {n} weeks on {day}"),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let joined = names.join(", ");
|
||||||
|
Some(match interval {
|
||||||
|
1 => format!("weekly on {joined}"),
|
||||||
|
2 => format!("every other week on {joined}"),
|
||||||
|
n => format!("every {n} weeks on {joined}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"MONTHLY" => {
|
||||||
|
if byday.is_some() || bymonth.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match bymonthday {
|
||||||
|
None => Some(every_unit(interval, "month", "months", "monthly")),
|
||||||
|
Some(d @ 1..=31) => {
|
||||||
|
let day = ordinal(d as u32);
|
||||||
|
Some(match interval {
|
||||||
|
1 => format!("monthly on the {day}"),
|
||||||
|
2 => format!("every other month on the {day}"),
|
||||||
|
n => format!("every {n} months on the {day}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(_) => None, // negative / out-of-range day-of-month → raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"YEARLY" => {
|
||||||
|
if byday.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match (bymonth, bymonthday) {
|
||||||
|
(None, None) => Some(every_unit(interval, "year", "years", "yearly")),
|
||||||
|
(Some(m @ 1..=12), Some(d @ 1..=31)) => {
|
||||||
|
let mon = MONTH_ABBR[(m - 1) as usize];
|
||||||
|
Some(match interval {
|
||||||
|
1 => format!("yearly on {mon} {d}"),
|
||||||
|
2 => format!("every other year on {mon} {d}"),
|
||||||
|
n => format!("every {n} years on {mon} {d}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_ABBR: [&str; 12] = [
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// `preset` for `n == 1`, `every other <singular>` for 2, `every N <plural>` otherwise.
|
||||||
|
fn every_unit(n: u32, singular: &str, plural: &str, preset: &str) -> String {
|
||||||
|
match n {
|
||||||
|
1 => preset.to_string(),
|
||||||
|
2 => format!("every other {singular}"),
|
||||||
|
n => format!("every {n} {plural}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1st`, `2nd`, `3rd`, `4th`, … `11th`, `21st`, `22nd`.
|
||||||
|
fn ordinal(n: u32) -> String {
|
||||||
|
let suffix = match (n % 10, n % 100) {
|
||||||
|
(_, 11..=13) => "th",
|
||||||
|
(1, _) => "st",
|
||||||
|
(2, _) => "nd",
|
||||||
|
(3, _) => "rd",
|
||||||
|
_ => "th",
|
||||||
|
};
|
||||||
|
format!("{n}{suffix}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MO,TU,WE,TH,FR` in any order (and only those), the inverse of the `weekdays`
|
||||||
|
/// preset.
|
||||||
|
fn is_weekday_set(byday: &str) -> bool {
|
||||||
|
let mut days: Vec<&str> = byday.split(',').map(str::trim).collect();
|
||||||
|
days.sort_unstable();
|
||||||
|
days == ["FR", "MO", "TH", "TU", "WE"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BYDAY` tokens → capitalized weekday abbreviations, order preserved. `None` if
|
||||||
|
/// any token isn't a bare weekday (e.g. an ordinal `2MO`), so the caller falls
|
||||||
|
/// back to the raw rule.
|
||||||
|
fn weekday_names(byday: &str) -> Option<Vec<&'static str>> {
|
||||||
|
byday
|
||||||
|
.split(',')
|
||||||
|
.map(|t| match t.trim() {
|
||||||
|
"MO" => Some("Mon"),
|
||||||
|
"TU" => Some("Tue"),
|
||||||
|
"WE" => Some("Wed"),
|
||||||
|
"TH" => Some("Thu"),
|
||||||
|
"FR" => Some("Fri"),
|
||||||
|
"SA" => Some("Sat"),
|
||||||
|
"SU" => Some("Sun"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -404,4 +570,71 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert!(parse_recurrence("every blue moon").is_err());
|
assert!(parse_recurrence("every blue moon").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_inverts_the_natural_language_forms() {
|
||||||
|
let cases = [
|
||||||
|
("FREQ=DAILY", "daily"),
|
||||||
|
("FREQ=DAILY;INTERVAL=2", "every other day"),
|
||||||
|
("FREQ=DAILY;INTERVAL=3", "every 3 days"),
|
||||||
|
("FREQ=WEEKLY", "weekly"),
|
||||||
|
("FREQ=WEEKLY;INTERVAL=2", "every other week"),
|
||||||
|
("FREQ=MONTHLY", "monthly"),
|
||||||
|
("FREQ=MONTHLY;INTERVAL=6", "every 6 months"),
|
||||||
|
("FREQ=YEARLY", "yearly"),
|
||||||
|
("FREQ=WEEKLY;BYDAY=FR", "every Fri"),
|
||||||
|
("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"),
|
||||||
|
("FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"),
|
||||||
|
("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"),
|
||||||
|
("FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"),
|
||||||
|
("FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"),
|
||||||
|
("FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"),
|
||||||
|
("FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"),
|
||||||
|
("FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"),
|
||||||
|
("FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"),
|
||||||
|
];
|
||||||
|
for (rrule, want) in cases {
|
||||||
|
assert_eq!(humanize_rrule(rrule), want, "humanizing {rrule}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_round_trips_through_parse_recurrence() {
|
||||||
|
// For the interval/weekday forms the display text is itself a valid input:
|
||||||
|
// owner types text → we store an RRULE → we show it back → it re-parses to
|
||||||
|
// the same rule. (The `yearly on Apr 15` / `monthly on the 5th` forms are
|
||||||
|
// tuned for reading, not re-typing — the stored RRULE, never this string,
|
||||||
|
// is what gets parsed — so they're covered by the exact-output test above.)
|
||||||
|
for input in [
|
||||||
|
"every 3 days",
|
||||||
|
"every other day",
|
||||||
|
"every other wed",
|
||||||
|
"weekdays",
|
||||||
|
"every fri",
|
||||||
|
"every 6 months",
|
||||||
|
"every 2 weeks",
|
||||||
|
] {
|
||||||
|
let rrule = parse_recurrence(input).unwrap();
|
||||||
|
let shown = humanize_rrule(&rrule);
|
||||||
|
assert_eq!(
|
||||||
|
parse_recurrence(&shown).unwrap(),
|
||||||
|
rrule,
|
||||||
|
"{input:?} → {rrule:?} → shown {shown:?} must re-parse to the same rule"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_falls_back_to_raw_for_unmodeled_rules() {
|
||||||
|
// COUNT/UNTIL/BYSETPOS and ordinal BYDAY would be misleading if dropped.
|
||||||
|
for raw in [
|
||||||
|
"FREQ=DAILY;COUNT=5",
|
||||||
|
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
|
||||||
|
"FREQ=MONTHLY;BYDAY=2MO",
|
||||||
|
"FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||||
|
"not an rrule at all",
|
||||||
|
] {
|
||||||
|
assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,10 @@ impl Store for RemoteStore {
|
||||||
self.call_as("health", json!({}))
|
self.call_as("health", json!({}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project_overview(&self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||||
|
self.call_as("project.overview", json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||||
self.call_as("search", json!({ "query": query }))
|
self.call_as("search", json!({ "query": query }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
||||||
let p: NodeListParams = parse(params)?;
|
let p: NodeListParams = parse(params)?;
|
||||||
json!(store.list_nodes(p.kind)?)
|
json!(store.list_nodes(p.kind)?)
|
||||||
}
|
}
|
||||||
|
"project.overview" => json!(store.project_overview()?),
|
||||||
"task.create" => {
|
"task.create" => {
|
||||||
let p: NewTask = parse(params)?;
|
let p: NewTask = parse(params)?;
|
||||||
json!(store.create_task(p)?)
|
json!(store.create_task(p)?)
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,17 @@
|
||||||
//! ops with the configured hub (tech-spec §6.1, §12).
|
//! ops with the configured hub (tech-spec §6.1, §12).
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
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};
|
||||||
|
|
@ -32,6 +34,23 @@ struct SpokeAuth {
|
||||||
client_id: String,
|
client_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A spoke's observed sync health, updated after every exchange (background loop
|
||||||
|
/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether
|
||||||
|
/// sync is actually working instead of trusting silence (tech-spec §3.1 / the
|
||||||
|
/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet".
|
||||||
|
#[derive(Clone, Default, Serialize)]
|
||||||
|
struct SyncHealth {
|
||||||
|
/// When we last attempted an exchange.
|
||||||
|
last_attempt_ms: Option<i64>,
|
||||||
|
/// When we last completed one without error (the "last synced" time).
|
||||||
|
last_success_ms: Option<i64>,
|
||||||
|
/// The last error message, cleared on the next success.
|
||||||
|
last_error: Option<String>,
|
||||||
|
/// Whether the most recent attempt failed authentication (a 401) — the
|
||||||
|
/// "re-auth needed" signal, distinct from a transient network blip.
|
||||||
|
auth_failure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared, cheaply-cloneable context each connection serves from.
|
/// The shared, cheaply-cloneable context each connection serves from.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Ctx {
|
struct Ctx {
|
||||||
|
|
@ -43,24 +62,113 @@ struct Ctx {
|
||||||
auth: Option<SpokeAuth>,
|
auth: Option<SpokeAuth>,
|
||||||
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
|
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
|
||||||
self_update: Option<SelfUpdateConfig>,
|
self_update: Option<SelfUpdateConfig>,
|
||||||
|
/// Live sync health, shared between the background loop and `sync.status`.
|
||||||
|
sync_health: Arc<Mutex<SyncHealth>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `e` carries an HTTP 401 — i.e. the hub rejected our bearer token.
|
||||||
|
fn is_auth_error(e: &anyhow::Error) -> bool {
|
||||||
|
e.downcast_ref::<reqwest::Error>()
|
||||||
|
.and_then(|re| re.status())
|
||||||
|
.is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact `heph auth login …` command that re-authenticates this spoke, built
|
||||||
|
/// 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 mut h = ctx.sync_health.lock().expect("sync_health mutex poisoned");
|
||||||
|
h.last_attempt_ms = Some(now);
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
h.last_success_ms = Some(now);
|
||||||
|
h.last_error = None;
|
||||||
|
h.auth_failure = false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let auth_failure = is_auth_error(e);
|
||||||
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +195,7 @@ impl Daemon {
|
||||||
.expect("building the daemon HTTP client"),
|
.expect("building the daemon HTTP client"),
|
||||||
auth: None,
|
auth: None,
|
||||||
self_update: None,
|
self_update: None,
|
||||||
|
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,8 +278,21 @@ impl Daemon {
|
||||||
let mut tick = tokio::time::interval(interval);
|
let mut tick = tokio::time::interval(interval);
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
let bearer = ctx.bearer().await;
|
let bearer = match ctx.bearer().await {
|
||||||
match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).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);
|
||||||
|
tracing::warn!("background sync: could not obtain bearer token: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result =
|
||||||
|
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
|
||||||
|
record_sync_outcome(&ctx, &result);
|
||||||
|
match result {
|
||||||
Ok(report) => tracing::debug!(?report, "background sync"),
|
Ok(report) => tracing::debug!(?report, "background sync"),
|
||||||
Err(e) => tracing::warn!("background sync failed: {e}"),
|
Err(e) => tracing::warn!("background sync failed: {e}"),
|
||||||
}
|
}
|
||||||
|
|
@ -264,8 +386,26 @@ 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 {
|
||||||
match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).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;
|
||||||
|
record_sync_outcome(ctx, &result);
|
||||||
|
match result {
|
||||||
Ok(report) => Ok(json!(report)),
|
Ok(report) => Ok(json!(report)),
|
||||||
Err(e) => Err(RpcError {
|
Err(e) => Err(RpcError {
|
||||||
code: INTERNAL_ERROR,
|
code: INTERNAL_ERROR,
|
||||||
|
|
@ -274,11 +414,28 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `sync.status` — the hub url and the current per-hub cursors.
|
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync
|
||||||
|
/// health (last-success time / last error / auth-failure flag), and the pending
|
||||||
|
/// merge-conflict count. A spoke that is silently failing is visible here (and,
|
||||||
|
/// via it, in the TUI status line).
|
||||||
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.
|
||||||
|
let store = ctx.store.clone();
|
||||||
|
let conflicts = tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = store.lock().expect("store mutex poisoned");
|
||||||
|
guard.conflicts_list().map(|c| c.len())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RpcError {
|
||||||
|
code: INTERNAL_ERROR,
|
||||||
|
message: format!("sync.status task failed: {e}"),
|
||||||
|
})?
|
||||||
|
.map_err(RpcError::from)?;
|
||||||
|
|
||||||
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 }));
|
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = ctx.store.clone();
|
let store = ctx.store.clone();
|
||||||
let hub = hub_url.clone();
|
let hub = hub_url.clone();
|
||||||
let cursors = tokio::task::spawn_blocking(move || {
|
let cursors = tokio::task::spawn_blocking(move || {
|
||||||
|
|
@ -291,5 +448,29 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||||
message: format!("sync.status task failed: {e}"),
|
message: format!("sync.status task failed: {e}"),
|
||||||
})?
|
})?
|
||||||
.map_err(RpcError::from)?;
|
.map_err(RpcError::from)?;
|
||||||
Ok(json!({ "hub_url": hub_url, "cursors": cursors }))
|
|
||||||
|
let health = ctx
|
||||||
|
.sync_health
|
||||||
|
.lock()
|
||||||
|
.expect("sync_health mutex poisoned")
|
||||||
|
.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!({
|
||||||
|
"hub_url": hub_url,
|
||||||
|
"cursors": cursors,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"health": health,
|
||||||
|
"auth": auth,
|
||||||
|
"reauth_command": reauth_command(Some(&hub_url), ctx.auth.as_ref()),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ pub fn router_with_web(
|
||||||
.route("/sync/push", post(push))
|
.route("/sync/push", post(push))
|
||||||
.route("/rpc", post(rpc_call))
|
.route("/rpc", post(rpc_call))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||||||
|
// Unauthenticated: the public OIDC params (issuer + client id) a browser
|
||||||
|
// client reads to start a PKCE login. Added after the auth `route_layer`
|
||||||
|
// so it is NOT gated — the app needs it *before* it has a token.
|
||||||
|
.route("/config", get(config))
|
||||||
// The static shell is unauthenticated and lives behind the API routes.
|
// The static shell is unauthenticated and lives behind the API routes.
|
||||||
.fallback(serve_static)
|
.fallback(serve_static)
|
||||||
// Outermost: stamp CORS headers on every response and short-circuit the
|
// Outermost: stamp CORS headers on every response and short-circuit the
|
||||||
|
|
@ -174,6 +178,20 @@ async fn cors(request: Request, next: Next) -> AxumResponse {
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public OIDC parameters for a browser client (the `heph-pwa`) to start a PKCE
|
||||||
|
/// login: `{ "issuer", "client_id" }`. Unauthenticated — neither value is a
|
||||||
|
/// secret. Returns an empty object `{}` when the hub runs without OIDC, so the
|
||||||
|
/// app can detect that and fall back to a manually pasted token.
|
||||||
|
async fn config(State(state): State<HubState>) -> Json<Value> {
|
||||||
|
let body = state
|
||||||
|
.verifier
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.oidc_config())
|
||||||
|
.map(|(issuer, client_id)| serde_json::json!({ "issuer": issuer, "client_id": client_id }))
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
Json(body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no
|
/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no
|
||||||
/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA
|
/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA
|
||||||
/// can own its own routing. Path traversal (`..`) is rejected.
|
/// can own its own routing. Path traversal (`..`) is rejected.
|
||||||
|
|
@ -243,8 +261,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
|
||||||
|
|
|
||||||
96
crates/hephd/tests/client_reconnect.rs
Normal file
96
crates/hephd/tests/client_reconnect.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! [`Client`] survives the daemon dropping the socket (opt-in self-update, `heph
|
||||||
|
//! daemon restart`). A mock daemon serves exactly one request per connection
|
||||||
|
//! then closes it, forcing the client to reconnect — without auto-reconnect,
|
||||||
|
//! every call after the first would fail forever.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use hephd::Client;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// A mock daemon that handles ONE request per connection then closes it, looping
|
||||||
|
/// to accept the next connection. `served` counts total requests answered.
|
||||||
|
fn spawn_one_shot_daemon(socket: PathBuf, served: Arc<AtomicUsize>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
for conn in listener.incoming() {
|
||||||
|
let Ok(mut stream) = conn else { continue };
|
||||||
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||||
|
continue; // client opened then went away; wait for the next one
|
||||||
|
}
|
||||||
|
let req: Value = serde_json::from_str(&line).unwrap();
|
||||||
|
let n = served.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let mut out = serde_json::to_string(&json!({
|
||||||
|
"id": req["id"],
|
||||||
|
"result": { "served": n },
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
out.push('\n');
|
||||||
|
let _ = stream.write_all(out.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
// `stream` drops here → the connection closes after one request.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for(socket: &std::path::Path) {
|
||||||
|
for _ in 0..400 {
|
||||||
|
if socket.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
panic!("mock daemon socket never appeared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_reconnects_after_the_daemon_drops_the_socket() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let socket = dir.path().join("d.sock");
|
||||||
|
let served = Arc::new(AtomicUsize::new(0));
|
||||||
|
spawn_one_shot_daemon(socket.clone(), served.clone());
|
||||||
|
wait_for(&socket);
|
||||||
|
|
||||||
|
let mut c = Client::connect(&socket).unwrap();
|
||||||
|
|
||||||
|
// First call works on the initial connection.
|
||||||
|
let r1 = c.call("ping", json!({})).unwrap();
|
||||||
|
assert_eq!(r1["served"], 1);
|
||||||
|
|
||||||
|
// The daemon has now closed that connection. With reconnect, the client
|
||||||
|
// recovers within a call or two (depending on whether the dead socket fails
|
||||||
|
// on write or on read); without it, every further call would fail forever.
|
||||||
|
let mut recovered = None;
|
||||||
|
for _ in 0..2 {
|
||||||
|
if let Ok(v) = c.call("ping", json!({})) {
|
||||||
|
recovered = Some(v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let r = recovered.expect("client should reconnect after the socket was dropped");
|
||||||
|
// The recovered call was served exactly once on the new connection — no
|
||||||
|
// double-serve from a spurious retry.
|
||||||
|
assert_eq!(r["served"], 2);
|
||||||
|
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
// And it keeps working across subsequent drops.
|
||||||
|
let r3 = {
|
||||||
|
let mut got = None;
|
||||||
|
for _ in 0..2 {
|
||||||
|
if let Ok(v) = c.call("ping", json!({})) {
|
||||||
|
got = Some(v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got.expect("client should keep reconnecting")
|
||||||
|
};
|
||||||
|
assert_eq!(r3["served"], 3);
|
||||||
|
}
|
||||||
|
|
@ -90,11 +90,25 @@ async fn token(State(s): State<IdpState>, Form(form): Form<HashMap<String, Strin
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Some("refresh_token") => Json(json!({
|
Some("refresh_token") => {
|
||||||
"access_token": "access-2",
|
// A rotated/expired refresh token is refused with `400 invalid_grant`
|
||||||
"expires_in": 3600,
|
// (RFC 6749 §5.2) — the case that used to be mislabeled "unreachable".
|
||||||
}))
|
if form.get("refresh_token").map(String::as_str) == Some("refresh-expired") {
|
||||||
.into_response(),
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Token is not active",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Json(json!({
|
||||||
|
"access_token": "access-2",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}))
|
||||||
|
.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();
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,23 @@ use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use heph_core::{FixedClock, LocalStore};
|
use heph_core::{FixedClock, LocalStore};
|
||||||
|
use hephd::auth::{AuthError, Claims, TokenVerifier};
|
||||||
use hephd::sync::{self, SharedStore};
|
use hephd::sync::{self, SharedStore};
|
||||||
|
|
||||||
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
|
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
|
||||||
|
|
||||||
|
/// A verifier that never admits a token but advertises OIDC params, so we can
|
||||||
|
/// drive the unauthenticated `/config` route without a live IdP.
|
||||||
|
struct StubOidc;
|
||||||
|
impl TokenVerifier for StubOidc {
|
||||||
|
fn verify(&self, _bearer: &str) -> Result<Claims, AuthError> {
|
||||||
|
Err(AuthError::Missing)
|
||||||
|
}
|
||||||
|
fn oidc_config(&self) -> Option<(&str, &str)> {
|
||||||
|
Some(("https://idp.example/application/o/heph/", "heph"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One parsed HTTP response: status line code, lowercased headers, and body.
|
/// One parsed HTTP response: status line code, lowercased headers, and body.
|
||||||
struct Resp {
|
struct Resp {
|
||||||
status: u16,
|
status: u16,
|
||||||
|
|
@ -64,6 +77,15 @@ fn request(addr: &str, method: &str, path: &str) -> Resp {
|
||||||
/// an ephemeral port; return its `host:port`. The server thread + temp dirs live
|
/// an ephemeral port; return its `host:port`. The server thread + temp dirs live
|
||||||
/// for the test's duration.
|
/// for the test's duration.
|
||||||
fn start(web_root: Option<std::path::PathBuf>) -> String {
|
fn start(web_root: Option<std::path::PathBuf>) -> String {
|
||||||
|
start_with(None, web_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`start`], but with an explicit token verifier (to exercise the `/config`
|
||||||
|
/// route, which reports the verifier's OIDC params).
|
||||||
|
fn start_with(
|
||||||
|
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||||
|
web_root: Option<std::path::PathBuf>,
|
||||||
|
) -> String {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
|
@ -78,7 +100,7 @@ fn start(web_root: Option<std::path::PathBuf>) -> String {
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
tx.send(listener.local_addr().unwrap()).unwrap();
|
tx.send(listener.local_addr().unwrap()).unwrap();
|
||||||
let _keep = dir;
|
let _keep = dir;
|
||||||
let app = sync::router_with_web(shared, None, web_root);
|
let app = sync::router_with_web(shared, verifier, web_root);
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -161,3 +183,31 @@ fn no_web_root_yields_404_for_static_paths() {
|
||||||
// Even the 404 carries CORS headers (it passed through the layer).
|
// Even the 404 carries CORS headers (it passed through the layer).
|
||||||
assert_eq!(resp.header("access-control-allow-origin"), Some("*"));
|
assert_eq!(resp.header("access-control-allow-origin"), Some("*"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_is_empty_without_oidc() {
|
||||||
|
let addr = start(None);
|
||||||
|
let resp = request(&addr, "GET", "/config");
|
||||||
|
assert_eq!(resp.status, 200);
|
||||||
|
assert_eq!(resp.body.trim(), "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_reports_oidc_params_unauthenticated() {
|
||||||
|
// Even on an authed hub, /config is reachable without a token (it is added
|
||||||
|
// after the auth layer) and reports the issuer + public client id.
|
||||||
|
let addr = start_with(Some(Arc::new(StubOidc)), None);
|
||||||
|
let resp = request(&addr, "GET", "/config");
|
||||||
|
assert_eq!(resp.status, 200);
|
||||||
|
assert!(
|
||||||
|
resp.body
|
||||||
|
.contains("\"issuer\":\"https://idp.example/application/o/heph/\""),
|
||||||
|
"body was: {}",
|
||||||
|
resp.body
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resp.body.contains("\"client_id\":\"heph\""),
|
||||||
|
"body was: {}",
|
||||||
|
resp.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ Background context and design decisions.
|
||||||
|
|
||||||
- [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap
|
- [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap
|
||||||
- [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up
|
- [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up
|
||||||
|
- [[hub-spoke-data-evolution]] — why op-based sync lets most new features skip migrations, and when a coordinated SQLite migration is actually required
|
||||||
|
|
|
||||||
87
docs/explanation/hub-spoke-data-evolution.md
Normal file
87
docs/explanation/hub-spoke-data-evolution.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
---
|
||||||
|
title: Hub + Spoke Data Evolution
|
||||||
|
modified: 2026-06-05
|
||||||
|
tags:
|
||||||
|
- explanation
|
||||||
|
- sync
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hub + Spoke Data Evolution
|
||||||
|
|
||||||
|
How the data model evolves safely when nodes run different versions across the
|
||||||
|
hub/spoke deployment (indri is the hub; see [[set-up-sync-hub]] and
|
||||||
|
[[host-heph-pwa]]). The short version: **sync is op-based, not schema-based**, so
|
||||||
|
most new features need no coordinated migration — but adding a SQLite *column*
|
||||||
|
does.
|
||||||
|
|
||||||
|
## Two independent layers
|
||||||
|
|
||||||
|
heph keeps two layers that evolve on different clocks:
|
||||||
|
|
||||||
|
1. **The op-log (synced).** Every change is an operation — `node.create`,
|
||||||
|
`node.set`, `task.set`, `link.add`, `link.remove`, … — carrying an HLC, an
|
||||||
|
origin device, and a JSON payload. Spokes push/pull ops to/from the hub; both
|
||||||
|
sides run the **same** merge logic from `heph-core` (`sqlite/apply.rs`). This
|
||||||
|
is the only thing that crosses the wire.
|
||||||
|
2. **The SQLite schema (local, per node).** Each node materializes ops into local
|
||||||
|
tables. The schema version is tracked by SQLite's `PRAGMA user_version` and
|
||||||
|
advanced by the ordered, append-only migration list in
|
||||||
|
`heph-core/src/sqlite/migrations.rs`. **No schema or migration state is ever
|
||||||
|
synced.** A spoke can sit on an older schema than the hub indefinitely.
|
||||||
|
|
||||||
|
Because the wire format is ops — not rows — a node only has to understand the
|
||||||
|
*ops* its peers emit, not their table layout.
|
||||||
|
|
||||||
|
## What forward/backward compatibility already buys you
|
||||||
|
|
||||||
|
The merge engine is deliberately lenient:
|
||||||
|
|
||||||
|
- **Unknown op types are stored but not applied** (`apply.rs`) — a spoke that
|
||||||
|
receives a newer op type keeps it in the log (so a later upgrade can replay it)
|
||||||
|
but doesn't choke on it.
|
||||||
|
- **Unknown payload fields are ignored.** Field extraction is by name
|
||||||
|
(`str_field` / `i64_field`), so a payload with extra keys an older node doesn't
|
||||||
|
recognize just drops the extras.
|
||||||
|
- **Links are schema-free.** A link's `type` is a string column. A brand-new link
|
||||||
|
kind (a new `LinkType`) needs no migration — every version reads it as text and
|
||||||
|
applies OR-set add/remove identically.
|
||||||
|
|
||||||
|
## The rule of thumb
|
||||||
|
|
||||||
|
| Change | Needs coordinated migration? |
|
||||||
|
|--------|------------------------------|
|
||||||
|
| New `LinkType` (e.g. a new relationship between nodes) | **No** — just emit `link.add` with the new `type` string |
|
||||||
|
| New optional/nullable scalar carried in an op payload | **No, if** every node's `apply` reads it defensively and tolerates its absence |
|
||||||
|
| New *read-side* feature over existing data (counts, hierarchy from existing `parent` links) | **No** — pure local queries, no op or schema change |
|
||||||
|
| New **required** SQLite column that `apply` must write on every relevant op | **Yes** — old spokes lack the column and the `UPDATE` fails |
|
||||||
|
| Renaming/removing a column other nodes' `apply` paths reference | **Yes** |
|
||||||
|
|
||||||
|
## When a migration *is* required, do it hub-first
|
||||||
|
|
||||||
|
If a change genuinely needs a new column that the apply path writes:
|
||||||
|
|
||||||
|
1. Ship the migration to **every** node (hub and all spokes) **before** any node
|
||||||
|
emits an op that depends on the new column. The migration list is
|
||||||
|
append-only and ordered, so rolling the new `hephd` out everywhere is the
|
||||||
|
gate.
|
||||||
|
2. Keep new columns **nullable / defaulted** so an op that predates the column
|
||||||
|
still applies, and so a node that hasn't yet upgraded degrades to "field
|
||||||
|
absent" rather than erroring.
|
||||||
|
3. Prefer encoding the new fact as a **link or an op-payload field** over a new
|
||||||
|
column whenever you can — that keeps the change in the no-migration column of
|
||||||
|
the table above.
|
||||||
|
|
||||||
|
## Worked example: indented, counted projects
|
||||||
|
|
||||||
|
The sidebar's subproject indentation and per-project task counts (see
|
||||||
|
[[install-heph]] and the agenda surface in [[design]] §8.1) are a pure read-side
|
||||||
|
feature:
|
||||||
|
|
||||||
|
- **Nesting** is read from `parent` links that already exist — created by
|
||||||
|
`heph project add <name> --parent <parent>` — via the existing
|
||||||
|
`project_subtree` traversal.
|
||||||
|
- **Counts** are a read-only `SELECT … GROUP BY` over the `tasks`/`links` tables.
|
||||||
|
|
||||||
|
No new column, no new op type, no migration — it works against a hub and a spoke
|
||||||
|
on any schema version that already understands `parent` links. That is the case
|
||||||
|
the rule of thumb is meant to make obvious.
|
||||||
|
|
@ -95,16 +95,24 @@ app defaults its hub URL to its own origin.
|
||||||
1. Ensure the phone is on the tailnet (or can reach the proxy).
|
1. Ensure the phone is on the tailnet (or can reach the proxy).
|
||||||
2. Open the hub URL (`https://indri.<tailnet>.ts.net/`) and **Add to Home Screen**.
|
2. Open the hub URL (`https://indri.<tailnet>.ts.net/`) and **Add to Home Screen**.
|
||||||
3. The app defaults its **Hub URL** to the origin it loaded from — no typing.
|
3. The app defaults its **Hub URL** to the origin it loaded from — no typing.
|
||||||
4. **Token:** the hub requires an OIDC bearer token, and the PWA does **not yet
|
4. **Sign in:** open **Settings → Login with Authentik**. The app reads the
|
||||||
implement the in-app device-code login** — paste a token into Settings →
|
hub's `GET /config` for the issuer + client id (zero-config) and runs an
|
||||||
Token for now. Obtain one via the device-code flow against the Authentik
|
Authorization-Code + PKCE redirect to Authentik; after you approve it lands
|
||||||
client (the same flow the CLI uses; e.g. reuse the access token a logged-in
|
back on the app, signed in, and silently refreshes the token from then on.
|
||||||
spoke cached, or run a one-off device-code grant). Tap **Test** to confirm.
|
(A manual **Bearer token** field remains as a fallback for hubs without
|
||||||
|
OIDC, or for pasting a one-off token.)
|
||||||
|
|
||||||
> **Known gap / next step:** wire the RFC 8628 device-code flow into the PWA's
|
> Re-prompted for login too often? The fix is the Authentik provider's
|
||||||
> Settings so login is in-app (open the verification URL, poll for the token,
|
> **refresh token validity**, not the app — see the token-lifetime note in
|
||||||
> store it, and refresh it) — removing the manual paste. Tracked as follow-up
|
> [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's
|
||||||
> work for `heph-pwa`.
|
> storage after ~7 idle days; Add to Home Screen mitigates it.)
|
||||||
|
|
||||||
|
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
|
||||||
|
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
|
||||||
|
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a
|
||||||
|
trailing slash, e.g. `https://heph.ops.eblu.me/` (and `http://localhost:8787/`
|
||||||
|
for local dev). In blumeops this is the `redirect_uris` list on the heph
|
||||||
|
provider blueprint.
|
||||||
|
|
||||||
## Upgrades
|
## Upgrades
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,47 @@ when it's already stopped is fine.
|
||||||
> exits cleanly to hand off to the new binary) wouldn't come back on Linux. Run
|
> exits cleanly to hand off to the new binary) wouldn't come back on Linux. Run
|
||||||
> `heph daemon restart` once (it regenerates the unit) to pick up `Restart=always`.
|
> `heph daemon restart` once (it regenerates the unit) to pick up `Restart=always`.
|
||||||
|
|
||||||
Either way it runs `hephd --mode local` against the default store
|
By default it runs `hephd --mode local` against the default store
|
||||||
(`~/.local/share/heph/heph.db`) and socket, with logs at
|
(`~/.local/share/heph/heph.db`) and socket, with logs at
|
||||||
`~/.local/share/heph/hephd.log`.
|
`~/.local/share/heph/hephd.log`. Pass flags to `start`/`restart` to bake a
|
||||||
|
different runtime config into the service (see below).
|
||||||
|
|
||||||
> **`stop` vs `uninstall`:** `stop` halts the daemon now, but the service is
|
> **`stop` vs `uninstall`:** `stop` halts the daemon now, but the service is
|
||||||
> still installed, so on macOS it starts again at next login. Use `uninstall`
|
> still installed, so on macOS it starts again at next login. Use `uninstall`
|
||||||
> to stop it persistently.
|
> to stop it persistently.
|
||||||
|
|
||||||
|
## Baking sync config (spoke / hub)
|
||||||
|
|
||||||
|
By default the service runs a standalone `--mode local` daemon. To make the
|
||||||
|
managed service a **spoke** (background-syncs to a hub) or a **hub** (`--mode
|
||||||
|
server`), pass the corresponding `hephd` flags to `start` (or `restart`) — they
|
||||||
|
get baked into the generated plist/unit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Spoke: sync to a hub, authenticating with OIDC
|
||||||
|
heph daemon start \
|
||||||
|
--hub-url http://hub.example:8787 \
|
||||||
|
--oidc-issuer https://idp.example/application/o/heph/ \
|
||||||
|
--oidc-client-id heph
|
||||||
|
|
||||||
|
# Hub: expose the authenticated sync endpoint
|
||||||
|
heph daemon start --mode server \
|
||||||
|
--http-addr 0.0.0.0:8787 \
|
||||||
|
--oidc-issuer https://idp.example/application/o/heph/ \
|
||||||
|
--oidc-audience heph
|
||||||
|
```
|
||||||
|
|
||||||
|
Bakeable flags: `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`,
|
||||||
|
`--oidc-audience`, `--oidc-client-id`, `--self-update`,
|
||||||
|
`--self-update-interval-secs`. **Regenerating preserves what's already baked
|
||||||
|
in** — `start`/`restart` read the existing service file and carry over any flags
|
||||||
|
you don't pass, so a bare `heph daemon restart` never drops your spoke/hub or
|
||||||
|
self-update config. Pass a flag again to add or override it.
|
||||||
|
|
||||||
|
> Spoke sync is HTTP-only today (`hephd`'s sync client doesn't speak HTTPS) — a
|
||||||
|
> `--hub-url` over the tailnet or behind a TLS-terminating proxy is the usual
|
||||||
|
> setup.
|
||||||
|
|
||||||
## After upgrading
|
## After upgrading
|
||||||
|
|
||||||
When you rebuild/reinstall (`cargo install … --force`), the running daemon is
|
When you rebuild/reinstall (`cargo install … --force`), the running daemon is
|
||||||
|
|
@ -53,15 +86,25 @@ 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
|
||||||
service that polls the forge for newer releases and, when one appears, rebuilds
|
service that polls the forge for newer releases and, when one appears, rebuilds
|
||||||
via `cargo install` (anonymous HTTPS clone of the public repo — no credentials)
|
via `cargo install` (anonymous HTTPS clone of the public repo — no credentials)
|
||||||
and restarts onto the new binary. It is **off by default**; the generated
|
and restarts onto the new binary. It is **off by default**; the generated
|
||||||
service also gets a `PATH` that can find cargo. `heph daemon restart` preserves
|
service also gets a `PATH` that can find cargo. Override the 6h poll cadence with
|
||||||
the setting (pass `--self-update` again to turn it on later). Requires the Rust
|
`--self-update-interval-secs <secs>`. Both `start` and `restart` preserve an
|
||||||
toolchain (`cargo`) installed for the service user.
|
already-baked self-update setting (and its interval), so a bare invocation won't
|
||||||
|
silently disable it — pass `--self-update` again only to turn it on later.
|
||||||
|
Requires the Rust toolchain (`cargo`) installed for the service user.
|
||||||
|
|
||||||
## Development isolation
|
## Development isolation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,17 @@ heph daemon start --self-update
|
||||||
```
|
```
|
||||||
|
|
||||||
That generates a launchd/systemd service that runs `hephd --self-update` and
|
That generates a launchd/systemd service that runs `hephd --self-update` and
|
||||||
gives it a `PATH` that can find `cargo`. `heph daemon restart` preserves the
|
gives it a `PATH` that can find `cargo`. Override the 6h poll cadence with
|
||||||
setting (pass `--self-update` again to turn it on later). To run the daemon
|
`--self-update-interval-secs <secs>`:
|
||||||
directly instead:
|
|
||||||
|
```bash
|
||||||
|
heph daemon start --self-update # default: poll every 6h
|
||||||
|
heph daemon start --self-update --self-update-interval-secs 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `start` and `restart` preserve an already-baked setting (the flag and its
|
||||||
|
interval), so a bare invocation won't silently disable it — pass `--self-update`
|
||||||
|
again only to turn it on later. To run the daemon directly instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hephd --self-update # default: poll every 6h
|
hephd --self-update # default: poll every 6h
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,26 @@ need:
|
||||||
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
|
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
|
||||||
- **Client id** — the device-code client id (this is also the token *audience*).
|
- **Client id** — the device-code client id (this is also the token *audience*).
|
||||||
|
|
||||||
|
### Token lifetime (avoid frequent re-logins)
|
||||||
|
|
||||||
|
Token lifetimes are set on the Authentik **provider**, not in heph — heph honors
|
||||||
|
whatever `expires_in` Authentik returns and silently refreshes using the
|
||||||
|
`offline_access` refresh token (both the CLI/daemon and the PWA do this). To
|
||||||
|
avoid re-authenticating often, set generous validities on the heph provider:
|
||||||
|
|
||||||
|
- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps
|
||||||
|
no revocation list, so this is the window in which a leaked token stays usable;
|
||||||
|
on a Tailscale-only hub, 24–48h is a reasonable trade.
|
||||||
|
- **Refresh token validity** — e.g. `days=30`+. This is the setting that stops
|
||||||
|
the re-logins: while the refresh token is valid, the spoke **and** the PWA
|
||||||
|
renew silently with no browser round-trip. A short refresh window is the usual
|
||||||
|
cause of "I have to log in constantly".
|
||||||
|
|
||||||
|
> **iOS PWA caveat:** Safari can purge an *un-installed* PWA's `localStorage`
|
||||||
|
> (where its tokens live) after ~7 idle days regardless of these settings.
|
||||||
|
> Installing the app to the home screen mitigates it, but expect the occasional
|
||||||
|
> re-login on iOS.
|
||||||
|
|
||||||
## 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).** Quiesce `gilbert` (`heph daemon stop`),
|
||||||
|
|
@ -98,25 +118,53 @@ and background-syncs on its interval.
|
||||||
## 4. Verify
|
## 4. Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
heph sync --status # last push/pull cursors, hub url
|
heph sync --status # hub url, last push/pull cursors, sync health
|
||||||
heph sync # force a cycle now
|
heph sync # force a cycle now
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`heph sync --status` also reports **sync health** — the time of the last
|
||||||
|
successful exchange, any last error, and whether the spoke is currently failing
|
||||||
|
to authenticate. The same signal is surfaced live in `heph-tui`'s status line
|
||||||
|
(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken
|
||||||
|
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.
|
||||||
|
|
||||||
|
### When sync stops authenticating
|
||||||
|
|
||||||
|
A spoke's refresh token can expire or be rotated (e.g. the IdP session lapses).
|
||||||
|
The spoke then can't refresh on its own and needs a re-login — but this is
|
||||||
|
**visible, not silent**:
|
||||||
|
|
||||||
|
- `heph-tui` shows a red `⚠ auth · heph auth status` chip in the status line.
|
||||||
|
- `heph auth status` prints the auth health and the **exact** re-login command,
|
||||||
|
pre-filled with this spoke's hub URL / issuer / client id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
heph auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
## Current gaps (finalized by the blumeops deployment)
|
## Current gaps (finalized by the blumeops deployment)
|
||||||
|
|
||||||
The flag-level flow above works today; two enablers make it a clean, managed
|
The flag-level flow above works today; one enabler makes it a clean, managed
|
||||||
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
|
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
|
||||||
|
|
||||||
- **`heph daemon` only generates a `--mode local` service** (no `--hub-url` /
|
|
||||||
`--oidc-*`). So for now the hub and the spoke config are expressed as `hephd`
|
|
||||||
flags (run directly, or via the blumeops-managed systemd unit), not via
|
|
||||||
`heph daemon start`.
|
|
||||||
- **Path A seeding is manual** (copy the store + reset the device origin). A
|
- **Path A seeding is manual** (copy the store + reset the device origin). A
|
||||||
small enabler — seed a hub from a snapshot with a fresh origin, or
|
small enabler — seed a hub from a snapshot with a fresh origin, or
|
||||||
`hephd --owner-id` — would make this one step.
|
`hephd --owner-id` — would make this one step.
|
||||||
|
|
||||||
|
> `heph daemon start`/`restart` can now bake the spoke/hub config (`--hub-url`,
|
||||||
|
> `--mode server`, `--http-addr`, `--oidc-*`) into the generated service (see
|
||||||
|
> [[run-the-daemon]]). The canonical hub on `indri` is still provisioned via the
|
||||||
|
> blumeops-managed systemd unit by deployment choice, not because `heph daemon`
|
||||||
|
> can't express it.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [[run-the-daemon]] — manage the local daemon as an OS service
|
- [[run-the-daemon]] — manage the local daemon as an OS service
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@
|
||||||
// rpc.js). Context/KB is read-only here (no nvim editing surface).
|
// rpc.js). Context/KB is read-only here (no nvim editing surface).
|
||||||
|
|
||||||
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
|
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
|
||||||
|
import * as oauth from "./oauth.js";
|
||||||
import { parse as quickParse } from "./quickadd.js";
|
import { parse as quickParse } from "./quickadd.js";
|
||||||
import { today, parseDate, toEpochMs } from "./datespec.js";
|
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
||||||
import {
|
import {
|
||||||
ATTENTION_COLORS,
|
ATTENTION_COLORS,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
|
|
@ -40,7 +41,29 @@ const state = {
|
||||||
lastUndo: null, // { label, run }
|
lastUndo: null, // { label, run }
|
||||||
};
|
};
|
||||||
|
|
||||||
state.client = new Client(state.settings);
|
// Build the RPC client from the current settings, wiring an OIDC silent-refresh
|
||||||
|
// hook: on a 401 the client calls this to renew the token (oauth.js) and retry
|
||||||
|
// once before surfacing the error.
|
||||||
|
function makeClient() {
|
||||||
|
return new Client({
|
||||||
|
baseUrl: state.settings.baseUrl,
|
||||||
|
token: state.settings.token,
|
||||||
|
refresh: async () => {
|
||||||
|
const tok = await oauth.ensureFreshToken(true);
|
||||||
|
applyToken(tok || "");
|
||||||
|
return tok;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt `token` as the active bearer: persist it and rebuild the client.
|
||||||
|
function applyToken(token) {
|
||||||
|
state.settings.token = token || "";
|
||||||
|
saveSettings(state.settings);
|
||||||
|
state.client = makeClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.client = makeClient();
|
||||||
|
|
||||||
// --- tiny DOM helper --------------------------------------------------------
|
// --- tiny DOM helper --------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -189,7 +212,7 @@ function taskRow(t) {
|
||||||
function taskDetail(t) {
|
function taskDetail(t) {
|
||||||
const meta = [];
|
const meta = [];
|
||||||
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
|
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
|
||||||
if (t.recurrence) meta.push(["recurs", t.recurrence]);
|
if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]);
|
||||||
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
|
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
|
||||||
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
|
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
|
||||||
|
|
||||||
|
|
@ -350,7 +373,7 @@ function openQuickAdd() {
|
||||||
}
|
}
|
||||||
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)));
|
||||||
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + parsed.recurrence));
|
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence)));
|
||||||
};
|
};
|
||||||
input.addEventListener("input", updatePreview);
|
input.addEventListener("input", updatePreview);
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
|
|
@ -692,27 +715,62 @@ function openSettings() {
|
||||||
const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" });
|
const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" });
|
||||||
const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" });
|
const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" });
|
||||||
const test = h("div", { class: "settings-test" });
|
const test = h("div", { class: "settings-test" });
|
||||||
|
const setTest = (msg, ok) => {
|
||||||
|
test.textContent = msg;
|
||||||
|
test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : "");
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
state.settings.baseUrl = url.value.trim();
|
state.settings.baseUrl = url.value.trim();
|
||||||
state.settings.token = tok.value.trim();
|
state.settings.token = tok.value.trim();
|
||||||
saveSettings(state.settings);
|
saveSettings(state.settings);
|
||||||
state.client = new Client(state.settings);
|
state.client = makeClient();
|
||||||
closeModal();
|
closeModal();
|
||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
test.textContent = "Checking…";
|
setTest("Checking…", null);
|
||||||
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
|
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
|
||||||
try {
|
try {
|
||||||
const v = await probe.call("version", {});
|
const v = await probe.call("version", {});
|
||||||
test.textContent = `✓ Connected (hephd ${v.version})`;
|
setTest(`✓ Connected (hephd ${v.version})`, true);
|
||||||
test.className = "settings-test ok";
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
test.textContent = `✗ ${e.message}`;
|
setTest(`✗ ${e.message}`, false);
|
||||||
test.className = "settings-test bad";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Login with Authentik: read the hub's /config for the issuer + client id,
|
||||||
|
// then start the PKCE redirect (this navigates away and returns to init()).
|
||||||
|
const login = async () => {
|
||||||
|
const hub = url.value.trim() || state.settings.baseUrl;
|
||||||
|
if (!hub) return setTest("✗ Set the hub URL first.", false);
|
||||||
|
setTest("Contacting hub…", null);
|
||||||
|
const cfg = await oauth.fetchHubConfig(hub);
|
||||||
|
if (!cfg) {
|
||||||
|
return setTest("✗ Hub has no OIDC (/config) — paste a token, or enable OIDC on the hub.", false);
|
||||||
|
}
|
||||||
|
state.settings.baseUrl = hub;
|
||||||
|
saveSettings(state.settings); // persist before we navigate away
|
||||||
|
try {
|
||||||
|
await oauth.beginLogin(cfg);
|
||||||
|
} catch (e) {
|
||||||
|
setTest(`✗ ${e.message}`, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const logout = () => {
|
||||||
|
oauth.clearAuth();
|
||||||
|
applyToken("");
|
||||||
|
closeModal();
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const authRow = oauth.loggedIn()
|
||||||
|
? h(
|
||||||
|
"div",
|
||||||
|
{ class: "settings-auth" },
|
||||||
|
h("span", { class: "settings-test ok" }, "✓ Signed in with Authentik"),
|
||||||
|
h("button", { class: "act", onclick: logout }, "Log out"),
|
||||||
|
)
|
||||||
|
: h("button", { class: "qa-add settings-login", onclick: login }, "Login with Authentik");
|
||||||
|
|
||||||
openModal(
|
openModal(
|
||||||
h(
|
h(
|
||||||
|
|
@ -721,8 +779,14 @@ function openSettings() {
|
||||||
h("div", { class: "modal-title" }, "Settings"),
|
h("div", { class: "modal-title" }, "Settings"),
|
||||||
h("label", { class: "settings-label" }, "Hub URL"),
|
h("label", { class: "settings-label" }, "Hub URL"),
|
||||||
url,
|
url,
|
||||||
h("label", { class: "settings-label" }, "Token"),
|
h("label", { class: "settings-label" }, "Sign-in"),
|
||||||
tok,
|
authRow,
|
||||||
|
h(
|
||||||
|
"details",
|
||||||
|
{ class: "settings-manual" },
|
||||||
|
h("summary", {}, "Or paste a bearer token"),
|
||||||
|
tok,
|
||||||
|
),
|
||||||
test,
|
test,
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
|
|
@ -730,7 +794,7 @@ function openSettings() {
|
||||||
h("button", { class: "act", onclick: check }, "Test"),
|
h("button", { class: "act", onclick: check }, "Test"),
|
||||||
h("button", { class: "qa-add", onclick: save }, "Save"),
|
h("button", { class: "qa-add", onclick: save }, "Save"),
|
||||||
),
|
),
|
||||||
h("div", { class: "settings-hint" }, "The hub is your server-mode hephd. Leave the token blank if the hub runs without OIDC."),
|
h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -802,6 +866,20 @@ async function init() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OIDC: finish a redirect callback (back from Authentik), or refresh an
|
||||||
|
// existing session, so the first reload() already carries a valid bearer.
|
||||||
|
if (oauth.isCallback()) {
|
||||||
|
try {
|
||||||
|
applyToken(await oauth.completeLogin());
|
||||||
|
toast("Signed in.");
|
||||||
|
} catch (e) {
|
||||||
|
toast(`Sign-in failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
} else if (oauth.loggedIn()) {
|
||||||
|
const tok = await oauth.ensureFreshToken();
|
||||||
|
if (tok) applyToken(tok);
|
||||||
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
reload();
|
reload();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,3 +219,143 @@ export function parseRecurrenceOrNull(spec) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reverse: humanize an RRULE for display (§8.1) — a faithful port of hephd's
|
||||||
|
// `datespec::humanize_rrule`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MONTH_ABBR = [
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
];
|
||||||
|
const DAY_ABBR = { MO: "Mon", TU: "Tue", WE: "Wed", TH: "Thu", FR: "Fri", SA: "Sat", SU: "Sun" };
|
||||||
|
|
||||||
|
function everyUnit(n, singular, plural, preset) {
|
||||||
|
if (n === 1) return preset;
|
||||||
|
if (n === 2) return `every other ${singular}`;
|
||||||
|
return `every ${n} ${plural}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n) {
|
||||||
|
const tens = n % 100;
|
||||||
|
if (tens >= 11 && tens <= 13) return `${n}th`;
|
||||||
|
switch (n % 10) {
|
||||||
|
case 1: return `${n}st`;
|
||||||
|
case 2: return `${n}nd`;
|
||||||
|
case 3: return `${n}rd`;
|
||||||
|
default: return `${n}th`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekdaySet(byday) {
|
||||||
|
const days = byday.split(",").map((s) => s.trim()).sort();
|
||||||
|
return days.join(",") === "FR,MO,TH,TU,WE";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BYDAY tokens → capitalized weekday abbreviations, order preserved, or null
|
||||||
|
* if any token isn't a bare weekday (e.g. an ordinal `2MO`). */
|
||||||
|
function weekdayNames(byday) {
|
||||||
|
const out = [];
|
||||||
|
for (const tok of byday.split(",")) {
|
||||||
|
const name = DAY_ABBR[tok.trim()];
|
||||||
|
if (!name) return null;
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an RFC-5545 RRULE back into the compact phrasing `parseRecurrence`
|
||||||
|
* accepts — `daily`, `every 3 days`, `every other week`, `weekdays`,
|
||||||
|
* `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, `monthly on the
|
||||||
|
* 5th`, `yearly on Apr 15`. Any rule using parts we don't model (COUNT, UNTIL,
|
||||||
|
* ordinal BYDAY, …) is returned **verbatim** so nothing is silently hidden.
|
||||||
|
*/
|
||||||
|
export function humanizeRecurrence(rrule) {
|
||||||
|
const known = humanizeKnown(rrule);
|
||||||
|
return known === null ? rrule.trim() : known;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeKnown(rrule) {
|
||||||
|
let freq = null;
|
||||||
|
let interval = 1;
|
||||||
|
let byday = null;
|
||||||
|
let bymonth = null;
|
||||||
|
let bymonthday = null;
|
||||||
|
for (const rawPart of rrule.trim().split(";")) {
|
||||||
|
const part = rawPart.trim();
|
||||||
|
if (part === "") continue;
|
||||||
|
const eq = part.indexOf("=");
|
||||||
|
if (eq === -1) return null;
|
||||||
|
const k = part.slice(0, eq).trim().toUpperCase();
|
||||||
|
const v = part.slice(eq + 1).trim();
|
||||||
|
switch (k) {
|
||||||
|
case "FREQ": freq = v.toUpperCase(); break;
|
||||||
|
case "INTERVAL": {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < 1) return null;
|
||||||
|
interval = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "BYDAY": byday = v.toUpperCase(); break;
|
||||||
|
case "BYMONTH": {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n)) return null;
|
||||||
|
bymonth = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "BYMONTHDAY": {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n)) return null;
|
||||||
|
bymonthday = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: return null; // a part we don't render → don't risk a wrong summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (freq) {
|
||||||
|
case "DAILY":
|
||||||
|
if (byday !== null || bymonth !== null || bymonthday !== null) return null;
|
||||||
|
return everyUnit(interval, "day", "days", "daily");
|
||||||
|
case "WEEKLY": {
|
||||||
|
if (bymonth !== null || bymonthday !== null) return null;
|
||||||
|
if (byday === null) return everyUnit(interval, "week", "weeks", "weekly");
|
||||||
|
if (interval === 1 && isWeekdaySet(byday)) return "weekdays";
|
||||||
|
const names = weekdayNames(byday);
|
||||||
|
if (names === null) return null;
|
||||||
|
if (names.length === 1) {
|
||||||
|
const day = names[0];
|
||||||
|
if (interval === 1) return `every ${day}`;
|
||||||
|
if (interval === 2) return `every other ${day}`;
|
||||||
|
return `every ${interval} weeks on ${day}`;
|
||||||
|
}
|
||||||
|
const joined = names.join(", ");
|
||||||
|
if (interval === 1) return `weekly on ${joined}`;
|
||||||
|
if (interval === 2) return `every other week on ${joined}`;
|
||||||
|
return `every ${interval} weeks on ${joined}`;
|
||||||
|
}
|
||||||
|
case "MONTHLY": {
|
||||||
|
if (byday !== null || bymonth !== null) return null;
|
||||||
|
if (bymonthday === null) return everyUnit(interval, "month", "months", "monthly");
|
||||||
|
if (bymonthday < 1 || bymonthday > 31) return null;
|
||||||
|
const day = ordinal(bymonthday);
|
||||||
|
if (interval === 1) return `monthly on the ${day}`;
|
||||||
|
if (interval === 2) return `every other month on the ${day}`;
|
||||||
|
return `every ${interval} months on the ${day}`;
|
||||||
|
}
|
||||||
|
case "YEARLY": {
|
||||||
|
if (byday !== null) return null;
|
||||||
|
if (bymonth === null && bymonthday === null) {
|
||||||
|
return everyUnit(interval, "year", "years", "yearly");
|
||||||
|
}
|
||||||
|
if (bymonth < 1 || bymonth > 12 || bymonthday < 1 || bymonthday > 31) return null;
|
||||||
|
const mon = MONTH_ABBR[bymonth - 1];
|
||||||
|
if (interval === 1) return `yearly on ${mon} ${bymonthday}`;
|
||||||
|
if (interval === 2) return `every other year on ${mon} ${bymonthday}`;
|
||||||
|
return `every ${interval} years on ${mon} ${bymonthday}`;
|
||||||
|
}
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
204
heph-pwa/src/oauth.js
Normal file
204
heph-pwa/src/oauth.js
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// Browser OIDC sign-in for the PWA: Authorization Code + PKCE (RFC 7636) against
|
||||||
|
// the hub's IdP (Authentik). Unlike the CLI's device-code flow, a browser SPA
|
||||||
|
// uses a redirect + PKCE — no client secret, no polling. The resulting access
|
||||||
|
// token is the same bearer the hub's OidcVerifier checks (iss / aud=client_id /
|
||||||
|
// RS256 / exp), so once signed in the app talks to /rpc exactly as a pasted
|
||||||
|
// token would. We also keep a refresh token (offline_access) to renew silently.
|
||||||
|
//
|
||||||
|
// Zero-config: the hub serves GET /config -> { issuer, client_id }, so the app
|
||||||
|
// learns the IdP without the user typing anything when served from the hub.
|
||||||
|
|
||||||
|
const AUTH_KEY = "heph-pwa:auth"; // localStorage: { issuer, clientId, access, refresh, expiresAt }
|
||||||
|
const PKCE_KEY = "heph-pwa:pkce"; // sessionStorage: in-flight { verifier, state, ... }
|
||||||
|
|
||||||
|
// --- persistence ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function loadAuth() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(AUTH_KEY) || "null");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveAuth(a) {
|
||||||
|
localStorage.setItem(AUTH_KEY, JSON.stringify(a));
|
||||||
|
}
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem(AUTH_KEY);
|
||||||
|
}
|
||||||
|
export function loggedIn() {
|
||||||
|
return !!loadAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PKCE helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
function b64url(bytes) {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
function randomString(nbytes = 32) {
|
||||||
|
const a = new Uint8Array(nbytes);
|
||||||
|
crypto.getRandomValues(a);
|
||||||
|
return b64url(a);
|
||||||
|
}
|
||||||
|
async function challengeOf(verifier) {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
||||||
|
return b64url(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The redirect URI is the app's own base directory (query/hash stripped), so it
|
||||||
|
// is stable across the login start and the callback. Register this exact value
|
||||||
|
// (with trailing slash) on the Authentik provider, e.g. https://heph.ops.eblu.me/.
|
||||||
|
function redirectUri() {
|
||||||
|
return new URL(".", location.href).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- discovery --------------------------------------------------------------
|
||||||
|
|
||||||
|
async function discover(issuer) {
|
||||||
|
const url = issuer.replace(/\/+$/, "") + "/.well-known/openid-configuration";
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`OIDC discovery failed (HTTP ${r.status}).`);
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.authorization_endpoint || !d.token_endpoint) {
|
||||||
|
throw new Error("OIDC discovery is missing authorization/token endpoints.");
|
||||||
|
}
|
||||||
|
return { authorize: d.authorization_endpoint, token: d.token_endpoint };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the hub's public OIDC params. Returns { issuer, clientId } or null. */
|
||||||
|
export async function fetchHubConfig(baseUrl) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(baseUrl.replace(/\/+$/, "") + "/config");
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.issuer || !d.client_id) return null;
|
||||||
|
return { issuer: d.issuer, clientId: d.client_id };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- login (redirect away) --------------------------------------------------
|
||||||
|
|
||||||
|
/** Begin a PKCE login: stash the verifier+state and redirect to the IdP. */
|
||||||
|
export async function beginLogin({ issuer, clientId }) {
|
||||||
|
const { authorize } = await discover(issuer);
|
||||||
|
const verifier = randomString(48);
|
||||||
|
const state = randomString(16);
|
||||||
|
const redirect_uri = redirectUri();
|
||||||
|
sessionStorage.setItem(
|
||||||
|
PKCE_KEY,
|
||||||
|
JSON.stringify({ verifier, state, issuer, clientId, redirect_uri }),
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri,
|
||||||
|
scope: "openid offline_access",
|
||||||
|
state,
|
||||||
|
code_challenge: await challengeOf(verifier),
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
});
|
||||||
|
location.assign(`${authorize}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- callback (back from the IdP) -------------------------------------------
|
||||||
|
|
||||||
|
/** True when the current URL is an OAuth redirect callback. */
|
||||||
|
export function isCallback() {
|
||||||
|
const p = new URLSearchParams(location.search);
|
||||||
|
return (p.has("code") && p.has("state")) || p.has("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exchange the callback code for tokens. Always cleans the URL. Returns the
|
||||||
|
* access token on success; throws on failure. */
|
||||||
|
export async function completeLogin() {
|
||||||
|
const p = new URLSearchParams(location.search);
|
||||||
|
const cleanUrl = () => history.replaceState(null, "", redirectUri());
|
||||||
|
let pkce = null;
|
||||||
|
try {
|
||||||
|
pkce = JSON.parse(sessionStorage.getItem(PKCE_KEY) || "null");
|
||||||
|
} catch {
|
||||||
|
pkce = null;
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(PKCE_KEY);
|
||||||
|
|
||||||
|
if (p.get("error")) {
|
||||||
|
cleanUrl();
|
||||||
|
throw new Error(p.get("error_description") || p.get("error"));
|
||||||
|
}
|
||||||
|
if (!pkce || pkce.state !== p.get("state")) {
|
||||||
|
cleanUrl();
|
||||||
|
throw new Error("Login state mismatch — please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await discover(pkce.issuer);
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: p.get("code"),
|
||||||
|
client_id: pkce.clientId,
|
||||||
|
redirect_uri: pkce.redirect_uri,
|
||||||
|
code_verifier: pkce.verifier,
|
||||||
|
});
|
||||||
|
const r = await fetch(token, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
cleanUrl();
|
||||||
|
if (!r.ok) throw new Error(`Token exchange failed (HTTP ${r.status}).`);
|
||||||
|
const t = await r.json();
|
||||||
|
saveAuth({
|
||||||
|
issuer: pkce.issuer,
|
||||||
|
clientId: pkce.clientId,
|
||||||
|
access: t.access_token,
|
||||||
|
refresh: t.refresh_token || null,
|
||||||
|
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
|
||||||
|
});
|
||||||
|
return t.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- token lifecycle --------------------------------------------------------
|
||||||
|
|
||||||
|
/** Return a usable access token, refreshing if it is near expiry (or `force`).
|
||||||
|
* Returns null when not logged in or when a refresh fails (caller re-prompts). */
|
||||||
|
export async function ensureFreshToken(force = false) {
|
||||||
|
const a = loadAuth();
|
||||||
|
if (!a) return null;
|
||||||
|
const stillFresh = a.expiresAt - Date.now() > 60_000;
|
||||||
|
if (!force && stillFresh) return a.access;
|
||||||
|
if (!a.refresh) return force ? null : a.access;
|
||||||
|
try {
|
||||||
|
const { token } = await discover(a.issuer);
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: a.refresh,
|
||||||
|
client_id: a.clientId,
|
||||||
|
});
|
||||||
|
const r = await fetch(token, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
// Refresh token rejected (expired/revoked) — drop the session so the UI
|
||||||
|
// shows "signed out" and the user can log in again.
|
||||||
|
clearAuth();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const t = await r.json();
|
||||||
|
saveAuth({
|
||||||
|
...a,
|
||||||
|
access: t.access_token,
|
||||||
|
refresh: t.refresh_token || a.refresh,
|
||||||
|
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
|
||||||
|
});
|
||||||
|
return t.access_token;
|
||||||
|
} catch {
|
||||||
|
// Network blip — keep the (possibly stale) token; the RPC layer will retry.
|
||||||
|
return a.access;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,8 +49,10 @@ export class Client {
|
||||||
return !!this.settings.baseUrl;
|
return !!this.settings.baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Low-level call: returns the `result` value, or throws RpcError. */
|
/** Low-level call: returns the `result` value, or throws RpcError. On a 401,
|
||||||
async call(method, params = {}) {
|
* tries the optional `settings.refresh()` hook once (OIDC silent refresh) and
|
||||||
|
* retries before surfacing the error. */
|
||||||
|
async call(method, params = {}, _retried = false) {
|
||||||
if (!this.configured) {
|
if (!this.configured) {
|
||||||
throw new RpcError("No hub configured — open Settings and set the hub URL.", 0);
|
throw new RpcError("No hub configured — open Settings and set the hub URL.", 0);
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +70,16 @@ export class Client {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0);
|
throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0);
|
||||||
}
|
}
|
||||||
if (resp.status === 401) throw new RpcError("Unauthorized — set or refresh your token.", 401);
|
if (resp.status === 401) {
|
||||||
|
if (!_retried && typeof this.settings.refresh === "function") {
|
||||||
|
const fresh = await this.settings.refresh();
|
||||||
|
if (fresh) {
|
||||||
|
this.settings.token = fresh;
|
||||||
|
return this.call(method, params, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RpcError("Unauthorized — sign in again (Settings).", 401);
|
||||||
|
}
|
||||||
if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403);
|
if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403);
|
||||||
if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status);
|
if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,26 @@ body {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
}
|
}
|
||||||
|
.settings-login {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.settings-auth {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.settings-auth .settings-test {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.settings-manual > summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.settings-manual[open] > summary {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Search --- */
|
/* --- Search --- */
|
||||||
.search-pane {
|
.search-pane {
|
||||||
|
|
|
||||||
|
|
@ -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-v3";
|
const CACHE = "heph-pwa-v4";
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|
@ -9,6 +9,7 @@ const SHELL = [
|
||||||
"./manifest.webmanifest",
|
"./manifest.webmanifest",
|
||||||
"./src/app.js",
|
"./src/app.js",
|
||||||
"./src/rpc.js",
|
"./src/rpc.js",
|
||||||
|
"./src/oauth.js",
|
||||||
"./src/quickadd.js",
|
"./src/quickadd.js",
|
||||||
"./src/datespec.js",
|
"./src/datespec.js",
|
||||||
"./src/fmt.js",
|
"./src/fmt.js",
|
||||||
|
|
@ -31,8 +32,10 @@ self.addEventListener("activate", (e) => {
|
||||||
self.addEventListener("fetch", (e) => {
|
self.addEventListener("fetch", (e) => {
|
||||||
const req = e.request;
|
const req = e.request;
|
||||||
// Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin)
|
// Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin)
|
||||||
// goes straight to the network.
|
// goes straight to the network. Skip URLs with a query string too, so the OAuth
|
||||||
if (req.method !== "GET" || new URL(req.url).origin !== self.location.origin) {
|
// redirect callback (`/?code=…&state=…`) is never cached or served from cache.
|
||||||
|
const u = new URL(req.url);
|
||||||
|
if (req.method !== "GET" || u.origin !== self.location.origin || u.search) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { parseDate, parseRecurrence, toEpochMs } from "../src/datespec.js";
|
import {
|
||||||
|
parseDate,
|
||||||
|
parseRecurrence,
|
||||||
|
humanizeRecurrence,
|
||||||
|
toEpochMs,
|
||||||
|
} from "../src/datespec.js";
|
||||||
import { parse } from "../src/quickadd.js";
|
import { parse } from "../src/quickadd.js";
|
||||||
|
|
||||||
const d = (y, m, day) => new Date(y, m - 1, day);
|
const d = (y, m, day) => new Date(y, m - 1, day);
|
||||||
|
|
@ -58,6 +63,60 @@ test("recurrence natural language", () => {
|
||||||
assert.throws(() => parseRecurrence("every blue moon"));
|
assert.throws(() => parseRecurrence("every blue moon"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parity with hephd's `humanize_rrule` unit tests (datespec.rs).
|
||||||
|
test("humanize inverts the natural-language forms", () => {
|
||||||
|
const cases = [
|
||||||
|
["FREQ=DAILY", "daily"],
|
||||||
|
["FREQ=DAILY;INTERVAL=2", "every other day"],
|
||||||
|
["FREQ=DAILY;INTERVAL=3", "every 3 days"],
|
||||||
|
["FREQ=WEEKLY", "weekly"],
|
||||||
|
["FREQ=WEEKLY;INTERVAL=2", "every other week"],
|
||||||
|
["FREQ=MONTHLY", "monthly"],
|
||||||
|
["FREQ=MONTHLY;INTERVAL=6", "every 6 months"],
|
||||||
|
["FREQ=YEARLY", "yearly"],
|
||||||
|
["FREQ=WEEKLY;BYDAY=FR", "every Fri"],
|
||||||
|
["FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"],
|
||||||
|
["FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"],
|
||||||
|
["FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"],
|
||||||
|
["FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"],
|
||||||
|
["FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"],
|
||||||
|
["FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"],
|
||||||
|
["FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"],
|
||||||
|
["FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"],
|
||||||
|
["FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"],
|
||||||
|
];
|
||||||
|
for (const [rrule, want] of cases) {
|
||||||
|
assert.equal(humanizeRecurrence(rrule), want, `humanizing ${rrule}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("humanize round-trips the re-typeable forms through parseRecurrence", () => {
|
||||||
|
for (const input of [
|
||||||
|
"every 3 days",
|
||||||
|
"every other day",
|
||||||
|
"every other wed",
|
||||||
|
"weekdays",
|
||||||
|
"every fri",
|
||||||
|
"every 6 months",
|
||||||
|
"every 2 weeks",
|
||||||
|
]) {
|
||||||
|
const rrule = parseRecurrence(input);
|
||||||
|
assert.equal(parseRecurrence(humanizeRecurrence(rrule)), rrule, `round-trip ${input}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("humanize falls back to raw for unmodeled rules", () => {
|
||||||
|
for (const raw of [
|
||||||
|
"FREQ=DAILY;COUNT=5",
|
||||||
|
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
|
||||||
|
"FREQ=MONTHLY;BYDAY=2MO",
|
||||||
|
"FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||||
|
"not an rrule at all",
|
||||||
|
]) {
|
||||||
|
assert.equal(humanizeRecurrence(raw), raw, `passthrough ${raw}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
|
// quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
|
||||||
const QTODAY = d(2026, 6, 3);
|
const QTODAY = d(2026, 6, 3);
|
||||||
const PROJECTS = [
|
const PROJECTS = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue