diff --git a/CHANGELOG.md b/CHANGELOG.md index 9799e3f..309e228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,17 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [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 diff --git a/Cargo.lock b/Cargo.lock index be8f974..73a4618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2196,7 +2196,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heph" -version = "0.0.0" +version = "1.2.2" dependencies = [ "anyhow", "chrono", @@ -2210,7 +2210,7 @@ dependencies = [ [[package]] name = "heph-core" -version = "0.0.0" +version = "1.2.2" dependencies = [ "chrono", "proptest", @@ -2227,7 +2227,7 @@ dependencies = [ [[package]] name = "heph-quickadd" -version = "0.0.0" +version = "1.2.2" dependencies = [ "anyhow", "chrono", @@ -2243,7 +2243,7 @@ dependencies = [ [[package]] name = "heph-tui" -version = "0.0.0" +version = "1.2.2" dependencies = [ "anyhow", "chrono", @@ -2259,7 +2259,7 @@ dependencies = [ [[package]] name = "hephd" -version = "0.0.0" +version = "1.2.2" dependencies = [ "anyhow", "apple-native-keyring-store", diff --git a/Cargo.toml b/Cargo.toml index e24c881..9390f25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ [workspace.package] edition = "2021" -version = "0.0.0" +version = "1.2.2" license = "LicenseRef-Proprietary" publish = false authors = ["Erich Blume "] diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index e60a969..3ced067 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -9,7 +9,7 @@ use anyhow::Result; use chrono::NaiveDate; use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS}; -use crate::backend::{Backend, Project, SearchHit, SyncStatus}; +use crate::backend::{Backend, Project, SearchHit}; use crate::fmt::{days_overdue, today_local}; /// How the task list is ordered (toggled in the UI, §8.1). @@ -433,8 +433,6 @@ pub struct App { undo_stack: Vec, redo_stack: Vec, pub status: String, - /// Latest sync health for the status-line indicator (refreshed on a tick). - pub sync: SyncStatus, pub should_quit: bool, } @@ -474,23 +472,12 @@ impl App { undo_stack: Vec::new(), redo_stack: Vec::new(), status: String::new(), - sync: SyncStatus::default(), should_quit: false, }; app.reload(); - app.refresh_sync(); 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). pub fn task_pane_title(&self) -> String { match self.sidebar.get(self.sidebar_cursor) { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index a52fd90..88beaaa 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -22,35 +22,6 @@ pub struct SearchHit { 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, - /// Pending merge conflicts awaiting resolution. - #[serde(default)] - pub conflicts: usize, - /// Observed health of the background sync loop (spoke only). - #[serde(default)] - pub health: Option, -} - -/// 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, - /// Epoch ms of the last attempt (success or failure), if any. - pub last_attempt_ms: Option, - /// The last error message, cleared on the next success. - pub last_error: Option, - /// Whether the most recent attempt failed authentication (needs re-login). - #[serde(default)] - pub auth_failure: bool, -} - /// Everything the agenda surface asks of the daemon. pub trait Backend { /// All project nodes (for the sidebar), title-sorted. @@ -85,11 +56,6 @@ pub trait Backend { /// 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. fn context_of(&mut self, task_id: &str) -> Result>; - /// 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 { - Ok(SyncStatus::default()) - } // --- triage mutations (T2) --- @@ -204,11 +170,6 @@ impl Backend for ClientBackend { .map(|l| l.dst_id)) } - fn sync_status(&mut self) -> Result { - let v = self.call("sync.status", json!({}))?; - Ok(serde_json::from_value(v)?) - } - fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { self.call("task.set_state", json!({ "id": task_id, "state": state }))?; Ok(()) diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 8c49ac9..3fc7373 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -25,29 +25,6 @@ pub fn today_local() -> NaiveDate { 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 /// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1). pub fn days_overdue(do_date: Option, today: NaiveDate) -> i64 { @@ -125,19 +102,6 @@ mod tests { 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] fn project_color_is_stable_distinct_and_neutral_when_absent() { assert_eq!(project_color(None), Color::DarkGray); diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b672d7b..27be96e 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -61,22 +61,14 @@ fn run( mut app: App, socket: &std::path::Path, ) -> 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 { terminal.draw(|f| ui::render(f, &app))?; - if event::poll(tick)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - if let Some(action) = handle_key(&mut app, key) { - perform(terminal, &mut app, socket, action)?; - } + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + 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 { return Ok(()); diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6e15453..e6043b8 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -3,7 +3,7 @@ use heph_core::Attention; use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, + layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ @@ -14,8 +14,8 @@ use ratatui::{ }; use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode}; -use crate::backend::{Backend, SyncStatus}; -use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local}; +use crate::backend::Backend; +use crate::fmt::{fmt_date, project_color, today_local}; // Task-pane gestures (the focused pane shows its own hints, §8.1). const HINTS: &str = @@ -538,130 +538,5 @@ fn render_status(frame: &mut Frame, app: &App, area: Rect) { } else { Style::default().fg(Color::DarkGray) }; - 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, `⟳ ` 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> { - 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 { - Span::styled("⚠ auth", 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"); - - // 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"); - } + frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); } diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 84ca739..917f602 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -98,12 +98,6 @@ fn agenda_renders_views_projects_and_tasks() { // 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("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] diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 30c5d5a..59826ac 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -10,10 +10,9 @@ //! ops with the configured hub (tech-spec §6.1, §12). use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use anyhow::Result; -use serde::Serialize; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -33,23 +32,6 @@ struct SpokeAuth { 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, - /// When we last completed one without error (the "last synced" time). - last_success_ms: Option, - /// The last error message, cleared on the next success. - last_error: Option, - /// 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. #[derive(Clone)] struct Ctx { @@ -61,41 +43,6 @@ struct Ctx { auth: Option, /// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card). self_update: Option, - /// Live sync health, shared between the background loop and `sync.status`. - sync_health: Arc>, -} - -/// 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::() - .and_then(|re| re.status()) - .is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED) -} - -/// Fold one exchange outcome into the shared [`SyncHealth`]. -fn record_sync_outcome(health: &Arc>, result: &Result) { - let now = now_ms(); - let mut h = 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) => { - h.auth_failure = is_auth_error(e); - h.last_error = Some(e.to_string()); - } - } } impl Ctx { @@ -140,7 +87,6 @@ impl Daemon { .expect("building the daemon HTTP client"), auth: None, self_update: None, - sync_health: Arc::new(Mutex::new(SyncHealth::default())), }, } } @@ -224,10 +170,7 @@ impl Daemon { loop { tick.tick().await; let bearer = ctx.bearer().await; - let result = - sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx.sync_health, &result); - match result { + match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await { Ok(report) => tracing::debug!(?report, "background sync"), Err(e) => tracing::warn!("background sync failed: {e}"), } @@ -322,9 +265,7 @@ async fn sync_now(ctx: &Ctx) -> Result { }); }; let bearer = ctx.bearer().await; - let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx.sync_health, &result); - match result { + match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await { Ok(report) => Ok(json!(report)), Err(e) => Err(RpcError { code: INTERNAL_ERROR, @@ -333,28 +274,11 @@ async fn sync_now(ctx: &Ctx) -> Result { } } -/// `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). +/// `sync.status` — the hub url and the current per-hub cursors. async fn sync_status(ctx: &Ctx) -> Result { - // 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 { - return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts })); + return Ok(json!({ "hub_url": Value::Null })); }; - let store = ctx.store.clone(); let hub = hub_url.clone(); let cursors = tokio::task::spawn_blocking(move || { @@ -367,17 +291,5 @@ async fn sync_status(ctx: &Ctx) -> Result { message: format!("sync.status task failed: {e}"), })? .map_err(RpcError::from)?; - - let health = ctx - .sync_health - .lock() - .expect("sync_health mutex poisoned") - .clone(); - - Ok(json!({ - "hub_url": hub_url, - "cursors": cursors, - "conflicts": conflicts, - "health": health, - })) + Ok(json!({ "hub_url": hub_url, "cursors": cursors })) } diff --git a/docs/changelog.d/+sync-age-seconds.feature.md b/docs/changelog.d/+sync-age-seconds.feature.md deleted file mode 100644 index cf453c2..0000000 --- a/docs/changelog.d/+sync-age-seconds.feature.md +++ /dev/null @@ -1 +0,0 @@ -heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing. diff --git a/docs/how-to/host-heph-pwa.md b/docs/how-to/host-heph-pwa.md index dc22359..63b3d76 100644 --- a/docs/how-to/host-heph-pwa.md +++ b/docs/how-to/host-heph-pwa.md @@ -102,11 +102,6 @@ app defaults its hub URL to its own origin. (A manual **Bearer token** field remains as a fallback for hubs without OIDC, or for pasting a one-off token.) - > Re-prompted for login too often? The fix is the Authentik provider's - > **refresh token validity**, not the app — see the token-lifetime note in - > [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's - > 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 diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md index a5b56ea..a0f7706 100644 --- a/docs/how-to/set-up-sync-hub.md +++ b/docs/how-to/set-up-sync-hub.md @@ -51,26 +51,6 @@ need: - **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/` - **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` **Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`), @@ -118,16 +98,10 @@ and background-syncs on its interval. ## 4. Verify ```bash -heph sync --status # hub url, last push/pull cursors, sync health +heph sync --status # last push/pull cursors, hub url 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. ## Current gaps (finalized by the blumeops deployment)